简介:面向H5地图可视化开发者,这套示例项目整合了Canvas绘图能力与ECharts地图组件,演示如何借助httpgeojson.io自由编辑GeoJSON数据,快速生成可交互的自定义地图。项目完整覆盖了从Canvas基础图形(矩形、路径、线条)绘制到坐标系换算的思路,包括将地理坐标转换为画布坐标、描边填充省份边界,并具体展示ECharts中map系列如何加载GeoJSON、通过roam配置实现缩放平移、监听点击事件按省/市动态下钻加载下级区域,同时支持将业务数据绑定到地理元素上以颜色或大小呈现。压缩包为rar格式,整体仅1.1MB,轻量且便于本地运行调试。目前已有1630人学习下载。对于想深入理解Canvas与ECharts协同绘制地图、期望在管理后台或数据分析页面中落地可视化地图的开发者,这份示例提供了可直接参考的代码结构与配置方式,能明显缩短从JSON地理数据到展示页面的开发路径。
1. 手写 H5 Canvas 地图渲染,再配一个 geojson.io 当数据编辑器
某跨平台系统的后台管理端需要展示一片园区范围、几十个标注点,还要允许业务人员手工圈一块区域标成“施工红线”。引入大而全的地图服务一是根本不需要在线底图,二是要部署在离线内网环境里,密钥和瓦片域名都走不通。最后方案落到了“H5 Canvas 自己画地图 + geojson.io 管理 GeoJSON 数据”这套轻量组合上:Canvas 负责把 GeoJSON 渲染成矢量图形并支持自由绘制,geojson.io 负责可视化地编辑、校验和导出这份 GeoJSON。这个方向适合有前端基础、需要轻量地图可视化、不想被地图 SDK 的体积和权限拖住的团队。先说结论:Canvas 画地图真正卡人的不是“画”本身,而是坐标换算、数据结构校验、交互命中这三件事。
2. H5 Canvas 地图坐标换算:把经纬度变成像素坐标,避免图形变形
2.1 为什么 GeoJSON 坐标不能直接画在 Canvas 上
GeoJSON 里的坐标是[经度, 纬度],经度范围约-180 ~ 180,纬度范围约-90 ~ 90,单位是“度”,而 Canvas 的坐标单位是“像素”,原点在左上角,x 轴向右、y 轴向下。如果直接把经度当 x、纬度当 y 丢进ctx.lineTo(),图形会被压缩到画布左上角一小块区域内,而且纬度越大在 Canvas 上 y 越大,画出来的地图是上下颠倒的——真实地图北方朝上,Canvas 却把高纬度画在了下方。
这个问题是 Canvas 地图的“第一性原理”:必须先做一次坐标映射,把经纬度范围归一化到画布尺寸。常见做法是遍历所有要素,算出最小外接矩形 bounds,再按 bounds 算出每个经纬度对应的像素位置,同时加 padding 防止图形贴边。这样不管数据落在哪个经纬度区间,都能被完整铺到画布中央,并且保持宽高比例不变形。
选型上要提前想清楚:区域范围小(一个园区、一条河道、一个街道)用线性映射完全够用;范围覆盖全国甚至全球(经纬度跨度几十度以上)就必须考虑投影方式,否则高纬度区域会被明显拉扁。下面给出两种映射的实现,以及它们在代码里的真实差异。
2.2 线性映射与 Web 墨卡托:两条路线的代码实现
先定义一个通用的 bounds 计算函数,它决定后续所有映射是否正确:
// 计算一组 GeoJSON 坐标的最小外接矩形 function calcBounds(coordinates) { let minLon = Infinity, minLat = Infinity; let maxLon = -Infinity, maxLat = -Infinity; // 这里只演示二维坐标 [lon, lat] 的遍历 coordinates.forEach(([lon, lat]) => { minLon = Math.min(minLon, lon); maxLon = Math.max(maxLon, lon); minLat = Math.min(minLat, lat); maxLat = Math.max(maxLat, lat); }); return { minLon, minLat, maxLon, maxLat }; }这段函数是所有后续坐标映射的前提。需要注意coordinates是拍平后的二维坐标数组,实际 GeoJSON 里多边形是“数组套数组”的结构,使用时必须先用递归把坐标全部拍平;少算一个嵌套层级,映射出来的图形就会被裁剪掉一部分。
有了 bounds,线性映射的核心函数如下:
function lonLatToPixel(lon, lat, bounds, canvasSize, padding = 40) { const { minLon, minLat, maxLon, maxLat } = bounds; const rangeLon = maxLon - minLon || 1; const rangeLat = maxLat - minLat || 1; // 等比缩放:取 x/y 缩放值中较小的那个,防止图形被拉伸变形 const scaleX = (canvasSize.width - padding * 2) / rangeLon; const scaleY = (canvasSize.height - padding * 2) / rangeLat; const scale = Math.min(scaleX, scaleY); // 居中偏移:等比缩放后把图形平移到画布中央 const offsetX = (canvasSize.width - rangeLon * scale) / 2; const offsetY = (canvasSize.height - rangeLat * scale) / 2; const x = offsetX + (lon - minLon) * scale; // 纬度要取反,因为 Canvas y 轴向下,纬度越高 y 越小 const y = offsetY + (maxLat - lat) * scale; return { x, y }; }这个函数的几个参数值得展开说。padding控制图形与画布边缘的最小距离,默认 40px 是因为后续要画标注文字、加图例,贴边的图形会被文字挡住。取Math.min(scaleX, scaleY)是为了等比缩放,如果直接各轴单独缩放,经纬度跨度和画布宽高比例不一致时图形会横向或纵向被拉长,这在视觉上非常明显。
另一种常见做法是 Web 墨卡托投影,它把纬度的余弦关系考虑进去,让高纬度地区横向拉伸程度降低:
// Web 墨卡托投影:把经纬度转成平面坐标 function lonLatToMercator(lon, lat) { const MAX_LAT = 85.05112878; // 墨卡托投影的纬度边界 const latClamped = Math.max(-MAX_LAT, Math.min(MAX_LAT, lat)); const y = Math.log(Math.tan((90 + latClamped) * Math.PI / 360)); return [lon, y]; }墨卡托的意义在于纬度越高的地区,单位纬度的屏幕距离会被压缩,避免高纬度视觉上“撑爆”画布。如果你的数据跨度超过 20 个纬度,建议先用墨卡托转换,再走线性映射;跨度只有几公里时两种结果差异肉眼几乎看不出来,线性映射的代码更短也更好调试。
2.3 高 DPI 适配:Canvas 模糊的根源与标准设置
很多人在本地把 Canvas 地图画完了,一放到高分屏笔记本上就发现线条发虚、文字有毛边。这不是绘制代码的问题,是 Canvas 的物理像素和 CSS 像素没对齐。Canvas 的width是绘图缓冲区像素数,而元素在页面上展示时用的是 CSS 尺寸;高分屏的 devicePixelRatio 大于 1,缓冲区像素不够就会被浏览器拉伸,产生模糊。
标准处理方式是在初始化时把缓冲区尺寸乘上window.devicePixelRatio:
function setupCanvas(canvas, cssWidth, cssHeight) { const dpr = window.devicePixelRatio || 1; // 缓冲区尺寸按 dpr 放大,CSS 尺寸保持原样 canvas.width = cssWidth * dpr; canvas.height = cssHeight * dpr; canvas.style.width = cssWidth + 'px'; canvas.style.height = cssHeight + 'px'; // 整体坐标系按 dpr 缩放,后面绘制时直接用 CSS 像素坐标 canvas.getContext('2d').setTransform(dpr, 0, 0, dpr, 0, 0); }这套设置里最容易被忽略的是setTransform。如果不做这一步,后面所有ctx.arc、ctx.lineWidth画的图形尺寸都要手动乘以 dpr;做了之后,绘制代码可以完全忽略 dpr 的存在,按 CSS 像素坐标写就行。还有一个坑:如果后续代码里执行了ctx.clearRect或ctx.save/restore,要确认没有把 transform 重置掉,否则又回到模糊状态,这属于玄学级排查问题。
3. 把 GeoJSON 渲染成 Canvas 地图:解析、校验与三类几何体绘制
3.1 解析 FeatureCollection:拿到几何体与属性
GeoJSON 的顶层结构通常是FeatureCollection,里面是features数组,每个 feature 由geometry和properties组成。渲染前要做的第一件事是把嵌套的坐标结构拍平,同时把properties单独存下来,因为后续交互编辑要按 feature 维度操作,而不是按坐标数组操作。
我一般会写一个轻量的解析器,统一处理 Point、LineString、Polygon 三种常用类型:
function parseGeoJSON(json) { if (json.type === 'FeatureCollection') { return json.features.map(feature => ({ id: feature.id || null, type: feature.geometry.type, // Point / LineString / Polygon coordinates: feature.geometry.coordinates, properties: feature.properties || {} })); } // 如果顶层就是单要素,也兼容处理 if (json.type === 'Feature') { return [{ id: json.id || null, type: json.geometry.type, coordinates: json.geometry.coordinates, properties: json.properties || {} }]; } throw new Error('Unsupported GeoJSON type: ' + json.type); }解析时有个很容易踩的边界:GeoJSON 规范要求 LineString 和 Polygon 的坐标强制使用[lon, lat]顺序,但很多从表单或表格工具导出的数据是[lat, lon]。这个问题解析层查不出来,要在渲染前针对已知数据源写一个坐标顺序探测逻辑——选一个面积合理的多边形,判断其 bounds 是否落在纬度范围-90~90内,如果超出则说明经纬度写反了。
解析完的数据建议单独存一份“已拍平坐标”的副本,绘制多边形时不再递归遍历嵌套数组,既能提升渲染效率,也方便后续命中检测直接用坐标集合做计算。
3.2 点、线、面的 Canvas 绘制策略与样式参数
三类几何体的绘制逻辑有共同点:全部通过ctx.beginPath()开始,用moveTo/lineTo或arc构造路径,最后统一 stroke 或 fill。我把它们拆成三个独立函数,便于针对样式分别调参:
function drawPoint(ctx, x, y, style) { ctx.beginPath(); ctx.arc(x, y, style.radius || 6, 0, Math.PI * 2); ctx.fillStyle = style.fill || '#e74c3c'; ctx.fill(); if (style.stroke) { ctx.lineWidth = style.strokeWidth || 2; ctx.strokeStyle = style.stroke; ctx.stroke(); } } function drawLineString(ctx, flatPoints, style) { ctx.beginPath(); flatPoints.forEach((point, i) => { if (i === 0) ctx.moveTo(point.x, point.y); else ctx.lineTo(point.x, point.y); }); ctx.lineWidth = style.lineWidth || 3; ctx.strokeStyle = style.color || '#3498db'; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.stroke(); } function drawPolygon(ctx, rings, style) { // rings 是多边形坐标数组,第一圈是外边界,后面是内洞,这里先只画第一圈 const ring = rings[0]; ctx.beginPath(); ring.forEach((point, i) => { if (i === 0) ctx.moveTo(point.x, point.y); else ctx.lineTo(point.x, point.y); }); ctx.closePath(); ctx.fillStyle = style.fill || 'rgba(52, 152, 219, 0.3)'; ctx.fill(); ctx.lineWidth = style.strokeWidth || 2; ctx.strokeStyle = style.stroke || '#2c3e50'; ctx.stroke(); }样式参数上有一个血泪经验:面要素的填充色fill一定要带透明度,没有透明度会导致多个多边形重叠时后画的把先画的完全盖住,用户看不出哪些区域是相交的。推荐用rgba而不是#rrggbb加全局ctx.globalAlpha,因为全局透明会影响后续绘制的边界线,让线也变淡。
线要素的lineCap和lineJoin设成round在绘制河流、道路这类自然形态时很重要,直角接头在放大后会露出明显的线段拼接痕迹;如果画的是建筑轮廓这种人工边界,反而应该用默认的butt和miter,视觉上更锐利。
另外要注意 Canvas 的绘制顺序:应该先画所有面,再画所有线,最后画点。这个顺序保证点永远显示在线和面的上层,否则点被填充色盖住,标注信息就看不见了。我通常会把要素按类型分成三组,一次性排序后统一渲染,而不是按 features 数组顺序逐个画。
3.3 用 geojson.io 快速制造一份合法测试数据
在写 Canvas 渲染代码之前,第一步应该先拿到一份结构合法、坐标正确的 GeoJSON 测试数据。最快的方式是打开 geojson.io 网站,在地图上手工画几个区域再导出,而不是自己手工写 JSON——手写坐标容易漏括号、写错经纬度顺序,排查起来浪费时间。
具体步骤是这样的:在浏览器打开 geojson.io,左侧面板会显示一张可交互的底图;用顶部工具栏的多边形工具在图上圈一个区域,再添加一个点要素和一个线要素;完成后点击 Save 选择 GeoJSON 格式,保存文件。打开这个文件,你会看到标准 FeatureCollection 结构,坐标都是[lon, lat]且已经投影到了真实地理位置。
我自己写渲染代码时,会专门保留一份这种“从 geojson.io 导出的数据”作为基准测试集,它有两个好处:一是结构严格符合规范,排除了数据源层面的结构错误;二是它包含真实的经纬度范围,能暴露映射函数的边界问题,比如跨南北纬、跨 180 度经线。纯手工编造的数据往往只覆盖某个几度的范围,很多边界 bug 测不出来。
还有一个小技巧:geojson.io 支持直接复制粘贴 GeoJSON 文本到左侧面板,不需要文件保存。调试阶段我经常在程序里打印转换后的 JSON,复制到 geojson.io 里对比渲染结果,两边图形一致才说明 Canvas 侧的映射和绘制没有偏差。
4. Canvas 自由绘制地图:交互坐标换算、点线面绘制与数据导出
4.1 screenToWorld:鼠标像素坐标反算回经纬度
自由绘制的第一步是让鼠标点和业务坐标建立可逆的换算关系。第 2 章实现了lonLatToPixel,把经纬度转成屏幕像素;交互时正好反过来,要把屏幕像素转回经纬度,这样才能把画上去的点存成 GeoJSON 坐标。
反算函数必须复用同样的映射参数,不能重新算一遍 bounds,否则缩放或平移后就会错位。常见做法是把映射参数封装成同一个对象:
function createMapper(bounds, canvasSize, padding = 40) { const rangeLon = bounds.maxLon - bounds.minLon || 1; const rangeLat = bounds.maxLat - bounds.minLat || 1; const scaleX = (canvasSize.width - padding * 2) / rangeLon; const scaleY = (canvasSize.height - padding * 2) / rangeLat; const scale = Math.min(scaleX, scaleY); const offsetX = (canvasSize.width - rangeLon * scale) / 2; const offsetY = (canvasSize.height - rangeLat * scale) / 2; return { // 业务坐标 → 屏幕坐标 worldToScreen: (lon, lat) => ({ x: offsetX + (lon - bounds.minLon) * scale, y: offsetY + (bounds.maxLat - lat) * scale }), // 屏幕坐标 → 业务坐标(逆运算) screenToWorld: (x, y) => ({ lon: bounds.minLon + (x - offsetX) / scale, lat: bounds.maxLat - (y - offsetY) / scale }) }; }有了这个 mapper,后续所有绘制和编辑操作都只跟worldToScreen、screenToWorld打交道。鼠标事件里拿到的坐标必须先经过一次转换,再进入绘图逻辑。注意screenToWorld的输入应该是相对于 Canvas 元素的坐标,而不是e.clientX/e.clientY,否则页面有滚动或画布不贴屏幕左上角时,画出来的点会整体偏移一大段距离。
换算公式看起来简单,但实际开发中 90% 的“画出来的点不在鼠标位置”都是因为漏了getBoundingClientRect()这一步:
canvas.addEventListener('mousemove', (e) => { const rect = canvas.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; const world = mapper.screenToWorld(mouseX, mouseY); // world.lon / world.lat 就是鼠标当前位置的经纬度 });4.2 三种绘制模式的交互状态机:点、线、面
自由绘制本质是一个交互状态机:当前处于 draw-point、draw-line、draw-polygon 还是 select 模式,不同模式对鼠标事件的处理完全不同。我习惯用一个mode变量做控制:
let mode = 'select'; // select / draw-point / draw-line / draw-polygon let currentFeature = null; // 正在绘制中的要素 let tempPoints = []; // 线、面绘制过程中的临时点 canvas.addEventListener('click', (e) => { if (mode === 'select') return; const world = getWorldFromEvent(e); if (mode === 'draw-point') { addFeature('Point', [world.lon, world.lat]); mode = 'select'; // 点一次就结束 } if (mode === 'draw-line' || mode === 'draw-polygon') { tempPoints.push([world.lon, world.lat]); // 线双击结束,面也是双击闭合 } }); canvas.addEventListener('dblclick', (e) => { if (mode === 'draw-line' || mode === 'draw-polygon') { finishGeometry(); // 闭合图形并写入 features } });双检测还有一个必须处理的问题:双击会先触发两次click,导致结束点时重复加点。常见做法是在click处理里记录时间戳,如果两次 click 间隔小于 300ms 且坐标距离小于阈值,就忽略后一次:
let lastClickTime = 0; const CLICK_THRESHOLD = 300; canvas.addEventListener('click', (e) => { const now = Date.now(); if (now - lastClickTime < CLICK_THRESHOLD) return; lastClickTime = now; // ...正常加点逻辑 });面的“橡皮筋预览”是绘制体验的关键:鼠标移动时,从上一个已确认点画一条临时线到鼠标当前位置,让用户能预判下一条边的走向。实现上是在mousemove里保存预览坐标,然后触发一次render(),渲染函数会先画“已确认的临时点连线”,再画“最后一个点到鼠标位置的虚线”。这个预览不走实时 GeoJSON 更新,只在渲染层做临时数据,性能开销非常小。
4.3 把绘制结果导出成 GeoJSON,去 geojson.io 校验
绘制的最终产物必须能导出为标准化 GeoJSON,否则画完的数据无法被其他系统使用。导出代码不复杂,核心是维护一个 features 数组,每次操作都同步更新它:
let features = []; // 当前画布上的所有要素 function toGeoJSON() { // 统一导出为 FeatureCollection return { type: 'FeatureCollection', features: features.map((feature, index) => ({ type: 'Feature', id: feature.id || 'feature_' + index, geometry: { type: feature.type, coordinates: feature.coordinates }, properties: feature.properties || {} })) }; } // 导出时生成一个可下载的 JSON 文件 function downloadGeoJSON() { const data = JSON.stringify(toGeoJSON(), null, 2); const blob = new Blob([data], { type: 'application/json' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = 'map-data.geojson'; link.click(); URL.revokeObjectURL(url); }导出的验证环节强烈建议走一遍 geojson.io:把生成的 JSON 直接复制粘贴到 geojson.io 左侧,右侧地图会立刻渲染出你画的图形。这一步能验证两件事——坐标顺序是否正确(如果图形镜像或出现在海里,说明经纬度反了),以及闭合状态是否正确(多边形首尾没有闭合,geojson.io 会渲染成一条线而不是封闭区域)。
这种“程序里画 → 导出 → geojson.io 验证”的工作流,比任何单元测试都直观。我实际开发中会把 geojson.io 当作 Canvas 地图的“对照实验组”:同一个 GeoJSON,两边渲染结果一致,才敢交付给业务方使用。
5. Canvas 地图高频问题排查:坐标反了、图形跳变、画了撤不回
5.1 图形左右颠倒 / 上下镜像
现象:导入一段 GeoJSON 后,渲染出来的图形和真实的区域方向正好相反,就像一个镜像。
原因:最常见的是经纬度顺序反了,把[lat, lon]当成[lon, lat]绘制;其次是映射函数里纬度没有取反,Canvas y 轴向下而纬度向北增大,不处理就会上下颠倒。
解决:先看 bounds 计算结果,如果minLon的范围超过-90~90而minLat在-180~180内,基本可以断定是数据本身经纬度写反,在解析层统一交换。如果 bounds 正常但画出来镜像,检查lonLatToPixel里的y = offsetY + (maxLat - lat) * scale是否写成了y = offsetY + (lat - minLat) * scale。这个改动只影响 y 轴方向,x 轴不变,所以呈现出来的是上下镜像而非左右颠倒。
5.2 图形高频重绘导致闪烁与跳变
现象:鼠标移动过程中不断调用render(),画布上的图形出现明显闪烁,甚至边缘在轻微抖动。
原因:render()每次都不清空画布就开始画,旧图形残留叠加;或者clearRect的尺寸和画布尺寸不一致,边缘残留半透明的像素。
解决:渲染函数第一行固定执行ctx.clearRect(0, 0, canvasCssWidth, canvasCssHeight),但注意第 2 章设置了setTransform(dpr, ...),clearRect 用的是 CSS 像素坐标,所以宽高也要是 CSS 宽高,不能传物理像素宽高。如果闪烁仍然存在,把静态底图(已有的 Feature 图形)缓存到一个离屏 Canvas 上,鼠标移动时只重绘预览线,底图直接从缓存里drawImage复制,性能会明显改善。
5.3 绘制时的光标漂移
现象:绘制模式下,鼠标点下去画出的点和指针位置有一小段偏移,尤其是在画布靠近页面上方、页面发生滚动时更明显。
原因:事件里用了e.clientX直接参与计算,而没有减去canvas.getBoundingClientRect().left/top。这是我最常看到的低级错误,但特别隐蔽,因为画布在页面顶部时 rect 的 top 接近 0,偏移量很小,不容易发现。
解决:统一封装一个getWorldFromEvent(e)函数,内部做“客户端坐标 → 画布相对坐标 → 业务经纬度”两步换算,所有鼠标事件都走这个函数,不从e里直接取值参与映射计算。可以加上一个调试开关,在页面上把鼠标的屏幕坐标和经纬度实时显示出来,对判断偏移很有帮助。
5.4 画错了只能清空重来
现象:绘制一半发现最后一个点加错了,没有后悔药可吃;业务人员误操作把一条完整的线画完,只能删掉整个要素重画。
原因:绘制交互只维护“确认”逻辑,没记录撤销栈。Canvas 绘制本身是无状态的,画上去就成像素了,想要撤销必须在数据层做。
解决:给 features 数组维护一个简单的历史栈,每次 add、delete、move 操作前把完整数据快照推入栈中:
const historyStack = []; const historyLimit = 20; function pushHistory() { historyStack.push(JSON.stringify(features)); if (historyStack.length > historyLimit) historyStack.shift(); } function undo() { const snapshot = historyStack.pop(); if (snapshot) features = JSON.parse(snapshot); render(); }注意快照用JSON.stringify而不是直接存对象引用,因为后续对 features 的修改会改动原对象,栈里的数据也会跟着变,等于撤销失效。这个坑一踩一个准,我吃过一次亏后所有历史存储都统一用深拷贝或序列化字符串。
5.5 要素数量多导致整体卡顿
现象:数据量达到几百个多边形时,拖动交互变得迟滞;几千个点时鼠标移动都要等上一会儿。
原因:每帧渲染把所有要素全部重画一遍,坐标换算和 Canvas 路径构建成本随要素数线性增长,再加上 Canvas 缓冲区过大,GPU 合成压力增加。
解决:先分清“静态底图”和“交互热区”两层。静态底图(不需要修改的要素)缓存到离屏 Canvas,交互时先drawImage复制底图,再画当前正在编辑的少量图形。命中检测不要遍历所有要素,先按简化包围盒粗筛,再做精细判断。具体的离屏缓存实现放在下一章展开。
6. 进阶:静态底图离屏缓存与命中检测的廉价方案
6.1 用离屏 Canvas 缓存静态底图
离屏缓存的基本原理是:GeoJSON 数据不频繁变化时,首次渲染把所有要素画到一个隐藏 Canvas 上,后续每一帧不再重画这些要素,而是用ctx.drawImage把隐藏 Canvas 整体贴到主画布。代码非常简单:
const staticLayer = document.createElement('canvas'); staticLayer.width = canvas.width; staticLayer.height = canvas.height; function buildStaticLayer() { const ctx = staticLayer.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, cssW, cssH); // 把所有已确认的 features 画到 staticLayer 上 features.forEach(f => { drawFeature(ctx, f, mapper); }); } function render() { const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, cssW, cssH); ctx.drawImage(staticLayer, 0, 0, cssW, cssH); // 叠加上临时绘制中的预览图形 drawTempPreview(ctx); }这里的边界是:只有“确认过的要素”才进缓存,正在绘制或拖拽中的元素必须在主画布上重画。每次要素集合变化时才重新调用buildStaticLayer,否则只会消耗一次布局和合成时间,交互过程完全流畅。
6.2 命中检测用包围盒 + 距离阈值的廉价方案
点选命中检测不用一开始就做精准计算。我会给每个要素维护一个预计算的最小包围盒(坐标空间),鼠标点进来先遍历包围盒,只有落在包围盒内的要素才进入精确判断:
function hitTest(world, thresholdPx = 8) { const threshold = thresholdPx / mapper.scale; // 像素阈值转成经纬度距离 for (const f of features) { const bbox = f.bbox; // 每个要素解析时预计算 if (world.lon < bbox.minLon - threshold || world.lon > bbox.maxLon + threshold) continue; if (world.lat < bbox.minLat - threshold || world.lat > bbox.maxLat + threshold) continue; // 包围盒命中后,点要素用距离判断,线面用几何判断 const coords = flatten(f.coordinates); for (const [lon, lat] of coords) { const dx = lon - world.lon; const dy = lat - world.lat; if (dx * dx + dy * dy < threshold * threshold) return f; } } return null; }阈值换算成经纬度距离是关键细节:屏幕上的 8 像素在 scale 不同的地图上对应的经纬度差异很大,不换算会导致缩放到小范围时点不中、大范围时误点一片。这段代码精确度不算高,但选点和拖拽足够用,需要更高精度的点到线段距离计算再单独实现。
6.3 用 geojson.io 逐要素对比验证
最后的验证手段仍然是回到 geojson.io:把 Canvas 侧导出的 GeoJSON 粘贴到 geojson.io 左侧面板,逐个要素对比右侧的渲染效果。我之前在某图像处理 Demo 里遇到过一个时针方向的 Polygon 闭合异常,Canvas 侧因为closePath()自动闭合看不出来,导入 geojson.io 后直接显示成非闭合路径,一眼就暴露了数据层的问题。
现在我的习惯做法是:每完成一个阶段,就按一次导出按钮把当前数据扔进 geojson.io 看一眼。这不是测试流程的奢求,而是 GeoJSON 这种文本化数据结构里,坐标顺序、闭合状态、层级嵌套这些问题只有可视化呈现时才能最直观地暴露。靠肉眼读 JSON 永远看不出镜像,但地图上一眼就能发现。希望这个工作流和上面的实现细节能帮你在自己的 Canvas 地图项目里少走弯路。
本文还有配套的精品资源,点击获取