从零绘制地图

写在前面


本文尝试用原生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源码的理解、如果还有什么疑问或者建议,可以多多交流,原创文章,文笔有限,才疏学浅,文中若有不正之处,万望告知。

相关推荐
学习使我快乐0116 分钟前
JS进阶 3——深入面向对象、原型
开发语言·前端·javascript
bobostudio199517 分钟前
TypeScript 设计模式之【策略模式】
前端·javascript·设计模式·typescript·策略模式
黄尚圈圈1 小时前
Vue 中引入 ECharts 的详细步骤与示例
前端·vue.js·echarts
浮华似水2 小时前
简洁之道 - React Hook Form
前端
正小安4 小时前
如何在微信小程序中实现分包加载和预下载
前端·微信小程序·小程序
_.Switch6 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
一路向前的月光6 小时前
Vue2中的监听和计算属性的区别
前端·javascript·vue.js
长路 ㅤ   6 小时前
vite学习教程06、vite.config.js配置
前端·vite配置·端口设置·本地开发
长路 ㅤ   6 小时前
vue-live2d看板娘集成方案设计使用教程
前端·javascript·vue.js·live2d
Fan_web6 小时前
jQuery——事件委托
开发语言·前端·javascript·css·jquery