自定义地图

背景

最近接到一个项目，需要做一个网页，内容是一个镇的影像图，鼠标移到每个村庄，相应村庄需要高亮，点击后需要跳转到该村庄的详情页。

技术预研

echart 有地图组件支持地图的展示，比如 美国地图。所以把影像图作为底图，然后在上面使用 echart 展示地图，就能达到效果。

问题是地图展示需要 geo 数据。所以网上搜寻了下，比如像 阿里云的数据可视化平台 最小支持市级（区县边界），而 geojson.cn 有提供支持县级（街道边界）的数据，却是按需制作和收费的。但我们这里的数据需要是镇级别（村边界）的，https://hxkj.vip/map/ 里有说可以定制村边界，但联系了对方 qq 后，没有回应，所以只能自己去制作村级别的 geo 数据。

制作 geo 数据

由于影像图里有用特定颜色的线条标注出了村边界，我们先通过识别颜色来识别边界。

Copy

let img = new window.Image();
img.crossOrigin = `Anonymous`;
img.src = "./image-1.jpg";
img.onload = function () {
canvas = document.getElementById("myCanvas");
canvas.width = img.width;
canvas.height = img.height;
let ctx = canvas.getContext(
"2d"
);
ctx.drawImage(img, 0, 0);

const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
data = imgData.data;

for (let i = 0; i < canvas.width; ++i) {
for (let j = 0; j < canvas.height; ++j) {
let cols = canvas.width;
let c = extractPixelColor(cols, j, i);

const key = [c.red, c.green, c.blue].join('_');

if (c.red >= 200 || c.red <= 180
|| c.green >= 10
|| c.blue >= 249 || c.blue <= 200) {
continue;
}

appendMark({
left: i,
top: j
})
}
}
};

function appendMark(position) {
const mark = document.createElement('div');
mark.style.width = '6px';
mark.style.height = '6px';
mark.style.borderRadius = '50%';
mark.style.position = 'absolute';
mark.style.top = `${position.top}px`;
mark.style.left = `${position.left}px`;
mark.style.background = '#000';
mark.classList.add('mark');

mark.onclick = function() {
this.classList.add('active');
mark.dataset.index = index;
index++;
};

const container = document.getElementById('myCanvasContainer');
container.appendChild(mark);
}

const extractPixelColor = (cols, x, y) => {
//To get the exact pixel, the logic is to multiply total columns in this image with the row position of this pixel and then add the column position of this pixed
let pixel = cols * x + y;
//To get the array position in the entire image data array, simply multiply your pixel position by 4 (since each pixel will have its own r,g,b,a in that order)
let position = pixel * 4;
//the rgba value of current pixel will be the following
return {
red: data[position],
green: data[position + 1],
blue: data[position + 2],
alpha: data[position + 3],
};
};

上面的代码通过 getImageData 获取图片数据，然后遍历图片的每个像素，找到边界的特定颜色，就在网页中的该位置生成一个标注点。如下图。

我们选定某个村，然后绕着顺时针方向去标注，就得到了该村的边界。

上面代码中这段代码的作用是可以对颜色的判断有一点包容性。

Copy
if (c.red >= 200 || c.red <= 180
|| c.green >= 10
|| c.blue >= 249 || c.blue <= 200) {
continue;
}

Copy
const data = [...document.querySelectorAll('.active')]
.map((item) => { return {
left: parseInt(item.style.left),
top: parseInt(item.style.top),
index: parseInt(item.dataset.index)}
})
.sort((a, b) => {
return a.index - b.index;
});

然后执行这段代码就可以得到组成某个村的边界的所有点的数据，每个点的数据包含left和top，代表该点相对于影像图的位置。

有了这些数据后，我们需要计算出每个点的经纬度。打开 边界生成器 然后导航到需要绘制的村的大致位置。

记得切换地图到卫星模式，这样可以更好地辨认出具体的村庄。

使用左侧工具栏里的矩形标注功能，标注出村庄所在的位置。

点击右侧的查看 GeoJSON

就能得到四个位置的经纬度。

比如我们得到左上角和右下角的经纬度。

Copy
const containerCoordinates = [
[
121.16169327,
37.50491774
],

[
121.22931442,
37.43753236
]
]

Copy
const CANVAS_WIDTH = 2000;
const CANVAS_HEIGHT = 2666;
const convert = ([left, top]) => {
const x = containerCoordinates[0][0] + (containerCoordinates[1][0] - containerCoordinates[0][0]) * (left / CANVAS_WIDTH);
const y = containerCoordinates[0][1] - (containerCoordinates[0][1] - containerCoordinates[1][1]) * (top / CANVAS_HEIGHT);

return [x, y];
}

data.push(data[0]);
const coordinates = data.map((item) => {
return convert([item.left, item.top]);
});

再把之前的数据通过这样的转换，就能得到每个点的经纬度数据。

本质上这个方法是有问题的，因为经纬度是相对地球球体来说的，而二维平面上的位置和经纬度之间需要做投影，也就是 墨卡托投影 。echart 有 convertFromPixel 方法可以从二位平面的位置转换成经纬度。

Copy
const myChart = echarts.init(document.getElementById('main'));
myChart.setOption({
geo: {
left: 0,
top: 0,
right: 0,
bottom: 0,
boundingCoords: [
[
121.16169327,
37.50491774
],
[
121.22931442,
37.43753236
]
]
}
});
const convert = ([left, top]) => {
return myChart.convertFromPixel({geoIndex: 0}, [left, top]);
}
data.push(data[0]);
const coordinates = data.map((item) => {
return convert([item.left, item.top]);
});

实际上，一开始我并没使用墨卡托投影转换，而是直接使用第一种方式，发现也大差不差，可能是因为要展示的区域非常小，所以几乎就等于二位平面。

地图展示

Copy
const data = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"id": "01",
"properties": { "name": "丁村" },
"geometry": {
"type": "Polygon",
"coordinates": coordinates
}
}
]
}
echarts.registerMap('village', data);

const myChart = echarts.init(document.getElementById('main'));
myChart.setOption({
geo: {
map: 'village',
itemStyle: {
areaColor: 'transparent',
borderColor: '#c004f7',
borderWidth: 0
},
label: {
formatter({name}) {
return '';
}
}
}
});

注册地图数据，并使用 echart 实例化地图。

但上面有个问题是，echart 地图并没有和底图完全重合。解决方式是可以使用 layoutCenter 和 layoutSize 来微调下，通过 layoutCenter 属性定义地图中心在容器中的位置，layoutSize 定义地图的大小。

Copy
myChart.setOption({
geo: {
layoutCenter: ['49.5%', '51.5%'],
layoutSize: 2200,
}
});