ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

H5 Canvas + GeoJSON 轻量地图方案:从手绘到 geojson.io 自由绘制

H5 Canvas + GeoJSON 轻量地图方案:从手绘到 geojson.io 自由绘制 简介一套面向Web前端开发者的H5 Canvas地图绘制示例资源依托Canvas API与ECharts地图系列结合httpgeojson.io在线工具自由定制GeoJSON数据解决网页中快速搭建可交互、可下钻地图的常见需求。资源为rar压缩包大小约1.1MB文件清单未在下载页详细展示。已有1630人学习下载。资源内容围绕地图可视化展开通过Canvas绘制基础轮廓与行政区边界利用ECharts实现省市层级切换、滚轮缩放与平移并支持将业务数据绑定至地理区域以颜色、大小等视觉元素呈现统计差异。借助httpgeojson.io可灵活编辑各级GeoJSON轻松调整地图范围与形状。适合具备一定前端基础、对地图数据可视化或地理分析展示感兴趣的开发者参考代码即可理解从GeoJSON整理、坐标转换到交互呈现的完整链路为自定义地图应用提供可复用的实现思路。1. 为什么在 H5 里用 Canvas 手绘地图而不是直接上 Leaflet一个 rar 包背后的轻量地图方案接到一个 H5 需求要在手机网页上画一个园区边界、几条巡检路线、几个围栏不需要卫星图、不需要路网底图只要能把几何图形显示出来并且允许人工调整。这种场景下引入 Leaflet 或 Mapbox 确实有点重底图服务要申请 key、瓦片请求要处理网络波动、包体一下就上去了。而“H5 Canvas 实现地图 结合 geojson.io 自由绘制地图”刚好是一条非常顺手的轻量路径。这套 rar 包方案的核心思路是把地图当成一张画布用 GeoJSON 当数据交换格式用 Canvas 2D API 完成渲染、缩放、平移和点线面的自由绘制geojson.io 则负责可视化取数和数据校对。它适合数据量在几百个要素以内的轻地图场景也是理解地图渲染原理最好的入门项目。下面按我自己实际落地时的顺序拆开讲。2. 先搞懂坐标系与 GeoJSON 结构动手画 Canvas 地图前必须立住的两块基石2.1 从经纬度到屏幕像素线性映射和 Web Mercator 怎么选地理数据进 Canvas第一件事不是画而是换算。GeoJSON 里存的坐标是经纬度单位是度Canvas 的坐标系原点在左上角x 轴向右、y 轴向下单位是像素。两者之间必须有一层投影函数。我自己的习惯是数据量小、区域范围小一个园区、一个县市时直接用线性映射把经纬度按比例压到画布上只有区域横跨多个省份、或者需要把 Canvas 叠加到标准地图底图上时才换 Web Mercator。线性映射在小范围里的误差肉眼几乎看不出来而且可逆性好反算经纬度做编辑操作非常方便。先给一个最常用的线性投影工具它负责经纬度和 Canvas 像素之间的双向换算。// project.js —— 经纬度与屏幕像素的双向换算 export function createProjector(bounds, canvasW, canvasH, padding 40) { // bounds: { minLng, minLat, maxLng, maxLat } // 必须用“度”为单位算出 x / y 两个方向各自的缩放比 const xScale (canvasW - padding * 2) / (bounds.maxLng - bounds.minLng); const yScale (canvasH - padding * 2) / (bounds.maxLat - bounds.minLat); // 取较小值保证数据等比缩放后不会超出画布 const scale Math.min(xScale, yScale); return { toPixel(lng, lat) { return { x: padding (lng - bounds.minLng) * scale, // 纬度越高屏幕 y 越小所以这里要反过来减 y: padding (bounds.maxLat - lat) * scale }; }, toLngLat(px, py) { return { // 逆运算编辑时把鼠标点还原成经纬度 lng: bounds.minLng (px - padding) / scale, lat: bounds.maxLat - (py - padding) / scale }; }, scale }; }这段代码里三个参数值得重点说。padding是四周留白默认 40 像素目的是让线条和图标不贴边canvasW / canvasH建议用 Canvas 的实际绘图宽高不是 CSS 宽高否则配合后面讲的高分屏适配时会算错toPixel里 y 方向的减法是最容易漏的网上很多半成品地图代码画出来上下颠倒就是忘了纬度大时屏幕坐标反而小。如果你拿到的是已经处理过的墨卡托坐标就跳过线性映射这一步直接按 x、y 平移缩放。如果数据跨度大线性映射会失真这时用 Web Mercator 公式先做一次归一化再把归一化结果套进上面的createProjector里。export function lngLatToMercator(lng, lat) { const x lng / 360; // 经度归一化范围约 [-0.5, 0.5] const y Math.log(Math.tan((90 lat) * Math.PI / 360)) / (2 * Math.PI); return { x, y }; } // 使用前先把 bounds 和每个坐标都转成墨卡托归一化值墨卡托的 y 公式来自球面投影推导lat越接近 90 度 y 越趋于无穷所以实际项目里通常会把纬度限制在 ±85 度以内。对大多数 H5 轻地图项目来说线性映射足够墨卡托更多是为了将来叠加底图留的后路。2.2 读懂 GeoJSON 的 Point / LineString / Polygon解析函数与容错GeoJSON 的标准结构是外层一个FeatureCollection里面是features数组每个 feature 有geometry和properties。geometry的type决定怎么画Point是单个坐标[lng, lat]LineString是坐标数组的数组Polygon是更深的嵌套第一层是环每个环是一串坐标。这里最坑的细节是GeoJSON 的坐标顺序永远是[经度, 纬度]不是我们口播时常说的“纬度、经度”。这个顺序一旦写反画出来的图形会跑到完全不相干的地方而且你盯着屏幕很难第一时间发现。我习惯在数据入口统一做一次解析和容错后续所有绘制代码都只依赖解析后的结构。// geojson.js —— 读取 GeoJSON 并归一化带基础容错 export function parseFeatureCollection(raw) { if (!raw || raw.type ! FeatureCollection || !Array.isArray(raw.features)) { throw new Error(数据不是合法的 FeatureCollection); } return raw.features .filter(f f f.geometry) .map(f { const g f.geometry; return { type: g.type, // 保留几何类型 coordinates: g.coordinates, // 原始坐标绘制时再展开 properties: f.properties || {} // 样式、名称等业务字段 }; }); }这一步做的事情很单纯把可能的null、空geometry过滤掉把properties归一成一个对象避免后续绘制时到处判断。真正的坐标展开放在绘制阶段因为它和循环、描边逻辑耦合在一起。// 把任意类型的 geometry 展开成 [lng, lat] 数组 export function getCoordinates(geometry) { const t geometry.type; if (t Point) return [geometry.coordinates]; if (t LineString) return geometry.coordinates; if (t Polygon) return geometry.coordinates[0]; // 只取外环 if (t MultiPolygon) return geometry.coordinates[0][0]; return []; }Polygon只取coordinates[0]意味着内环洞会被忽略。如果你的数据里有带洞的行政区面这样处理会画错但对园区、路线这类轻量场景外环已经能覆盖绝大多数需求真的需要洞时再改成遍历所有 ring并用ctx.fill(evenodd)让 Canvas 按奇偶规则挖洞。这里有个取舍绘制函数越通用写的分支越多对于标题这个量级的项目处理到MultiPolygon的取首环已经够用再复杂的结构交给 geojson.io 去校验。3. 用 Canvas 把 GeoJSON 渲染出来核心绘制流程与三个必调参数3.1 最小可运行的渲染主循环load → fitBounds → draw投影函数和解析函数就绪之后就可以写渲染器了。我一般会把渲染器封装成一个类外部只需要load(geojson)内部自动完成边界计算、初始视野适配和绘制。核心是fitBounds先遍历所有坐标算出外包矩形再基于它生成投影这样无论数据在哪个经纬度范围打开页面都能完整看到全貌不用手动拖地图。// renderer.js —— 最小 Canvas 地图渲染器 import { createProjector } from ./project.js; import { getCoordinates } from ./geojson.js; function calcBounds(features) { let minLng 180, minLat 90, maxLng -180, maxLat -90; features.forEach(f { getCoordinates(f).forEach(([lng, lat]) { if (lng minLng) minLng lng; if (lng maxLng) maxLng lng; if (lat minLat) minLat lat; if (lat maxLat) maxLat lat; }); }); return { minLng, minLat, maxLng, maxLat }; } export class CanvasMap { constructor(canvas) { this.canvas canvas; this.ctx canvas.getContext(2d); this.features []; this.baseProjector null; this.view { zoom: 1, offsetX: 0, offsetY: 0 }; } load(geojson) { this.features parseFeatureCollection(geojson); this.fitBounds(); this.draw(); } fitBounds() { const bounds calcBounds(this.features); // 基于数据范围生成基准投影后续缩放平移通过 view 叠加 this.baseProjector createProjector( bounds, this.canvas.width, this.canvas.height, 40 ); this.view { zoom: 1, offsetX: 0, offsetY: 0 }; } toScreen(lng, lat) { const p this.baseProjector.toPixel(lng, lat); return { x: p.x * this.view.zoom this.view.offsetX, y: p.y * this.view.zoom this.view.offsetY }; } draw() { const ctx this.ctx; ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.features.forEach(f this.drawFeature(f)); } drawFeature(f) { const pts getCoordinates(f).map(([lng, lat]) this.toScreen(lng, lat)); const ctx this.ctx; ctx.beginPath(); pts.forEach((p, i) { i 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y); }); // 多边形需要闭合路径否则描边会少一条边 if (f.type Polygon) ctx.closePath(); ctx.strokeStyle f.properties.stroke || #3388ff; ctx.lineWidth f.properties[stroke-width] || 2; ctx.stroke(); if (f.properties.fill f.type Polygon) { ctx.fillStyle f.properties.fill; ctx.fill(); } } }这段代码有四个参数值得调。padding在fitBounds里被写死成 40如果你的数据本身有大量标注文字建议改成 60 或 80stroke默认色#3388ff是地理信息工具里比较通用的蓝但也能从properties覆盖所以 geojson.io 里给每个要素配好颜色这里会自动生效stroke-width默认 2在手机上建议不小于 3否则高分屏下细线会发虚fill只在多边形时生效且要放在stroke之后避免半透明填充盖住边线。3.2 平移缩放怎么实现以鼠标为锚点的 zoom 才是“顺手”的很多半成品地图的缩放是直接改baseProjector的 scale这种做法最直接的问题是视野中心会乱跳用户会觉得“地图在跟我对着干”。正确做法是保持基准投影不变单独维护一个view对象缩放时以鼠标位置为锚点先记住鼠标此刻对应的经纬度放大后把这个经纬度重新拉回鼠标位置。这样缩放前后鼠标下的那个点纹丝不动手感才对。// 绑定在 canvas 上的 wheel 事件 canvas.addEventListener(wheel, (e) { e.preventDefault(); // 阻止页面滚动 const rect canvas.getBoundingClientRect(); const mx e.clientX - rect.left; // 鼠标相对 canvas 左上角的 CSS 像素坐标 const my e.clientY - rect.top; const anchor this.toLngLat(mx, my); // 缩放前鼠标位置的经纬度 const factor e.deltaY 0 ? 0.85 : 1.18; // 向下滚缩小向上滚放大 this.view.zoom * factor; if (this.view.zoom 0.5) this.view.zoom 0.5; // 缩放下限防丢失 if (this.view.zoom 20) this.view.zoom 20; // 缩放上限防精度问题 const newPx this.toScreen(anchor.lng, anchor.lat); // 缩放后锚点在哪 // 把锚点拉回鼠标位置差值就是新 offset this.view.offsetX mx - newPx.x; this.view.offsetY my - newPx.y; this.draw(); });这里需要补一个toLngLat的反算方法和toScreen是完全对称的toLngLat(px, py) { const bx (px - this.view.offsetX) / this.view.zoom; const by (py - this.view.offsetY) / this.view.zoom; return this.baseProjector.toLngLat(bx, by); }缩放下限 0.5 和上限 20 不是拍脑袋定的。下限太低时数据范围很小的地图会缩成一个小点交互就废了上限太高时浮点精度问题开始暴露坐标会轻微抖动。这两个值是“项目里翻过车之后定的”建议你根据实际坐标范围和线宽微调。平移的实现更简单pointerdown记录起点pointermove累加 offsetpointerup结束。注意wheel事件里e.preventDefault()是必须的移动端页面否则会在缩放时跟着滚动。let dragging null; canvas.addEventListener(pointerdown, (e) { dragging { x: e.clientX, y: e.clientY }; }); canvas.addEventListener(pointermove, (e) { if (!dragging) return; this.view.offsetX e.clientX - dragging.x; this.view.offsetY e.clientY - dragging.y; dragging { x: e.clientX, y: e.clientY }; this.draw(); }); canvas.addEventListener(pointerup, () { dragging null; });3.3 高分屏与触摸双端适配devicePixelRatio 和 pointer 事件iPhone 的逻辑分辨率是 375 物理宽度但devicePixelRatio是 2 或 3。如果不处理 DPR直接按 CSS 尺寸设置canvas.width画面会被浏览器拉伸填满线和文字全部发糊。这是我在这类项目里被吐槽最多的问题。适配方案是在初始化时把 Canvas 物理像素放大到rect.width * dpr再用setTransform(dpr, 0, 0, dpr, 0, 0)让绘图逻辑继续按 CSS 像素思考。function setupCanvas(canvas) { const dpr window.devicePixelRatio || 1; const rect canvas.getBoundingClientRect(); // 物理像素 CSS 像素 * dpr canvas.width rect.width * dpr; canvas.height rect.height * dpr; const ctx canvas.getContext(2d); // 之后的绘图坐标全部按 CSS 像素写浏览器自动映射到物理像素 ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return ctx; }注意一个容易忽略的点fitBounds里计算投影用的是canvas.width/canvas.height此时它们已经是物理像素。而事件里getBoundingClientRect得到的是 CSS 像素。因为setTransform(dpr)已经让toScreen输出的坐标按 CSS 像素解释所以两套坐标其实是对齐的——前提是你初始化时先执行setupCanvas再执行fitBounds。顺序反了地图会放得过大或过小。触摸端的事件我用pointer系列统一它在现代浏览器和 WebView 里能同时覆盖鼠标、手指和触控笔比分别绑touchstart和mousedown少写一半代码还少踩一坑移动端点击会触发一次模拟 mouse 事件导致拖拽和点击重叠用pointer事件加e.preventDefault()可以压住这个问题。4. 结合 geojson.io 的自由绘制闭环从网页画图到 H5 展示再导回去4.1 在 geojson.io 上快速产出测试数据画矩形、导 JSON 的标准路径标题里提到的 httpgeojson.io说的就是浏览器里打开的 geojson.io 这个在线地理数据编辑器。它左边是地图、右边是 GeoJSON 文本左边画一笔右边 JSON 实时变。这套 rar 方案里最舒服的工作流就是拿它当“数据生产车间”。我一般这样用打开 geojson.io找到左侧工具栏的矩形或折线工具在地图上把园区边界点出来。右侧面板会自动生成对应Polygon或LineString坐标顺序是标准[lng, lat]不用手工改。点击要素可以在右侧加properties比如name: 一号园区这些字段在 H5 渲染端可以直接当样式或标签用。最后用页面上的 Save 按钮下拉菜单导出 GeoJSON 文件或者直接 CtrlA 复制右侧 JSON 文本。为什么推荐它而不是直接手写 JSON因为手写坐标容易犯两种错经纬度顺序搞反、多边形不闭合。geojson.io 的编辑器会在画图时自动把多边形收尾闭合导出格式也规范等于把最脏的数据整理环节外包了。另一个理由是它能即时校验如果你把一个 Python 脚本生成的 GeoJSON 粘贴进去格式有问题它会立刻红色提示省去你在 H5 页面里 debug 的时间。4.2 把 H5 Canvas 里画的草图导出成 GeoJSON反算经纬度自由绘制的另一半是“在 H5 里画完把结果还给 GeoJSON”这样数据才能回到 geojson.io 校对或存到后端。导出前必须做一步关键反算交互过程中记录的鼠标坐标是屏幕像素不能直接写进 GeoJSON。正确路径是调用渲染器的toLngLat(px, py)反算成经纬度再拼 GeoJSON 结构。// export.js —— 把绘制层导成 GeoJSON export function exportDrawings(drawnFeatures) { const features drawnFeatures.map(({ type, coordinates, style }) ({ type: Feature, geometry: { type, coordinates }, // coordinates 已经是经纬度数组 properties: { ...style } })); return JSON.stringify({ type: FeatureCollection, features }, null, 2); // 缩进 2 空格方便直接贴到 geojson.io }一个完整的“画线”交互应该在pointerup时把整条线的屏幕点一次性反算。比如记录两个端点pointerdown时存经纬度起点pointermove时把中间点也追加进去结束时统一导出。这里有一个细节导出时如果遇到单个点要素要把它包装成{ type: Point, coordinates: [lng, lat] }如果是线coordinates就是二维数组。千万不要把屏幕坐标写进去否则 geojson.io 打开后所有要素会堆在非洲海岸附近的同一个点上这是新手最容易翻车的地方。拿到导出 JSON 后我通常会直接打开 geojson.io把 JSON 粘贴到右侧面板地图上立刻出现刚才画的图形。这个闭环的价值在于你不需要在 H5 里做复杂的属性编辑 UI要素的样式、名称和经验值都可以在 geojson.io 里批量改完再导回两端各司其职。4.3 数据通路剪贴板直贴、JSON 文件加载与 URL 分享这套方案实际项目里常见三种数据通路。第一种是剪贴板直贴适合开发调试第二种是 JSON 文件加载适合交付前替换真实数据第三种是把 GeoJSON 塞进 URL 的 hash 里分享适合跨设备快速预览。// 加载 JSON 文件到页面 input.addEventListener(change, async (e) { const file e.target.files[0]; if (!file) return; const text await file.text(); try { map.load(JSON.parse(text)); // 交给渲染器的 load 入口统一处理 } catch (err) { alert(GeoJSON 解析失败 err.message); } });加载入口要做两件事JSON.parse只能保证语法正确真正的结构校验要交给parseFeatureCollection错误提示要精准直接把err.message抛给用户比一句空洞的“数据错误”有用得多。URL 分享我一般只在演示时用做法是把 JSON 压缩后用encodeURIComponent放进location.hash但要注意 URL 长度限制超过 2 万字符就老老实实走文件通路。日常开发我更推荐剪贴板在 geojson.io 里画好复制 JSON粘贴到代码里一个demo.geojson.js文件里H5 页面引入即用调试效率最高。5. 这套方案常见的 5 个坑现象、原因、解决5.1 画出来的点全跑到非洲海岸GeoJSON 坐标顺序写反现象在北京标注的点地图上出现在非洲西海岸的几内亚湾附近。原因GeoJSON 标准规定坐标是[经度, 纬度]但很多人受“纬度在前”的日常习惯影响写成了[纬度, 经度]。北京经度约 116纬度约 39写反后 Canvas 把它解释成经度 39、纬度 116纬度 116 超出合法范围在 Web Mercator 下被投影到极地附近线性映射下则飞出视野。解决在parseFeatureCollection里加一道自动纠偏检查每个坐标的数值范围发现第一项绝对值大于 90 而第二项在 180 以内时交换两者function normalizeCoord(coord) { let [a, b] coord; // 纬度合法范围 [-90, 90]如果第一项超过它说明第一项其实是经度 if (Math.abs(a) 90 Math.abs(b) 180) return [b, a]; return coord; }这道“后悔药”加在数据入口坏数据一进门就被矫正绘制层不用背锅。5.2 缩放之后标记和线错位缩放中心没算准现象滚轮放大后标注点跑到了线的旁边再缩放一次偏得更远。原因view.zoom和offset由两套逻辑维护缩放时没有重新计算锚点导致投影和偏移量不一致。有时候是偷懒直接重建了baseProjector把缩放前已经累积的 offset 全部冲掉了。解决严格按 3.2 的顺序执行——先取鼠标位置对应的经纬度再改 zoom最后用经纬度反推 offset 修正量。只要toScreen和toLngLat是一对严格互逆的函数锚点就不会漂。5.3 多边形填充出现“对角线”路径没闭合或填充规则不对现象多边形填充后中间出现一条诡异的斜线像被切了一刀。原因beginPath之后只用lineTo画了四个点没有调用closePathCanvas 的描边和填充默认会把最后一个点和第一个点用直线连起来形成那个对角线另一种情况是多边形带洞默认nonzero填充规则会把洞也填上。解决画完所有点后按类型调用closePath对有洞的数据用ctx.fill(evenodd)。在drawFeature里我已经把closePath放进了Polygon分支这里要记住fill必须在closePath之后调用。5.4 手机上画面一片糊没适配 devicePixelRatio现象iPhone 上文字边缘全是锯齿直线像被水洇过。原因Canvas 物理像素跟不上屏幕像素密度浏览器强行拉伸导致模糊。解决setupCanvas里把canvas.width设为 CSS 宽度乘 DPR再用ctx.setTransform(dpr, 0, 0, dpr, 0, 0)复位坐标系。注意如果你在setupCanvas之后重新给canvas.width赋值比如窗口 resize 时Canvas 会清空上下文并重置 transform必须重新走一遍setTransform。这也是不少人改了宽度后画面反而空白的根因。5.5 拖动时明显掉帧没有分层和视口裁剪现象几千个点要素每次pointermove都卡一下尤其手机上发热。原因draw里把全部要素重绘一遍而拖动过程中每帧都在触发重绘无效计算太多。解决分三步走底图和“动态绘制中的草图”分开两层 Canvas 叠加“底图层只重绘视口内要素草图层随鼠标更新”离线渲染技术成熟后比如当数据超过 5000 个点时先把全部要素画到一个离屏 Canvas 上拖动时只平移离屏 Canvas不重绘内部线条。我这个方案里 5000 是个经验阈值超过它以后离屏 Canvas 的收益非常明显。6. 让这套 Canvas 地图方案真正能落地数据校验、本地调试与还能加的三件事方案能跑起来之后最后一步是把它从“demo”变成“能交付”。我每次接外部 GeoJSON 数据第一件事就是在加载入口加校验而不是等渲染出问题再回头查。function validateGeoJSON(geojson) { if (!geojson || geojson.type ! FeatureCollection) return 数据不是 FeatureCollection; for (const f of geojson.features || []) { if (!f.geometry || !Array.isArray(f.geometry.coordinates)) { return feature ${f.properties?.name || f.id || 未知} 坐标异常; } } return null; // 没问题 }这个函数能拦住最常见的“半截数据”空对象、非数组 coordinates、丢失 geometry 的 feature。交付前跑一遍比用户反馈“地图是白的”再排查高效一个量级。本地调试时注意别用file://协议直接打开 HTMLES 模块会被浏览器拦下。在项目目录起一个静态服务即可python3 -m http.server 8080然后浏览器访问http://localhost:8080/。如果端上用 nginx 或 Node静态资源配置同理重点是把index.html和.js文件放在同一级目录。最后说三个值得继续加的方向。第一是吸附绘制编辑新点时把坐标吸附到 10 像素范围内的既有节点实测能把人工描绘的误差从几十米降到几米第二是撤销栈每次绘制或移动操作入栈CtrlZ弹栈重绘成本很低但体验提升巨大第三是导出高清 PNG临时把 canvas 放大两倍重绘一次再canvas.toDataURL(image/png)导出的图交给非技术同事看不会糊。我自己的习惯是不管项目多小都把数据校验放在加载入口把 DPR 适配写成公共函数这两件事能替你挡掉至少一半的“玄学 bug”。这套方案我已经在不同项目里改过三轮每次改得最多的反而不是渲染逻辑而是数据清洗和交互细节。希望帮到你。本文还有配套的精品资源点击获取
返回列表