多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

H5 Canvas 地图编辑实战:GeoJSON 数据契约与 httpgeojson.io 闭环

H5 Canvas 地图编辑实战:GeoJSON 数据契约与 httpgeojson.io 闭环 简介这份资源面向具备一定前端基础、希望掌握交互式地图开发的开发者围绕 H5 Canvas 与 ECharts 结合 GeoJSON 实现可自由绘制、可展开收缩的地图应用。包内以 rar 压缩包形式提供整体约 1.1MB文件总数与类型明细上游暂未提供从描述看主要涉及 Canvas 绘图脚本、ECharts 地图配置及 GeoJSON 数据文件分别承担底图轮廓绘制、地图系列加载与地理数据定制等用途。目前已有 1630 人学习下载说明该方向具备一定关注度。读者可从中了解 Canvas 的 fillRect、beginPath、moveTo、lineTo 等 API 如何绘制地图边界掌握 ECharts 地图系列加载 GeoJSON 的方式并借助 httpgeojson.io 在线编辑生成适配需求的 GeoJSON 文件。项目还涉及 zoomOnMouseWheel、Roam 等缩放平移配置以及点击省份动态加载下级城市数据实现地图展开收缩的思路并可将统计数据通过颜色、大小等视觉元素绑定到地理位置适合地理数据分析与业务监控类可视化场景参考。1. 从一张空白画布到可编辑地图H5 Canvas 地图方案到底解决什么问题产品经理丢过来一个需求活动页里要放一张园区导览图用户能自己圈选摊位、标注临时通道还要能导出成 GeoJSON 交给后端入库。你打开地图 SDK 文档发现底图瓦片、坐标系、鉴权 Key、离线部署一堆事而需求方只想要「一张能画的地图」。这时候 H5 Canvas 加 httpgeojson.io 的组合就值得认真看一眼Canvas 负责把 GeoJSON 的几何对象画到页面上并接管鼠标交互httpgeojson.io 负责把画出来的东西转成标准 GeoJSON 文本两边通过数据格式对齐不依赖任何地图服务商的瓦片和 Key。这套方案适合谁适合做活动互动页、园区/展馆导览、设备点位标注、教学演示的前端同学尤其是那些底图可以自己提供一张 PNG 或 SVG、不需要真实地理投影、但需要「画完能存、存了能改」的场景。它不适合替代高德、百度、Leaflet 这类完整 GIS 方案因为一旦涉及真实经纬度、瓦片层级、投影换算Canvas 手写坐标变换的维护成本会迅速超过收益。所以先把边界划清楚本文讲的是「以 GeoJSON 为数据契约、以 Canvas 为渲染层」的轻量地图编辑器不是通用地图引擎。2. 为什么选 Canvas 而不是 SVG 或现成地图库2.1 Canvas 与 SVG 在「可编辑地图」场景下的取舍先回答选型问题。很多人第一反应是用 SVG因为每个图形都是 DOM 节点绑定事件方便。但地图编辑场景有两个特点一是图形数量可能上千点位、线段、面二是需要频繁重绘拖拽、缩放、实时预览。SVG 在这种量级下 DOM 节点数会拖慢布局和重排而 Canvas 只有一个 DOM 节点绘制成本集中在 JS 里配合requestAnimationFrame做批量重绘更可控。代价是 Canvas 没有内置的命中检测。SVG 里点一个圆浏览器帮你判断Canvas 里你得自己算「鼠标坐标是否落在某个多边形内」。这就是为什么中间必须有一层几何计算常见做法是用射线法判断点是否在多边形内用点到线段距离判断是否选中线。这部分代码不难但必须写不能省。至于为什么不直接用 Leaflet 或 OpenLayers它们解决的是「真实地理坐标 瓦片底图」的问题自带投影、图层、控件体系。如果你的底图是一张设计稿导出的 PNG坐标是像素而非经纬度硬套地图库反而要写一堆坐标转换适配层。轻量场景下Canvas 自绘更直接。2.2 GeoJSON 作为数据契约的四个字段httpgeojson.io 这类工具的核心价值是让你可视化地编辑 GeoJSON。要跟它对接先得把 GeoJSON 的结构吃透。一个 FeatureCollection 长这样{ type: FeatureCollection, features: [ { type: Feature, properties: { id: tent-01, name: A区摊位 }, geometry: { type: Polygon, coordinates: [[[100, 120], [200, 120], [200, 220], [100, 220], [100, 120]]] } } ] }四个关键点type决定几何类型Point、LineString、Polygon、MultiPolygoncoordinates的嵌套层级随类型变化Polygon 是「环的数组」第一个环是外环后续是洞properties存业务字段导出时原样保留Polygon 的环必须闭合即首尾坐标相同。这四条里最容易翻车的是闭合问题——少写一个重复点httpgeojson.io 打开时可能直接报错或渲染异常。2.3 最小可运行把 GeoJSON 画到 Canvas 上下面这段代码是整套方案的骨架加载 GeoJSON、按几何类型分派绘制、处理坐标到画布的映射。// 假设 canvas 已存在宽高为 800x600 const canvas document.getElementById(map); const ctx canvas.getContext(2d); // 坐标映射GeoJSON 坐标范围 - 画布像素 // 这里假设数据坐标本身就在 0~800 / 0~600 范围内 // 真实场景需要根据 bbox 做线性缩放 function toPixel(coord) { return { x: coord[0], y: coord[1] }; } function drawFeature(feature) { const geom feature.geometry; ctx.beginPath(); if (geom.type Point) { const p toPixel(geom.coordinates); ctx.arc(p.x, p.y, 5, 0, Math.PI * 2); ctx.fillStyle #e74c3c; ctx.fill(); } else if (geom.type LineString) { geom.coordinates.forEach((c, i) { const p toPixel(c); i 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y); }); ctx.strokeStyle #3498db; ctx.lineWidth 2; ctx.stroke(); } else if (geom.type Polygon) { geom.coordinates.forEach(ring { ring.forEach((c, i) { const p toPixel(c); i 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y); }); }); ctx.fillStyle rgba(46, 204, 113, 0.3); ctx.fill(); ctx.strokeStyle #27ae60; ctx.stroke(); } } function render(geojson) { ctx.clearRect(0, 0, canvas.width, canvas.height); geojson.features.forEach(drawFeature); } // 使用 fetch(./data/map.geojson) .then(res res.json()) .then(render);逻辑说明toPixel是坐标变换的唯一入口所有几何都经过它将来要加缩放和平移只改这一处。drawFeature按类型分派Point 用圆、LineString 用折线、Polygon 用闭合路径。render每次先清空再全量重绘这是 Canvas 的常规做法简单可靠。参数说明arc的半径 5 是点位视觉大小可按画布尺寸调整lineWidth影响线的粗细fillStyle用 rgba 是为了让多边形半透明叠放时能看清层次。如果数据坐标范围远超画布需要先算 bbox 再做缩放否则图形会画到画布外。3. 用 httpgeojson.io 打通「画」与「存」的闭环3.1 在 httpgeojson.io 里准备底图与初始数据httpgeojson.io 的用法很直接打开页面左侧是地图区域右侧是 GeoJSON 文本编辑区两边实时联动。你可以先粘贴一段初始 GeoJSON页面上立刻出现对应图形也可以在地图上用工具画点、线、面右侧文本自动更新。实操步骤第一步把底图坐标范围确定下来。如果你的底图是 800x600 的 PNG那么所有 GeoJSON 坐标就按这个像素范围来写不要混入经纬度。第二步在 httpgeojson.io 里粘贴一个空的 FeatureCollection 作为起点{ type: FeatureCollection, features: [] }第三步用页面上的多边形工具画出第一个区域观察右侧文本变化确认坐标格式符合预期。第四步把 properties 补上业务字段比如{id: zone-1, type: booth}。这一步很关键因为后端入库靠的就是 properties几何只是形状。提示httpgeojson.io 的坐标是经纬度语义但你可以把它当纯数值用。只要前后端约定好「这些数字是像素坐标」就不会有歧义。真正要小心的是别把像素坐标和真实经纬度混在同一个文件里。3.2 把编辑结果导出并接入 Canvas 渲染在 httpgeojson.io 里画完复制右侧的 GeoJSON 文本保存成.geojson文件前端用fetch加载后交给上一节的render函数即可。但这里有个常见问题httpgeojson.io 导出的坐标可能是经纬度比如 116.39, 39.9而你的画布是 800x600 像素直接画会全部挤在一个点上。解决办法是加一层归一化function computeBBox(features) { let minX Infinity, minY Infinity, maxX -Infinity, maxY -Infinity; features.forEach(f { const coords f.geometry.type Point ? [f.geometry.coordinates] : f.geometry.coordinates.flat(1); coords.forEach(([x, y]) { minX Math.min(minX, x); maxX Math.max(maxX, x); minY Math.min(minY, y); maxY Math.max(maxY, y); }); }); return { minX, minY, maxX, maxY }; } function makeProjector(bbox, width, height, padding 20) { const scaleX (width - padding * 2) / (bbox.maxX - bbox.minX || 1); const scaleY (height - padding * 2) / (bbox.maxY - bbox.minY || 1); const scale Math.min(scaleX, scaleY); // 等比缩放避免变形 return (coord) ({ x: (coord[0] - bbox.minX) * scale padding, y: height - ((coord[1] - bbox.minY) * scale padding) // Y 轴翻转 }); }逻辑说明computeBBox遍历所有坐标求包围盒makeProjector根据包围盒和画布尺寸生成一个映射函数。注意 Y 轴要翻转因为 GeoJSON 的 Y 向上增大而 Canvas 的 Y 向下增大。scale取 X、Y 方向较小值保证图形等比不变形。参数说明padding是图形与画布边缘的留白避免贴边|| 1防止包围盒宽高为零时除零。这套投影只做等比缩放和平移不做旋转和投影变换够用且好排查。3.3 交互编辑选中、拖拽与新增顶点渲染只是第一步能编辑才算闭环。Canvas 里做交互的核心是「命中检测 状态机」。下面是一个简化的选中与拖拽逻辑let selectedIndex -1; let dragging false; let lastMouse { x: 0, y: 0 }; canvas.addEventListener(mousedown, (e) { const rect canvas.getBoundingClientRect(); const mx e.clientX - rect.left; const my e.clientY - rect.top; // 从后往前找后画的在上层 for (let i geojson.features.length - 1; i 0; i--) { if (hitTest(geojson.features[i], mx, my)) { selectedIndex i; dragging true; lastMouse { x: mx, y: my }; render(geojson); return; } } selectedIndex -1; render(geojson); }); canvas.addEventListener(mousemove, (e) { if (!dragging || selectedIndex 0) return; const rect canvas.getBoundingClientRect(); const mx e.clientX - rect.left; const my e.clientY - rect.top; const dx mx - lastMouse.x; const dy my - lastMouse.y; translateFeature(geojson.features[selectedIndex], dx, dy); lastMouse { x: mx, y: my }; render(geojson); }); canvas.addEventListener(mouseup, () { dragging false; });逻辑说明mousedown时从数组尾部往前遍历做命中检测因为后绘制的图形在视觉上层应该优先被选中。命中后记录索引和鼠标位置进入拖拽状态。mousemove时计算位移增量调用translateFeature修改几何坐标然后重绘。mouseup结束拖拽。参数说明hitTest需要按几何类型分别实现Point 判断距离、LineString 判断点到线段距离、Polygon 用射线法。translateFeature遍历坐标数组每个点加上 dx、dy。注意拖拽时修改的是原始 GeoJSON 数据不是画布像素这样导出时数据才是对的。4. 避坑与排查Canvas 地图编辑最容易翻车的五个地方4.1 图形画出来了但点不中现象明明看到多边形在画布上鼠标点上去没反应。原因通常是命中检测用的坐标系和绘制用的坐标系不一致。绘制时经过toPixel映射命中检测却直接拿鼠标坐标和原始 GeoJSON 坐标比较两者差了一个缩放和平移。解决命中检测必须走同一套投影函数先把鼠标坐标反投影回数据坐标或者把几何坐标投影到像素后再比较。我一般统一在像素空间做检测因为鼠标坐标天然就是像素。4.2 拖拽后图形抖动或漂移现象拖动一个多边形松手后它自己往某个方向偏了一点。原因是mousemove里累加位移时用了lastMouse但没在每帧更新或者用了offsetX而画布有 CSS 缩放导致坐标不准。解决每帧处理完立即更新lastMouse坐标一律用getBoundingClientRect换算不要直接用offsetX因为 CSS 尺寸和画布实际尺寸可能不一致。4.3 导出的 GeoJSON 在 httpgeojson.io 里打不开现象自己拼的 GeoJSON 粘贴到 httpgeojson.io页面报错或什么都不显示。原因有三类Polygon 环没闭合、坐标层级多了一层或少了一层、JSON 里有尾逗号。解决Polygon 第一个环的首尾坐标必须相同LineString 是[[x,y],[x,y]]Polygon 是[[[x,y],[x,y]]]多一层数组用JSON.stringify生成而不是手写能避免逗号问题。4.4 图形多了之后拖拽卡顿现象几十个多边形时还流畅上百个之后拖拽明显掉帧。原因是每次mousemove都全量重绘所有图形。解决分两层 Canvas静态层画不动的图形动态层只画当前选中的图形或者用requestAnimationFrame节流重绘把一帧内的多次 mousemove 合并成一次绘制。另一个思路是拖拽过程中只画选中图形的包围盒松手后再全量渲染。4.5 坐标精度丢失导致图形变形现象从 httpgeojson.io 复制出来的坐标有很多位小数经过缩放后图形边缘出现锯齿或轻微变形。原因是浮点数在多次运算后累积误差或者缩放比例过小导致有效精度不足。解决存储时保留原始坐标只在渲染时做投影不要用投影后的坐标反写数据缩放比例尽量接近 1避免极端的放大缩小。5. 进阶技巧用 Canvas 做地图抽稀与离线瓦片预览5.1 地图抽稀顶点太多时怎么减从 httpgeojson.io 画出来的多边形如果顶点很密导出文件会很大渲染也慢。常见做法是 Douglas-Peucker 抽稀核心思路是保留对形状影响大的点去掉近似直线的中间点。下面是一个简化实现function perpendicularDistance(point, lineStart, lineEnd) { const [x, y] point; const [x1, y1] lineStart; const [x2, y2] lineEnd; const A x - x1, B y - y1, C x2 - x1, D y2 - y1; const dot A * C B * D; const lenSq C * C D * D; const param lenSq ! 0 ? dot / lenSq : -1; let xx, yy; if (param 0) { xx x1; yy y1; } else if (param 1) { xx x2; yy y2; } else { xx x1 param * C; yy y1 param * D; } return Math.hypot(x - xx, y - yy); } function simplify(points, tolerance) { if (points.length 2) return points; let maxDist 0, index 0; for (let i 1; i points.length - 1; i) { const d perpendicularDistance(points[i], points[0], points[points.length - 1]); if (d maxDist) { maxDist d; index i; } } if (maxDist tolerance) { const left simplify(points.slice(0, index 1), tolerance); const right simplify(points.slice(index), tolerance); return left.slice(0, -1).concat(right); } return [points[0], points[points.length - 1]]; }逻辑说明perpendicularDistance算点到线段的垂直距离simplify递归找最远点超过容差就以此为界分成两段继续处理。这是经典算法适合对精度要求不极端的场景。参数说明tolerance是容差单位与坐标一致。像素坐标下取 2 到 5 比较合适太小起不到抽稀效果太大图形会明显走样。抽稀只对 LineString 和 Polygon 的环做Point 不参与。5.2 离线瓦片预览把栅格地图当底图如果你的场景需要底图又不想依赖在线服务可以把瓦片下载到本地用 Canvas 按行列拼接。常见做法是瓦片按z/x/y.png命名前端根据当前视口计算需要哪些瓦片逐个drawImage到对应位置。这里要注意瓦片坐标系和 Canvas 像素坐标的换算以及跨域图片的crossOrigin设置否则getImageData会报安全错误。一个验证方法是先固定缩放级别只加载当前视口内的瓦片用console.table打印每张瓦片的行列号和绘制坐标确认拼接无缝。如果出现缝隙通常是瓦片尺寸和绘制位置取整不一致统一用Math.floor对齐即可。5.3 我踩过的坑与现在的习惯早期我总想一步到位把投影、抽稀、瓦片、编辑全塞进一个文件结果调试时根本分不清是数据问题还是渲染问题。现在的习惯是数据层只存 GeoJSON渲染层只读数据画图交互层只改数据不碰画布三层之间用函数边界隔开。每次改完先console.log当前 GeoJSON确认数据对了再看画面。另外httpgeojson.io 适合做数据校对不适合做最终交付因为它的坐标语义是经纬度而我的场景是像素每次对接都要提醒自己别混。这套方案值不值得做取决于你的底图是不是自提供、坐标是不是像素级、编辑需求是不是轻量。三个都是「是」那它比引入完整地图库省事得多有一个是「否」就该重新评估。希望帮到你。本文还有配套的精品资源点击获取
返回列表