写在前面
本文尝试用原生JS
实现任意区域的地图、类似echart
的map图
、具体效果如下。
echart
的使用
我们使用echart
生成地图一般只要三步就可以了: 先引入echart.js
、再去阿里云下载对应区域的geoJson
、 最后调用echart
的api
就可以生成地图了。
js
const geoJson = {}; // 地图json
echarts.registerMap('ZJ', geoJson);
var dom = document.getElementById('chart-container');
var myChart = echarts.init(dom, null, {
renderer: 'canvas',
useDirtyRect: false
});
myChart.setOption({
// 地理坐标系组件
geo: {
map: 'ZJ',
},
});
那么给你一个geoJson
、如何生成这样一个地图呢?
实现难点
1.
geoJson
到底是什么、应该如何解析?地图的本质到底是绘点还是绘线?
2. 鼠标交互怎么实现的、怎么知道鼠标在哪个区域的?
只要解决这两个问题、基本就能绘制出地图了
首先解决第一个问题、
geoJson
本质上是区域边界的经纬度、也就是N个点的经纬度。
我们只需要将每个点绘制出来、然后用直线连接起来就可以了。
但是这里存在一个经纬度换算到canvas
坐标的问题。
第二个问题、任何区域主要包含两部分:边界和区域内部,由于绘制的边界很细、暂时可以不考虑鼠标落在边界的情况、所以这个问题就转换成了如何检测任意一点是否落在不规则区域内、方法很多、大家可以参考这篇文章。
实际上echart
考虑了鼠标落在边界的情况、也就是要计算任意一点是否落在一条宽度为N
的直线上。感兴趣可以看看这个传送门(PS:也是参考echart
实现的、文本并未考虑这一问题)
代码分析
- 首先要解决坐标转化的问题、本质上就是计算
geoJson
经纬度所在区域的最大经度、纬度
和最小经度、纬度
、形成一个矩形、然后映射到canvas矩形内
, 具体代码如下:
js
function applyTransform() {
const aspectRatio = 0.75;
const ratio = 0.8;
// 深拷贝一份地图json
var _geoJson = clone(geoJson);
shapes = _geoJson.features.map(item => Object.assign(item.properties, item.geometry));
transformInfoRaw = { // 默认tansform
"x": 0,
"y": 0,
"scaleX": 1,
"scaleY": 1,
}
var longitude = [Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER]; // 经度最小值、最大值
var latitude = [Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER]; // 纬度最小值、最大值
for (let i = 0; i < shapes.length; i++) {
if (shapes[i].type == 'MultiPolygon') {
for (var j = 0; j < shapes[i].coordinates.length; j++) { // 计算rect边界
var coor = shapes[i].coordinates[j][0];
for (var k = 0; k < coor.length; k++) {
var coorK = coor[k];
longitude[0] = Math.min(coorK[0], longitude[0]);
latitude[0] = Math.min(coorK[1], latitude[0]);
longitude[1] = Math.max(coorK[0], longitude[1]);
latitude[1] = Math.max(coorK[1], latitude[1]);
}
}
} else {
var coor = shapes[i].coordinates[0];
for (var k = 0; k < coor.length; k++) {
var coorK = coor[k];
longitude[0] = Math.min(coorK[0], longitude[0]);
latitude[0] = Math.min(coorK[1], latitude[0]);
longitude[1] = Math.max(coorK[0], longitude[1]);
latitude[1] = Math.max(coorK[1], latitude[1]);
}
}
}
// 经纬度的rect
var rect = {
x: longitude[0],
y: latitude[0],
width: longitude[1] - longitude[0],
height: latitude[1] - latitude[0]
}
// y坐标inverted
rect.y = -rect.y - rect.height;
// canvas的rect
var rectWin = {
x: 0,
y: 0,
width: NaN,
height: NaN,
}
var _ratioRect = rect.width / rect.height * aspectRatio; // 进行缩放
var _rationWindow = window.innerWidth / window.innerHeight; // 计算canvas宽高比
if (_ratioRect > _rationWindow) {
rectWin.width = window.innerWidth * ratio;
} else {
rectWin.height = window.innerHeight * ratio;
}
if (isNaN(rectWin.width)) {
rectWin.width = rectWin.height * _ratioRect;
}
if (isNaN(rectWin.height)) {
rectWin.height = rectWin.width / _ratioRect;
}
rectWin.x = (window.innerWidth - rectWin.width) / 2;
rectWin.y = (window.innerHeight - rectWin.height) / 2;
// 根据rectWin和rect 计算transform (如何将rect 转换成rectWin)
var scale = [rectWin.width / rect.width, rectWin.height / rect.height];
// 先将rect 平移到原点
transformInfoRaw.x -= rect.x;
transformInfoRaw.y -= rect.y;
// rect 进行缩放
transformInfoRaw.x *= scale[0];
transformInfoRaw.y *= scale[1];
transformInfoRaw.scaleX *= scale[0];
transformInfoRaw.scaleY *= scale[1];
// 先将rect 平移到rectWindow的位置
transformInfoRaw.x += rectWin.x;
transformInfoRaw.y += rectWin.y;
// y坐标inverted why
transformInfoRaw.scaleY = -transformInfoRaw.scaleY;
// 根据transform 计算新的坐标
for (let i = 0; i < shapes.length; i++) {
var minX = Number.MAX_SAFE_INTEGER;
var maxX = Number.MIN_SAFE_INTEGER;
var minY = Number.MAX_SAFE_INTEGER;
var maxY = Number.MIN_SAFE_INTEGER;
if (shapes[i].type == 'MultiPolygon') {
for (var j = 0; j < shapes[i].coordinates.length; j++) { // 坐标转化
var coor = shapes[i].coordinates[j][0];
for (var k = 0; k < coor.length; k++) {
coor[k][0] = (coor[k][0] * transformInfoRaw.scaleX + transformInfoRaw.x) * dpr;
coor[k][1] = (coor[k][1] * transformInfoRaw.scaleY + transformInfoRaw.y) * dpr;
// coor[k][0] = (rectWin.x + (coor[k][0] - rect.x) / rect.width * rectWin.width) * dpr;
// coor[k][1] = (rectWin.y + (1 - (coor[k][1] - rect.y) / rect.height) * rectWin.height) * dpr;
// 统计当前区域的边界、方便后面的区域检测
minX = Math.min(minX, coor[k][0]);
minY = Math.min(minY, coor[k][1]);
maxX = Math.max(maxX, coor[k][0]);
maxY = Math.max(maxY, coor[k][1]);
}
}
} else {
var coor = shapes[i].coordinates[0];
for (var k = 0; k < coor.length; k++) {
coor[k][0] = (coor[k][0] * transformInfoRaw.scaleX + transformInfoRaw.x) * dpr;
coor[k][1] = (coor[k][1] * transformInfoRaw.scaleY + transformInfoRaw.y) * dpr;
// coor[k][0] = (rectWin.x + (coor[k][0] - rect.x) / rect.width * rectWin.width) * dpr;
// coor[k][1] = (rectWin.y + (1 - (coor[k][1] - rect.y) / rect.height) * rectWin.height) * dpr;
// 统计当前区域的边界、方便后面的区域检测
minX = Math.min(minX, coor[k][0]);
minY = Math.min(minY, coor[k][1]);
maxX = Math.max(maxX, coor[k][0]);
maxY = Math.max(maxY, coor[k][1]);
}
}
// 记录当前区域的边界
shapes[i].minX = minX;
shapes[i].maxX = maxX;
shapes[i].minY = minY;
shapes[i].maxY = maxY;
}
}
echart
的实现方式有点绕、其实有更简单的方法、就是计算出geo
的rect
和canvas
的rect
后、不需要将geo
的rect.y
进行反转、也不用算transformInfoRaw
、直接用比例换算。使用代码中注释掉的部分进行计算、同时注释掉inverted y
的代码。
- 坐标换算完成后、剩下就是绘制了、代码很简单:
js
function drawShape(shape, style) {
if (shape.type == 'MultiPolygon') {
for (var coor of shape.coordinates) {
drawShape(coor, style);
}
return
}
var path = shape[0];
if (shape.type == 'Polygon') {
path = shape.coordinates[0];
}
ctx.beginPath();
ctx.save();
ctx.moveTo(path[0][0], path[0][1]);
for (var i = 1; i < path.length; i++) {
ctx.lineTo(path[i][0], path[i][1]);
}
ctx.fillStyle = style.fill;
ctx.strokeStyle = style.stroke;
ctx.lineWidth = style.lineWidth;
ctx.fill();
ctx.stroke();
}
function draw(hoverTarget, hoverStyle, cb) {
ctx.clearRect(0, 0, dpr * w, dpr * h);
for (var shape of shapes) {
if (shape == hoverTarget) {
drawShape(shape, hoverStyle);
} else {
drawShape(shape, style);
}
}
if (hoverTarget && hoverTarget.name) {
// 最后绘制文字
ctx.save();
ctx.fillStyle = '#333';
ctx.font = "24px sans-serif";
ctx.fillText(hoverTarget.name,
hoverTarget.minX + (hoverTarget.maxX - hoverTarget.minX) / 2 - 12 * hoverTarget.name.length,
hoverTarget.minY + (hoverTarget.maxY - hoverTarget.minY) / 2 + 6
);
ctx.restore();
}
cb && cb();
}
绘制线就可以了、需要注意的是绘制区域名称
的方法有待商榷、位置存在一定的误差、如果大家有更好的方法欢迎提出。
- 接下来就是检测鼠标移入事件了
js
prevTarget = null;
document.body.addEventListener('mousemove', (e) => {
var { clientX, clientY } = e;
var hoverTarget = null;
for (var shape of shapes) {
if (
clientX < shape.minX / dpr || clientX > shape.maxX / dpr
|| clientY < shape.minY / dpr || clientY > shape.maxY / dpr
) {
// 边界检测
continue;
}
if (shape.type == 'MultiPolygon') {
for (var coor of shape.coordinates) {
var i = 0;
var path = coor[0];
var w = 0;
while (i < path.length - 1) {
w += windingLine(path[i][0] / dpr, path[i][1] / dpr, path[i + 1][0] / dpr, path[i + 1][1] / dpr, clientX, clientY);
i++;
}
w += windingLine(path[i][0] / dpr, path[i][1] / dpr, path[0][0] / dpr, path[0][1] / dpr, clientX, clientY);
if (w != 0) {
hoverTarget = shape;
break;
}
}
} else {
var i = 0;
var path = shape.coordinates[0];
var w = 0;
while (i < path.length - 1) {
w += windingLine(path[i][0] / dpr, path[i][1] / dpr, path[i + 1][0] / dpr, path[i + 1][1] / dpr, clientX, clientY);
i++;
}
w += windingLine(path[i][0] / dpr, path[i][1] / dpr, path[0][0] / dpr, path[0][1] / dpr, clientX, clientY);
if (w != 0) {
hoverTarget = shape;
break;
}
}
}
if (hoverTarget && hoverTarget != prevTarget) {
draw(hoverTarget, style2);
raf(() => {
draw(hoverTarget, style1, () => {
raf(() => {
draw(hoverTarget, style2);
})
})
})
} else if (!hoverTarget && prevTarget) {
raf(draw);
}
prevTarget = hoverTarget;
})
function windingLine(x0, y0, x1, y1, x, y) {
// 检查点 (x, y) 是否在线段的上方或下方,如果是则返回 0,表示不在左侧或右侧
if ((y > y0 && y > y1) || (y < y0 && y < y1)) {
return 0;
}
// 如果线段是水平的,则返回 0,表示不在左侧或右侧
if (y1 === y0) {
return 0;
}
// 计算点 (x, y) 在线段上的位置比例
var t = (y - y0) / (y1 - y0);
// 确定线段的走向(从上到下还是从下到上),以确定计算方向
var dir = y1 < y0 ? 1 : -1;
// 如果点 (x, y) 在线段的端点上,则将方向调整为一半,用于处理特殊情况
// 这步可去掉、chart源码中的
if (t === 1 || t === 0) {
dir = y1 < y0 ? 0.5 : -0.5;
}
// 计算点 (x, y) 在线段上对应的 x 坐标
var x_ = t * (x1 - x0) + x0;
// 如果计算得到的 x 坐标与给定的 x 坐标相等,则返回无穷大(表示点在线段上)
if (x_ === x) {
return Infinity;
}
return x_ > x ? dir : 0;
}
上述windingLine
方法的原理在这篇文章中、大家可以找找windingLine
原理是第几种方法。
最后
本文完整代码地址传送门
注意:进去肯定会报错、因为缺少geoJson
、需要先在全局声明一个geoJson={xxx: xxx} //绘制区域的geoJson信息
才能运行。
最后、本文仅仅代表自己对echart
源码的理解、如果还有什么疑问或者建议,可以多多交流,原创文章,文笔有限,才疏学浅,文中若有不正之处,万望告知。