ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Canvas 渲染 GeoJSON:构建轻量地图的完整指南

Canvas 渲染 GeoJSON:构建轻量地图的完整指南

简介:一份面向Web前端开发者的H5 Canvas交互地图示例资源,核心思路是结合ECharts与GeoJSON,在网页中绘制可自由定制、支持缩放和平移的行政地图。资源内容围绕三个关键技术展开:Canvas基础图形API用于勾勒地图轮廓,ECharts地图系列加载GeoJSON数据完成渲染,以及通过httpgeojson.io在线工具编辑边界数据,并配合zoomOnMouseWheel、Roam等配置项实现交互操作。整体包含从坐标转换、GeoJSON属性绑定到动态加载下级城市地图的完整实践路径,对开发数据看板、区域统计分析或业务监控地图有直接的参考价值。压缩包为RAR格式,共1.1MB,体量精简,便于本地快速查看;该资源已有1631人学习浏览,适合具有一定前端基础、希望快速上手地图可视化定制的开发者。

1. Canvas 地图方案:不依赖地图 SDK 的轻量渲染思路

手头有个运营后台的示例站点,需要把一片区域的关键地块画出来,既不要卫星底图,也不想引入一整套地图 SDK,加载一堆 JS 和样式资源。最初的想法是用一张静态图片配合绝对定位做标注,但业务区域每周都会调整,静态图改起来太痛苦。后来换了一个思路:用 Canvas 直接渲染 GeoJSON 数据,配合 geojson.io 在线绘制区域、导出 JSON、再让页面动态加载。这套方案最终落地成一个 H5 Canvas 地图项目,整个地图渲染不依赖任何第三方地图库,全部绘制逻辑由 Canvas 2D API 完成,适合对底图没有强依赖、只需要展示自定义区域的轻量场景。从实现效果来看,它比预想的要可靠得多,而且把绘制工作流从「改代码」变成了「在线画图、下载数据、刷新页面」三步,非常符合快速迭代的业务节奏。

这个方案的核心链路是:geojson.io 负责「画」,Canvas 负责「渲染」,中间用一个标准的 GeoJSON 文件做数据桥梁。看完这篇文章,你能复现出从数据准备到地图渲染的完整项目,并在此基础上扩展点击高亮、图层切换、视图导出等交互能力。

2. 技术选型与目录结构:从经纬度到 Canvas 像素的变换链路

2.1 为什么选择 Canvas 而不是 SVG 或 WebGL

地图可视化有 SVG、Canvas、WebGL 三条主流路径,各自适用的数据量级完全不同。WebGL 能撑起百万级点要素,但 Shader 编写和调试成本高,对纯展示场景是杀鸡用牛刀;SVG 的优点是 DOM 节点天然支持事件绑定和 CSS 样式,但一千个多边形就有上千个 DOM 节点,DOM 树压力很大,缩放平移时的重排代价也高。这个项目的数据量是百级到千级多边形,而且每个要素只需要描边和填充,Canvas 2D 是性价比最高的选择:一次性把所有要素绘制到画布上,没有 DOM 节点的数量压力,缩放平移时触发一次完整的重绘也能接受。

选型时唯一需要妥协的是事件交互,Canvas 没有「图形对象」的概念,所有图形画完就变成像素。做点击高亮时不能直接监听图形本身,而需要维护一份「坐标命中检测」的数据结构。这个在后文的二次开发章节会详细展开,这里先明确一点:用 Canvas 的代价是交互逻辑要自己做,收益是渲染性能和代码可控性。

2.2 项目目录结构与各文件职责

项目解压后的目录结构大致如下:

canvas-map/ ├── index.html # 页面入口,包含 Canvas 画布和工具栏 ├── css/ │ └── style.css # 基础布局与画布样式 ├── js/ │ ├── map.js # 地图核心:加载、变换、绘制主流程 │ ├── geojson.js # GeoJSON 解析与坐标遍历工具 │ ├── draw.js # 具体图形绘制函数:Polygon / LineString / Point │ └── viewport.js # 视口管理:缩放、平移、坐标变换 ├── data/ │ ├── region.geojson # 示例区域数据,从 geojson.io 导出 │ └── boundary.geojson # 边界线数据,用于叠加展示 └── README.md # 数据准备说明与运行方式

文件划分的原则是「数据解析、坐标变换、图形绘制」三者解耦。geojson.js只负责遍历坐标并提取边界范围,viewport.js只维护当前缩放比例和平移偏移量,map.js把所有部分串起来:加载 GeoJSON → 计算初始缩放 → 绑定画布事件 → 调用draw.js完成渲染。这样的分层在后期扩展时非常顺手,比如你要支持新的几何类型,只需要在draw.js里加一个对应的绘制函数,不需要动其他文件。

index.html里的关键结构就两样:一个<canvas id="mapCanvas">存画布,一个隐藏的<input type="file">用来加载本地 GeoJSON 文件。整页没有引入任何框架和外部库,原生 JavaScript 就能跑起来,部署时随便放到一个静态服务器或者直接用 Live Server 打开都行。

2.3 两条关键链路:坐标变换与渲染刷新

理解这套代码,需要抓住两条链路。第一条是数据链路:GeoJSON 里的经纬度坐标[lng, lat],要经过「边界归一化」和「视口变换」两步,最终变成 Canvas 上的像素坐标。第二步是关键,因为经纬度是地球表面的球面坐标,而 Canvas 是平面像素坐标,中间必须有一个投影过程。

投影最容易懂的做法是线性映射:先算出整个数据集的外接矩形bounds,再把矩形范围等比缩放到画布尺寸。缩放平移时,对整个坐标系乘以一个缩放系数scale并加上偏移量offset。这样实现的不是精确的墨卡托投影,但对小范围的区域示意图来说视觉上完全够用,代码量也最低。

第二条链路是渲染链路:任何视口变化(缩放、平移)都触发一次render()函数,它的执行顺序是「清空画布 → 遍历要素 → 应用变换矩阵 → 调用对应图形的绘制函数」。每次交互都走一遍完整循环,在要素数量不多时性能没问题;要素数量上万后,可以考虑把静态图层缓存在离屏 Canvas 上,这个在避坑章节会给出具体方案。

3. 核心模块拆解:GeoJSON 从解析到 Canvas 绘制的完整链路

3.1 加载数据与边界计算

geojson.js里最基础但仍然值得单独写的函数是「遍历所有坐标」。GeoJSON 的坐标嵌套层级不固定:Point是两层嵌套[lng, lat],LineString是三层,Polygon是四层(外环加内环),MultiPolygon是五层。如果写死循环层级,后续加数据时很容易翻车。用一个递归遍历函数最省心:

// 遍历 GeoJSON 中所有坐标对,统一回调经纬度 function forEachCoordinate(geojson, callback) { const type = geojson.type; let coordinates = geojson.coordinates; // FeatureCollection 需要先取出每个 feature 的 geometry if (type === 'FeatureCollection') { geojson.features.forEach((feature) => { forEachCoordinate(feature, callback); }); return; } // Feature 类型直接递归处理 geometry if (type === 'Feature') { if (geojson.geometry && geojson.geometry.coordinates) { forEachCoordinate({ type: geojson.geometry.type, coordinates: geojson.geometry.coordinates }, callback); } return; } // 判断当前坐标是不是一对经纬度:两个数字且范围在合法经纬度内 if (typeof coordinates[0] === 'number') { callback(coordinates[0], coordinates[1]); return; } // 数组就继续递归 coordinates.forEach((coord) => { forEachCoordinate({ type: type, coordinates: coord }, callback); }); }

递归的终止条件有两个:一个是收到FeatureCollection,需要解包成单个Feature再继续;另一个是坐标本身已经是数字对,直接回调。中间的判断typeof coordinates[0] === 'number'是关键,它会拦截所有多层嵌套的中间数组,直到最底层的经纬度数字对。

拿到遍历函数后,计算边界范围就顺理成章了:

function computeBounds(geojson) { let minLng = Infinity; let maxLng = -Infinity; let minLat = Infinity; let maxLat = -Infinity; forEachCoordinate(geojson, (lng, lat) => { if (lng < minLng) minLng = lng; if (lng > maxLng) maxLng = lng; if (lat < minLat) minLat = lat; if (lat > maxLat) maxLat = lat; }); return { minLng, maxLng, minLat, maxLat }; }

Infinity作为初始值是为了让第一次比较必然成立。注意经纬度的顺序,GeoJSON 规范里永远是[lng, lat]而不是[lat, lng],很多人第一次写解析代码都会在这里踩坑,把横纵坐标反掉。后续在避坑章节我会专门展开这个问题。

3.2 经纬度到屏幕坐标的变换与初始适配

边界算出来后,接下来是让地图第一次出现在画布上时恰好居中且铺满。这一步做的是「初始适配」:计算bounds的宽高比和画布宽高比,取较小的缩放系数,保证地图完整可见。这个逻辑在viewport.js里:

function fitBounds(bounds, canvasWidth, canvasHeight, padding) { const boundsWidth = bounds.maxLng - bounds.minLng; const boundsHeight = bounds.maxLat - bounds.minLat; const scaleX = (canvasWidth - padding * 2) / boundsWidth; const scaleY = (canvasHeight - padding * 2) / boundsHeight; // 取较小值,保证地图完整显示且不变形 const scale = Math.min(scaleX, scaleY); // 计算地图中心点对应的屏幕坐标 const centerX = canvasWidth / 2; const centerY = canvasHeight / 2; const offset = { x: centerX - ((bounds.minLng + bounds.maxLng) / 2) * scale, y: centerY - ((bounds.minLat + bounds.maxLat) / 2) * scale }; return { scale: scale, offset: offset }; }

这段代码有一个很容易被忽略的设计点:偏移量offset的计算不是简单地把minLng和minLat直接映射到画布原点,而是把地图中心(经纬度范围的中点)放到画布中心。这样即使数据集中在某个角落,地图第一次渲染时也能居中展示。

padding是边距,默认取 20 像素,防止地图贴边时边界线被裁掉。scaleX和scaleY分开算,再取Math.min,这个做法的含义是:完整展示整个数据区域,并且保持横向和纵向的等比拉伸,避免图形变形。这里的投影本质上是线性等距投影,不涉及真实地理坐标系,所以不会出现 Mercator 投影那种高纬度变形,在局部区域场景下视觉误差可以忽略。

3.3 render 主流程与 Polygon 绘制实现

draw.js里的核心是绘制函数的入口,它会根据geometry.type分发到对应的绘制逻辑。以最常见的Polygon为例,完整的绘制代码如下:

function drawPolygon(ctx, polygon, transform) { // polygon 是数组,它可能包含多个环:第一个是外环,其余是内环(洞) polygon.forEach((ring, index) => { if (ring.length < 3) return; // 少于三个点不成面 ctx.beginPath(); ring.forEach((coord, i) => { // 经纬度转屏幕坐标 const point = transform(coord[0], coord[1]); if (i === 0) { ctx.moveTo(point.x, point.y); } else { ctx.lineTo(point.x, point.y); } }); ctx.closePath(); if (index === 0) { // 外环:填充 + 描边 ctx.fillStyle = 'rgba(25, 110, 200, 0.3)'; ctx.strokeStyle = '#1a5c9e'; ctx.lineWidth = 2; ctx.fill(); ctx.stroke(); } else { // 内环:镂空,用目标背景色覆盖 ctx.fillStyle = '#ffffff'; ctx.fill('evenodd'); } }); }

这里的transform是一个函数,它把经纬度映射成屏幕坐标,实现细节就是上一节的视口公式:x = (lng - origin.lng) * scale + offset.x。把transform作为参数传入而不是在绘制函数内部直接计算,是为了方便在绘制时统一注入当前视口状态,测试时也可以替换成 mock 函数验证绘制逻辑。

Polygon 的内外环处理必须注意:GeoJSON 规范里,coordinates[0]是外环,后续元素是内环(洞)。如果外环和内环用同一套fill()逻辑,洞的区域会被填充覆盖,实际效果是「洞被填平」。解决方式是内环单独调用一次ctx.fill('evenodd'),利用 Canvas 的 evenodd 填充规则自动识别内外环的包含关系,这个属性是处理多边形镂空的标准做法,值得记住。

对LineString的绘制更简单,只需要beginPath、moveTo、lineTo,然后stroke描边,不填充。Point则用ctx.arc画圆点,再配合label属性绘制名称文字。三种几何类型覆盖了绝大多数业务区域的展示需求,如果你想支持MultiPolygon,在分发表里加一个分支,遍历多个 Polygon 即可。

3.4 缩放平移交互的实现

视口交互是地图体验的关键。缩放用滚轮事件,平移用鼠标拖拽。滚轮缩放最需要处理的细节是「以鼠标当前位置为缩放中心」,否则缩放时地图会往左上角跑,视觉上非常奇怪。核心代码如下:

canvas.addEventListener('wheel', (e) => { e.preventDefault(); const rect = canvas.getBoundingClientRect(); // 鼠标在画布内的像素坐标 const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; // 放大或缩小的比例因子 const factor = e.deltaY > 0 ? 0.9 : 1.1; const newScale = viewport.scale * factor; if (newScale < 0.001 || newScale > 1000) return; // 关键:缩放中心点保持不变 viewport.offset.x = mouseX - (mouseX - viewport.offset.x) * factor; viewport.offset.y = mouseY - (mouseY - viewport.offset.y) * factor; viewport.scale = newScale; render(); });

viewport.offset.x = mouseX - (mouseX - viewport.offset.x) * factor这行公式的含义是:缩放前鼠标位置对应的地理坐标(mouseX - offset.x) / scale,缩放后要落在同样的屏幕位置mouseX。所以先计算出比例差,再反推新的offset。理解了这个数学关系,缩放中心的问题就解决了。

平移用鼠标拖拽实现,逻辑相对直观:mousedown时记录起始点,mousemove时计算位移量并更新offset:

let isDragging = false; let lastX = 0, lastY = 0; canvas.addEventListener('mousedown', (e) => { isDragging = true; lastX = e.clientX; lastY = e.clientY; }); canvas.addEventListener('mousemove', (e) => { if (!isDragging) return; const dx = e.clientX - lastX; const dy = e.clientY - lastY; viewport.offset.x += dx; viewport.offset.y += dy; lastX = e.clientX; lastY = e.clientY; render(); }); canvas.addEventListener('mouseup', () => { isDragging = false; });

平移这里有个常见误区:offset.x加的是屏幕像素的位移dx,而不是经纬度数。因为视口层只关心屏幕坐标,两层变换之间是解耦的,所以平移时不需要重新做投影计算,只是简单累加偏移量,性能开销极小。render()每帧会全量重绘,但要素少时这个函数执行一次也就是几毫秒的事。如果后续数据量大了,就要考虑「重绘节流」或「离屏缓存」,这个放到第 5 章讲。

4. 串联 geojson.io 工作流:从在线绘制到项目落地渲染

4.1 在 geojson.io 上完成区域绘制的具体操作

geojson.io 是一个可以在浏览器里直接编辑 GeoJSON 数据的在线工具,核心价值在于「地图底图上框选 → 自动生成 GeoJSON 数据 → 一键保存文件」。它省去了手动写经纬度数组的笨办法,尤其适合画出不规则的业务区域边界。

操作路径很简单:打开 geojson.io 之后,在左侧工具栏选择画多边形工具,直接在底图上逐个点击打点,围出一个闭合区域,双击完成绘制。对已经画好的区域,用鼠标拖拽顶点可以微调边界,点「Edit」标签可以给这个多边形附加属性字段,比如区域名称、编号、分类等。这里有一个容易被忽略的产品细节:geojson.io 画的图形会自动记录你点击的经纬度,角度越密集,画出来的边界越贴合底图上的真实地形,建议绘制时沿着地图上的道路或地块边缘逐步打点,不要只点三四个顶点完事。

画完所有需要的区域后,点菜单栏的「Save」下拉按钮,选择「GeoJSON」格式,浏览器就会下载一个.geojson文件。把这个文件放进项目的data目录,地图渲染逻辑不需要任何改动就能直接展示刚画出来的区域。这个工作流的价值在于:业务人员调整边界时不再需要找开发改代码,自己画完导出一份新文件替换即可,开发同学只需要确认数据格式没问题。

4.2 导出的 GeoJSON 数据结构说明

从 geojson.io 导出的文件是标准的 GeoJSON 格式,顶层是一个FeatureCollection对象。如果你手里没有现成的示例文件,可以看一下它的典型结构:

{ "type": "FeatureCollection", "features": [ { "type": "Feature", "properties": { "name": "区域A", "level": 1 }, "geometry": { "type": "Polygon", "coordinates": [ [ [116.391, 39.907], [116.401, 39.917], [116.411, 39.907], [116.391, 39.907] ] ] } } ] }

重点关注geometry里的coordinates结构,它是三层嵌套的数组:最外层是一个数组,里面包含一个或多个「环」;每个环是一组坐标点;每个坐标点又是[经度, 纬度]的二元数组。第一层是外环,后续是内环。properties` 里可以放任意自定义字段,geojson.io 里编辑的属性都会出现在这里。

项目里的map.js加载这个文件时,就是拿到features数组然后逐个遍历绘制。properties里的自定义字段在绘制完成后可以用来做区域标签的显示内容,或者作为点击事件的回调参数传给业务逻辑。

4.3 数据落地前的校验流程

从 geojson.io 下载的文件虽然规范,但落地前还是建议做一遍数据校验。最直接的方式是打开文件看一下尖括号和逗号是否配对——虽然 JSON 格式的错误在JSON.parse时会直接报错,但如果错误发生在嵌套层级,报错信息不一定好理解。我通常会在项目里加一个简单的校验函数,在fetch加载后先JSON.parse转成对象,然后检查顶层类型是否FeatureCollection、每个 feature 是否有geometry和properties:

function validateGeoJSON(data) { if (!data || !data.type) { throw new Error('解析失败:数据为空或不是 JSON 格式'); } if (data.type === 'FeatureCollection') { if (!Array.isArray(data.features)) { throw new Error('解析失败:FeatureCollection 缺少 features 数组'); } data.features.forEach((feature, i) => { if (!feature.geometry || !feature.geometry.type) { throw new Error(`第 ${i} 个 feature 缺少 geometry`); } }); } return true; }

这个函数的作用是提前暴露数据结构问题,而不是等到绘制时才发现。实际经验中,最常见的异常情况是:geojson.io 中新建文件后直接拖拽上传了另一个文件,导致保存出的内容混入了多余字段;或者手改坐标时把[lng, lat]的顺序写反成[lat, lng](这个在避坑章节重点展开)。校验函数只查结构不查坐标合理性,如果要把坐标合法性检查加上,可以对每个坐标判断经度是否在[-180, 180]、纬度是否在[-90, 90]之间,超出范围则直接拒绝渲染并提示数据异常。

4.4 本地加载与远程加载的两种方式

项目支持两种加载方式:一种是用fetch直接请求data/region.geojson,适合部署在本地静态服务器或正式环境;另一种是页面内放一个文件选择控件,用FileReader读取用户选择的.geojson文件,适合在开发阶段快速替换数据文件、或者做数据演示时临时载入:

function loadGeoJSONFromFile(file) { const reader = new FileReader(); reader.onload = (evt) => { try { const data = JSON.parse(evt.target.result); renderGeoJSON(data); } catch (e) { alert('GeoJSON 文件解析失败,请确认文件格式'); } }; reader.readAsText(file); }

JSON.parse和renderGeoJSON之间被try-catch包裹,解析失败时来一个明显提示,方便排查。如果是后端接口动态下发数据,只需要把fetch的地址换成实际接口路径,同时保证接口返回的格式是标准的 GeoJSON 就行,代码无需改动。

5. 常见问题排查与性能优化:坐标反转、高分屏模糊、缩放卡顿

5.1 经纬度顺序反了:地图整体错位或形状异常

现象:从 geojson.io 导出的数据渲染出来后,图形歪斜、位置明显不对,或者整个图形被拉伸成奇怪的形状。有些区域甚至出现在完全错误的位置。

原因:GeoJSON 规范规定坐标格式是[经度, 纬度],即先写经度再写纬度。但很多人手工编辑数据或从其他工具转换时习惯写成[纬度, 经度]。Canvas 绘制时如果直接把第一位当经度用,第二位当纬度用,正好反了。纬度范围只有-90 到 90,经度范围是-180 到 180,如果不对坐标做范围校验,这种错误在数据量少时很难一眼看出来。

解决:在解析坐标时加一层校验逻辑,判断坐标对的数值范围。如果发现大量坐标的第一位在-90 到 90而第二位在-180 到 180,说明很可能是[lat, lng]的顺序,做一个自动交换再继续。我一般会在forEachCoordinate的回调里做判断:

callback = (lng, lat) => { if (Math.abs(lng) > 180 || Math.abs(lat) > 90) { // 明显是经纬度反了,交换处理 const temp = lng; lng = lat; lat = temp; } // 继续正常逻辑 };

这段校验能在渲染前兜住一大部分数据问题,而且不增加多少性能开销。真正的数据源头仍然要在 geojson.io 导出时确认清楚,别在底层代码里做太多「猜数据」的兼容逻辑。

5.2 高分屏下地图模糊:Canvas 物理像素与逻辑像素不匹配

现象:在 MacBook Pro 这类高分屏(devicePixelRatio 大于 1)上打开页面,地图文字发虚、边缘锯齿严重,整个画面像是被放大了半倍。

原因:Canvas 的width和height属性设置的是物理像素,而 CSS 的width和height设置的是逻辑像素。高分屏上让这两个数值相等,等于用一份像素渲染到 2 倍大小的设备屏幕上,浏览器会做一次插值放大,模糊是必然的。更本质的原因是latitude方向的视觉精度没有得到应有的像素密度支持。

解决:绘制前根据window.devicePixelRatio缩放画布的实际尺寸,再把绘图上下文按同样的比例放大:

const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; canvas.style.width = rect.width + 'px'; canvas.style.height = rect.height + 'px'; const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr);

这样做的效果是:Canvas 的物理像素数变成逻辑像素数的 2 倍,绘图时ctx.scale(dpr, dpr)让后续所有绘制指令自动放大到对应的物理像素位置,画面的细节精度显著提升。需要特别留意的是,fitBounds计算画布宽高时必须传入 CSS 逻辑像素的宽高,否则scale会按物理像素算而绘制又被ctx.scale放大,导致地图过大超出画布范围。这个细节很容易在适配高分屏时二次翻车。

5.3 缩放平移卡顿:全量重绘导致性能瓶颈

现象:地图放大缩小或拖拽时,画面有明显的掉帧感,尤其在绘制多个多边形、每个多边形顶点数较多的场景下,操作不跟手。

原因:每次视口变化都调用render()全量重绘,而这个函数会重新遍历所有要素、重新走坐标变换、重新做路径填充和描边。要素数不多时没问题,但当一个 Polygon 有上千个顶点,或者整个数据集的顶点总数超过几万,重绘的工作量就开始指数级上涨。

解决:优先做节流和分层缓存,两个方案配合使用。节流用requestAnimationFrame把交互事件和渲染解耦:

let rafId = null; function onViewportChange() { if (rafId) return; rafId = requestAnimationFrame(() => { render(); rafId = null; }); }

requestAnimationFrame会把多次连续触发的重绘请求合并到同一帧,避免滚轮事件每滚一格就触发一次完整的重绘。分层缓存则把静态图层(比如所有不变化的 Polygon 边界)绘制到一个离屏 Canvas 上,缩放平移时只需要把离屏画布按新的视口参数绘制一次,不用重新逐个跑坐标变换。这个方案需要额外维护一层缓存逻辑,在自己项目的要素数量超过几千之前,先用requestAnimationFrame节流就够用了。

5.4 geojson.io 导出的文件带 BOM 头导致解析失败

现象:用fetch请求本地.geojson文件,控制台报Unexpected token,但同一个文件用浏览器直接打开没有问题,复制到在线 JSON 校验工具里也正常。

原因:部分版本的 geojson.io 在导出文件时会留下一个 UTF-8 BOM(Byte Order Mark)头,即\uFEFF字符。这个不可见字符加在文件开头,JSON.parse遇到它就直接报错。文件在浏览器里直接打开时,浏览器会自动剥离 BOM,所以肉眼看不到问题。

解决:加载后先用text()读取内容,再做一层 BOM 清理再交给JSON.parse:

const text = await response.text(); const jsonText = text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text; const data = JSON.parse(jsonText);

charCodeAt(0) === 0xFEFF判断文件开头是不是 BOM 字符,是就去掉。这个问题的坑在于它只在特定环境下触发,本地开发时容易怀疑是代码本身的问题,排查方向容易跑偏。

5.5 图内文字随缩放变大变小:视觉体验细节问题

现象:给每个区域标注名称后,地图放大会出现文字尺寸跟着变大、缩小时文字挤成一团,视觉干扰严重。

原因:绘制文字时用的font-size是固定像素,它没有跟随viewport.scale做反向补偿。地图图形是等比放大的,文字却固定在屏幕像素大小,两者关系失衡。

解决:在draw.js里绘制文字的font属性时,设置一个与当前缩放比例成反比的字体大小:

const fontSize = 12 / viewport.scale; // 缩放越大地图字体相对越小 ctx.font = `${fontSize}px sans-serif`;

这样做的视觉效果是:放大地图时文字保持屏幕上的阅读大小不变,缩小时也不会挤成一团,始终保持可读。实际调试时可以根据屏幕尺寸和个人偏好微调12这个基准值,比如办公大屏可以调到14,演示投影可以调到16。

6. 二次开发实践:从静态展示到可交互图层系统

6.1 给要素加点击事件与高亮效果

Canvas 没有 DOM 级别的点击事件,所以需要靠「坐标命中检测」自己实现交互。思路是:把所有要素的屏幕坐标范围或者中心点存到一个数组里,点击时遍历数组找出距离鼠标位置最近的要素,然后单独重绘该要素的高亮状态。这里提供一个简化的命中检测逻辑:

canvas.addEventListener('click', (e) => { const rect = canvas.getBoundingClientRect(); const mx = (e.clientX - rect.left) / (viewport.scale); const my = (e.clientY - rect.top) / (viewport.scale); // 反向变换:屏幕坐标转经纬度 const lng = mx - viewport.offset.x / viewport.scale; const lat = my - viewport.offset.y / viewport.scale; // 遍历要素,判断 lng/lat 是否在某个 Polygon 内 const hitFeature = findFeatureAt(lng, lat); if (hitFeature) { selectFeature(hitFeature); } });

findFeatureAt用「点在多边形内」的射线法判断,把点击点转回经纬度后,对每个 Polygon 的外环做一次包含检测。这个方法的时间复杂度是 O(n),n 是要素数量,几百个要素时性能足够。检测命中后重绘该要素,改变其填充颜色或线宽,视觉上就有了高亮效果。

6.2 多图层叠加与切换

在业务里经常需要同时展示多个类型的数据:区域底图、边界线、点位标注。最简单的方式是维护一个数组,里面每个元素是一个图层对象,包含自己的data和render函数。切换图层就是修改数组的可见性字段,重绘时只绘制visible = true的图层。这样既保证了逻辑清爽,也方便后续把每个图层的数据独立从不同的 geojson.io 文件加载。

6.3 导出当前视图为图片

导出缩略图这个需求经常被忽略,但实际做的时候会发现它异常简单:Canvas 原生支持toDataURL,直接一行代码就能搞定:

const url = canvas.toDataURL('image/png'); const a = document.createElement('a'); a.href = url; a.download = 'map-view.png'; a.click();

需要提醒的是:导出前要确认是按物理像素计算画布尺寸的,否则导出的图片会偏小。从那以后,我每次完成一个 Canvas 地图的交互改造,都会强制把「导出图片」和「点击高亮」这两个一起测一遍,前者验证画布状态,后者验证事件计算,这两项不翻车,整体就稳了。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进