背景#
最近接到一个项目,需要做一个网页,内容是一个镇的影像图,鼠标移到每个村庄,相应村庄需要高亮,点击后需要跳转到该村庄的详情页。
技术预研#
echart 有地图组件支持地图的展示,比如 美国地图。所以把影像图作为底图,然后在上面使用 echart 展示地图,就能达到效果。
问题是地图展示需要 geo 数据。所以网上搜寻了下,比如像 阿里云的数据可视化平台 最小支持市级(区县边界),而 geojson.cn 有提供支持县级(街道边界)的数据,却是按需制作和收费的。但我们这里的数据需要是镇级别(村边界)的,https://hxkj.vip/map/ 里有说可以定制村边界,但联系了对方 qq 后,没有回应,所以只能自己去制作村级别的 geo 数据。
制作 geo 数据#
由于影像图里有用特定颜色的线条标注出了村边界,我们先通过识别颜色来识别边界。
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) => {
let pixel = cols * x + y;
let position = pixel * 4;
return {
red: data[position],
green: data[position + 1],
blue: data[position + 2],
alpha: data[position + 3],
};
};
上面的代码通过 getImageData 获取图片数据,然后遍历图片的每个像素,找到边界的特定颜色,就在网页中的该位置生成一个标注点。如下图。
我们选定某个村,然后绕着顺时针方向去标注,就得到了该村的边界。
上面代码中这段代码的作用是可以对颜色的判断有一点包容性。
if (c.red >= 200 || c.red <= 180
|| c.green >= 10
|| c.blue >= 249 || c.blue <= 200) {
continue;
}
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
就能得到四个位置的经纬度。
比如我们得到左上角和右下角的经纬度。
const containerCoordinates = [
[
121.16169327,
37.50491774
],
[
121.22931442,
37.43753236
]
]
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 方法可以从二位平面的位置转换成经纬度。
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]);
});
实际上,一开始我并没使用墨卡托投影转换,而是直接使用第一种方式,发现也大差不差,可能是因为要展示的区域非常小,所以几乎就等于二位平面。
地图展示#
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 定义地图的大小。
myChart.setOption({
geo: {
layoutCenter: ['49.5%', '51.5%'],
layoutSize: 2200,
}
});