从零绘制地图

写在前面


本文尝试用原生JS实现任意区域的地图、类似echartmap图、具体效果如下。

echart的使用


我们使用echart生成地图一般只要三步就可以了: 先引入echart.js、再去阿里云下载对应区域的geoJson、 最后调用echartapi就可以生成地图了。

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实现的、文本并未考虑这一问题)

代码分析


  1. 首先要解决坐标转化的问题、本质上就是计算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的实现方式有点绕、其实有更简单的方法、就是计算出georectcanvasrect后、不需要将georect.y进行反转、也不用算transformInfoRaw、直接用比例换算。使用代码中注释掉的部分进行计算、同时注释掉inverted y的代码


  1. 坐标换算完成后、剩下就是绘制了、代码很简单:
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();
}

绘制线就可以了、需要注意的是绘制区域名称的方法有待商榷、位置存在一定的误差、如果大家有更好的方法欢迎提出。


  1. 接下来就是检测鼠标移入事件了
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源码的理解、如果还有什么疑问或者建议,可以多多交流,原创文章,文笔有限,才疏学浅,文中若有不正之处,万望告知。

相关推荐
崔庆才丨静觅9 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60619 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了10 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅10 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅10 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅10 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment11 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅11 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊11 小时前
jwt介绍
前端
爱敲代码的小鱼11 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax