
简介面向需要构建交互式地图应用的开发者这份rar压缩包整合了HTML5 Canvas与ECharts的地图可视化方案。核心思路是利用Canvas的fillRect、strokeRect、beginPath、moveTo、lineTo等API绘制地图基础轮廓再借助ECharts地图系列加载GeoJSON数据通过zoomOnMouseWheel、roam等配置项实现缩放与平移并监听省份点击事件动态加载并显示下级城市的GeoJSON数据从而实现地图层级展开和收缩。配合geojson.io在线工具可自由上传、编辑和导出GeoJSON文件轻松定制省市级地图边界。压缩包体积约1.1MB内容涵盖前端HTML、JavaScript源码及示例数据结构清晰便于直接运行与二次开发。目前已有1631人学习通过完整的代码演示可帮助开发者掌握Canvas绘图、ECharts配置与GeoJSON数据结构并了解如何将业务数据与地图地理位置关联以颜色、大小等视觉元素呈现统计信息适用于地理数据分析、业务监控等场景。1. H5 Canvas 画布地图不依赖地图 SDK也能做出可编辑的 GeoJSON 地图接到一个地块标注需求要在网页上让业务人员自己圈范围、改边界第一反应是引地图 SDK后来发现只需要几十个多边形引 SDK 反而要处理配额、加载和样式杀鸡用牛刀。换成 H5 Canvas 画布地图后整个事情变成一条清晰的链路解析 GeoJSON、画多边形、拖拽顶点、导出文件每一步都自己说了算。这份资源就是一套这样的完整实现结合 geojson.io 这类在线 GeoJSON 编辑器画完的数据可以自由导出、继续编辑。适合不想被第三方地图绑定的前端开发者也适合想弄懂 Canvas 地图交互实现的人。2. 架构与数据流从 GeoJSON 解析到 Canvas 绘制的坐标变换链路画布地图项目最容易忽略的是“两套坐标系统”GeoJSON 里的经纬度是一个世界Canvas 上的像素是另一个世界中间必须隔一层视图变换。很多翻车现场都是经纬度直接当像素用图形要么飞出画布要么缩成一团。这套代码的架构就是围绕这条数据流组织的。2.1 目录结构与模块划分资源解开后是一个标准的原生前端项目没有框架依赖浏览器直接打开 index.html 就能跑。模块按职责拆得很干净核心目录结构大致是这样h5-canvas-map/ ├─ index.html # 页面骨架画布容器、工具栏、状态栏 ├─ src/ │ ├─ main.js # 入口组装各模块绑定事件 │ ├─ parser.js # GeoJSON 解析、校验、坐标归一化 │ ├─ renderer.js # Canvas 绘制底图、要素、高亮 │ ├─ editor.js # 交互拾取、拖拽、新增、删除 │ ├─ viewport.js # 视图变换平移、缩放、坐标换算 │ └─ exporter.js # 序列化回 GeoJSON ├─ samples/ │ └─ demo.geojson # 示例数据几个地块多边形parser、renderer、editor、viewport、exporter 这五个模块各管一摊边界很明确parser 只负责解析和校验不碰 Canvasrenderer 只负责把 feature 画出来不监听鼠标editor 只处理交互状态改完数据后通知 renderer 重绘。互相之间通过一个全局的 scene 对象通信这个对象里存了当前所有的 feature 和选中的 feature id。我拿到这种项目一般先看模块边界边界清楚了后面加功能就不会牵一发动全身。2.2 GeoJSON 的坐标结构先搞懂数据长什么样GeoJSON 是这套工具的唯一数据格式所以它的坐标结构必须吃透。拿示例数据里最常见的 Polygon 类型来说{ type: FeatureCollection, features: [ { type: Feature, properties: { name: 地块A, fill: #ff6600 }, geometry: { type: Polygon, coordinates: [ [ [100.201, 30.102], [100.201, 30.205], [100.305, 30.205], [100.305, 30.102] ] ] } } ] }这段示例坐标是虚构的只为说明结构。Polygon 的 coordinates 是一个三维数组最外层是一个环的数组环内的每个元素是 [经度, 纬度] 顶点对。GeoJSON 规范规定Polygon 的第一个环必须是外环后面的环是内环也就是洞。也就是说coordinates[0] 才是我们绘制时最常用的外环coordinates[1]、coordinates[2] 这些是镂空区域。画布地图只需要经纬度坐标所以 parser 的主要工作就是把这份 JSON 校验一遍确保 geometry.type 和 coordinates 的维度对得上再深拷贝一份存进 scene避免后续拖拽把原始文件对象改脏。2.3 viewport 变换平移缩放时锚点不漂移的关键viewport 是这套代码的地基。它维护了一个 camera 对象里面只有三个字段scale 是缩放倍数offsetX 和 offsetY 是画布原点的像素偏移。所有绘制和事件处理都通过两个换算函数在“世界坐标”和“屏幕坐标”之间切换// viewport.js —— 视图变换所有坐标换算都走这里 const camera { scale: 1, offsetX: 0, offsetY: 0 }; // 世界坐标经纬度转屏幕坐标像素 function worldToScreen(x, y) { return { x: x * camera.scale camera.offsetX, y: y * camera.scale camera.offsetY }; } // 屏幕坐标像素转世界坐标经纬度 function screenToWorld(sx, sy) { return { x: (sx - camera.offsetX) / camera.scale, y: (sy - camera.offsetY) / camera.scale }; } // 鼠标滚轮缩放锚点是鼠标当前位置 function zoomAt(mx, my, factor) { const before screenToWorld(mx, my); camera.scale clamp(camera.scale * factor, 0.5, 64); camera.offsetX mx - before.x * camera.scale; camera.offsetY my - before.y * camera.scale; }这里值得多说两句。zoomAt 的核心技巧是缩放前先把鼠标下的世界坐标取出来缩放后调整 offset让这个点重新回到鼠标位置视觉上就像是地图“绕着鼠标”缩放而不是绕着画布原点。mx、my 是鼠标相对画布左上角的像素坐标factor 是缩放系数滚轮向上取 1.1向下取 1/1.1。clamp 把 scale 限制在 0.5 到 64 之间防止用户滚过头找不到图形。还有个细节Canvas 的渲染状态里我一般会直接 setTransform(camera.scale, 0, 0, camera.scale, camera.offsetX, camera.offsetY)这样 drawFeature 里就不用每次手动调 worldToScreen直接拿经纬度画就行。但命中测试和拖拽更新顶点时必须用 screenToWorld 把鼠标像素转回世界坐标。两套写法并存容易乱这套代码统一成“绘制走 Canvas 矩阵交互走 screenToWorld”算是比较稳妥的约定。3. 绘制与编辑实现点、线、多边形怎么画出来又能拖起来有了 viewport 做地基绘制和编辑就都站在同一个坐标系上了。这一章拆开看 renderer 和 editor 的配合绘制引擎负责把 feature 变成 Canvas 路径命中测试负责感知鼠标点到了哪个要素拖拽逻辑负责把顶点拉到一个新位置并实时重绘。3.1 绘制引擎从 GeoJSON 到 Canvas 路径renderer 只做一件事遍历 scene 里的所有 feature按几何类型画出轮廓和填充。核心代码不长但参数上有些讲究// renderer.js —— 绘制单个 feature function drawFeature(ctx, feature) { // 只处理 Polygon外环取 coordinates[0] const ring feature.geometry.coordinates[0]; ctx.beginPath(); ring.forEach(([lng, lat], index) { if (index 0) ctx.moveTo(lng, lat); else ctx.lineTo(lng, lat); }); ctx.closePath(); // 选中态高亮换填充色和描边色 const isSelected feature.id scene.selectedId; ctx.fillStyle isSelected ? feature.properties.highlightFill || rgba(255, 102, 0, 0.45) : feature.properties.fill || rgba(38, 132, 255, 0.35); ctx.fill(evenodd); ctx.strokeStyle isSelected ? feature.properties.highlightStroke || #ff6600 : feature.properties.stroke || #2684ff; ctx.lineWidth 2 / camera.scale; // 线条粗细不随缩放变化 ctx.stroke(); }这段代码看着简单但有个关键点因为绘制前 Canvas 已经 setTransform 过了这里写经纬度坐标Canvas 会自动映射到屏幕。fill(evenodd) 是专门处理带洞多边形和自交多边形的后面避坑章会细说。lineWidth 除以 camera.scale 是个小技巧因为 Canvas 的 lineWidth 也会被矩阵缩放如果不除回去放大地图时线条会粗得吓人。绘制完成后还有一层“顶点手柄”遍历选中 feature 的顶点在每个顶点位置画一个小圆点用 6 号半径、白色填充加蓝色描边这样用户才知道哪个顶点可以拖。顶点和填充使用同一个坐标系代码容易保持一致。3.2 命中测试点选多边形用的射线法鼠标点下去怎么知道点中了哪个多边形这里用经典的射线法实现一个点在多边形内部的判断// editor.js —— 射线法判断点是否在多边形内 function pointInPolygon(px, py, ring) { let inside false; for (let i 0, j ring.length - 1; i ring.length; j i) { const [xi, yi] ring[i]; const [xj, yj] ring[j]; // 射线与边相交则翻转状态 if (yi py ! yj py px ((xj - xi) * (py - yi)) / (yj - yi) xi) { inside !inside; } } return inside; }射线法的原理是从点向右侧发一条水平射线统计射线穿过多边形边的次数奇数次在内部偶数次在外部。picked 流程是 mousedown 时把鼠标像素坐标转成世界坐标然后逆序遍历 features因为后画的要素视觉上在上层优先被命中。命中的 feature 设为 selectedId再调 render() 触发一次全量重绘高亮效果就出来了。边界情况值得提一句当鼠标恰好落在多边形顶点或边上时射线法有可能误判。实际交互里业务人员圈的地块一般不会精确到像素级偶发的边缘误判影响不大。如果想更稳可以给每个顶点加一个 4px 的包围盒优先判断顶点命中优先于多边形命中这套代码就是按这个优先级写的。3.3 顶点拖拽状态机 屏幕坐标转世界坐标拖拽是这套工具交互里最核心的部分也是体现“自由绘制”的地方。editor 内部维护一个 mode 状态取值是 idle、panning、vertex-drag 三种。mousedown 时先判断是否命中某个顶点命中则进入 vertex-drag否则判断是否命中某个 feature命中则选中再否则进入 panning 拖底图。// editor.js —— 查找鼠标附近可拖拽的顶点 function findVertex(px, py, feature) { const threshold 8; // 像素阈值 let best null; feature.geometry.coordinates[0].forEach(([x, y], idx) { const sp worldToScreen(x, y); const distance Math.hypot(sp.x - px, sp.y - py); if (distance threshold) best { idx, x, y }; }); return best; } // mousemove 里更新顶点位置 function onMouseMove(e) { const p getCanvasPoint(e); if (mode vertex-drag activeVertex) { const w screenToWorld(p.x, p.y); activeVertex[0] w.x; activeVertex[1] w.y; render(); } }这里有个容易被忽略的点activeVertex 存的不是坐标副本而是 geometry.coordinates[0][idx] 这个数组的引用。mousemove 里直接改数组元素render() 后新位置就画出来了。GeoJSON 本身就是可变数据结构这种引用式写法顺手但也意味着拖拽时千万别重新解析数据源否则引用断了。getCanvasPoint 负责把 e.clientX 转成画布相对坐标具体实现要减去 canvas.getBoundingClientRect().left这条在避坑章里还会强调。新增多边形的交互也值得一带双击空白处进入绘制模式每次单击加一个顶点双击结束时自动闭合环并把新 feature 的 properties 里填上默认名称和填充色。绘制过程中同样走 worldToScreen 画临时路径所以底图缩放平移时绘制点不会错位。4. 避坑排查坐标错位、填充空洞和导出文件打不开的常见问题这章是血泪经验整理全部来自实际跑这个项目时踩过的坑。每一条都有明确的“现象 → 原因 → 解决”按主题分三组来写。4.1 坐标与投影的坑现象导入 demo.geojson 后画布一片空白或者图形缩在左上角变成一个小点。原因GeoJSON 坐标是经纬度范围在 0 到 180 之间而 Canvas 画布是 1000 像素量级。如果直接用经纬度当像素画图形会落在画布坐标系的第一象限边缘肉眼几乎看不见。解决数据加载后必须先做一次 fitView扫描所有 feature 的坐标算出包围盒根据画布宽高计算初始 scale 和 offset让数据居中显示。具体做法是把包围盒的宽高和画布的有效宽高做比值取较小的比值作为初始 scale再反推 offset 让中心对齐。这套代码在 parser 里留了 fitView 的调用入口数据导入后会自动执行。4.2 绘制与填充的坑现象一个简单的矩形多边形填充后中间出现不规则的透明区域像是被挖了一块。原因Canvas 的 fill() 默认使用 nonzero 环绕规则。当一个多边形的环方向不一致或者边界存在自交时部分区域的环绕数为 0填充效果就被抵消了。这个问题在人工绘制地块时特别容易触发因为手绘的边界往往不够规整。解决一行代码的事fill(evenodd) 强制使用奇偶规则只要点在多边形内部就填充不关心环方向。从那以后我所有 Canvas 多边形填充都统一用 evenodd再也没见过“透明窟窿”。现象地图放大后多边形边线粗得像马克笔盖住了填充色。原因setTransform 会连带缩放 lineWidth2 像素的边线在 10 倍缩放下变成 20 像素。解决绘制时把 lineWidth 除以 camera.scale让物理线宽保持不变即 ctx.lineWidth 2 / camera.scale。4.3 数据导出与交互状态的坑现象导出的 .geojson 文件拖进 GIS 软件提示 invalid geometry或者干脆打不开。原因多半是 geometry.type 和 coordinates 维度不匹配。比如 geometry 声明为 Polygon但 coordinates 却塞了 MultiPolygon 的四维数组另一个高频原因是环没有闭合首尾点坐标不同严格校验的解析器直接拒绝。解决导出前做一次清理按 geometry.type 分支校验维度对 Polygon 的每个 ring检查首尾点如果是开环就把第一个顶点复制一份追加到结尾。另外建议把坐标四舍五入到 6 位小数经纬度 6 位小数精度大约是 0.1 米足够业务标注用还能显著减小文件体积。现象页面有滚动条或者画布不在浏览器左上角时点击和拖拽总是差一截。原因鼠标事件里的 clientX、clientY 是相对于浏览器视口的坐标而 Canvas 的坐标系是以自身左上角为原点。差的距离恰好是 canvas 元素到视口左上角的偏移。解决统一用一个 getCanvasPoint 函数做换算x e.clientX - canvas.getBoundingClientRect().lefty e.clientY - rect.top。所有鼠标事件进 handler 后第一件事就是过这个函数不要在业务代码里直接裸用 clientX。现象拖拽顶点时快速移动鼠标顶点“脱手”跳到很远的位置松开后无法恢复。原因拖拽过程中的 mousemove 事件和滚轮缩放事件可能交错触发缩放改变了 camera 参数但拖拽逻辑里缓存的还是旧的世界坐标导致回写位置错误。还有一种情况是移动太快中间帧的 mousemove 没来得及处理顶点坐标被覆盖成了一次未换算的数据。解决拖拽循环里每次都现算 screenToWorld不缓存世界坐标缩放事件触发时如果 mode 是 vertex-drag先把拖拽状态切回 idle等鼠标松开再重新拾取。交互状态机的边界必须显式处理不能指望事件按顺序到达。5. 导入导出让画好的地图能跟 geojson.io 这类工具互相接力画地图只是第一步数据得能进得来、出得去工具才不白做。这套代码的导入导出围绕标准 GeoJSON 展开这也是它跟 geojson.io 配合的基础。5.1 导入文件拖一个 geojson 进来就渲染导入逻辑放在 parser 里入口是一个 input 文件选择框。读取流程是 file.text() 拿文本JSON.parse 成对象再 normalize 成统一的 feature 数组// parser.js —— 解析并规整 GeoJSON 数据 function normalize(geojson) { // 兼容单 Feature 和 FeatureCollection 两种输入 const collection geojson.type FeatureCollection ? geojson : { type: FeatureCollection, features: [geojson] }; return collection.features.map((f) ({ // 用 uuid 做内部选中标记不污染原 properties id: f.id ?? crypto.randomUUID(), properties: { ...f.properties }, geometry: JSON.parse(JSON.stringify(f.geometry)) })); }这段代码解决三个隐患。第一单 Feature 输入也能被包成数组避免调用方判断类型第二id 用随机 uuid 而不是直接用原数据里的 id因为 GeoJSON 的 id 并不保证存在而且后面做选中态高亮时需要一个可靠标识第三geometry 做了深拷贝后面拖拽会直接改 coordinates 数组如果不拷贝两次导入同一个文件会互相污染。normalize 完成后再调 fitView 让数据居中renderer 立刻就能画出来。5.2 导出流程序列化加清理再下载导出逻辑在 exporter 里做的事情和导入正好相反遍历 scene.features序列化成 GeoJSON 结构再触发浏览器下载。核心序列化函数比较朴素// exporter.js —— 序列化为 GeoJSON function exportGeoJSON(features) { return { type: FeatureCollection, features: features.map((f) ({ type: Feature, id: f.id, properties: { ...f.properties }, geometry: deepClone(f.geometry) // 导出时再深拷贝避免影响编辑态 })) }; } // 下载动作Blob 临时 a 标签 function downloadGeoJSON(features) { const data exportGeoJSON(features); const blob new Blob([JSON.stringify(data, null, 2)], { type: application/geojson }); const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download map.geojson; a.click(); URL.revokeObjectURL(url); }JSON.stringify 第三个参数传 2是为了输出带缩进的 JSON。文件大一点无所谓但 git diff 的时候一目了然方便交接和审查。导出前一定记得先跑一遍 4.3 里说的 closeRing 和维度校验否则 GIS 工具会拒收。URL.revokeObjectURL 是收尾动作如果不调大文件连续导出几次会有内存隐患。5.3 与 geojson.io 这类在线 GeoJSON 编辑器的配合方式标题里提到的 geojson.io本质上是一个在线 GeoJSON 可视化编辑器。这套资源跟它的配合逻辑很简单本工具画完导出丢进在线编辑器里继续调样式、量算面积或者转其他格式反过来在线编辑器里画好的 GeoJSON拖进本工具就能继续渲染和编辑。两者对标准 GeoJSON 的兼容性都很好但有几个细节要留意。第一properties 里只放 JSON 原生类型像 Date 对象、Map 这类会在序列化时变成空对象的类型绕一圈就丢了第二坐标精度控制在 6 位小数内经纬度 6 位小数精度大约 0.1 米够用反而文件更小第三不要手写 crs 字段新版 GeoJSON 规范已经废弃它写上反而会让部分严格解析器报警。按我的习惯properties 里至少留一个 name 字段这样两个工具之间来回切换至少能认得出每个地块是谁。6. 二次开发吸附网格、图层显隐与业务数据绑定跑通之后二次开发的需求基本会集中在三个方向上绘制时对齐网格、图层显隐控制、业务属性联动。这三个改造点都能在这套模块化结构里小成本落地。绘制吸附网格是最常见的需求尤其做地块标注的时候业务人员希望边界能自动对齐到整数坐标。实现方式是在拖拽更新顶点时对世界坐标做一次取整// 在 editor.js 的拖拽更新逻辑里加入吸附 const SNAP 0.001; // 网格步长约 0.1 度 function snapCoord(value) { return Math.round(value / SNAP) * SNAP; } // 更新顶点时启用吸附 activeVertex[0] snapCoord(w.x); activeVertex[1] snapCoord(w.y);参数说明SNAP 是经纬度步长0.001 度大约对应 100 米左右。步长定成经纬度而不是像素好处是网格不随缩放变化放大缩小后顶点始终落在同一个物理网格上。想改密就调小数值想改疏就调大。图层显隐适合数据量大的场景。比如导入了十几个地块只想看某几个。改造方式很轻在 feature 上挂一个 visible 字段renderer 的 drawFeature 开头加一行if (feature.visible false) return;再在工具栏加一组 checkbox 控制显隐即可。注意过滤要在绘制层做不要在数据层把 feature 删掉否则切走再切回来还要重新加载。业务数据绑定的典型需求是“拖动顶点后自动重算地块面积”。多边形面积可以用鞋带公式算// 用于地块面积计算返回的是平面坐标面积 function ringArea(ring) { let sum 0; for (let i 0; i ring.length - 1; i) { sum ring[i][0] * ring[i 1][1] - ring[i 1][0] * ring[i][1]; } return Math.abs(sum / 2); }算出来的面积还需要乘一个系数换算成真实地面面积因为经纬度坐标不是等距投影粗略做法是以纬度 111 公里、经度 111 乘以纬度余弦来估算。这个系数在代码里留作配置项不同业务场景按需调整就好不必追求地理级精度。血泪经验在这一开始我把所有交互逻辑都写进 renderer后面加吸附和显隐改得头皮发麻。从那以后我每次做这种画布工具都强制走一遍固定顺序先定数据结构再做视图变换最后接交互。数据结构和变换对了加功能只是往模块里塞代码不会推到重来。希望帮到你。本文还有配套的精品资源点击获取