这个地图组件是我在做省级业务数据看板时被逼出来的。当时的需求很明确:首页一张中国地图,鼠标划过省份要高亮,点击之后要“钻”进这个省,把地级市的轮廓展开铺满整个画布,还要带一段平滑的过渡动画;再点一下空白处要能退回来。我先用图表库试了一版,省级下钻能做,但那个展开动画怎么看怎么别扭——它是在两个独立的图表实例之间硬切,省界和市界之间没有任何视觉上的连续性,产品经理一句“能不能像地图 App 那样缩进去”就把我打回了原形。于是我把整块实现推倒重来,用 HTML5 Canvas 从经纬度开始自己画,最后出来的效果反而比图表库版本轻了三分之二。这篇就把这套 JS 特效从头到尾讲透:数据怎么选、投影怎么算、Path2D 怎么缓存、鼠标怎么命中、下钻动画怎么插值,以及我在真机上踩过的那些坑。
1. 一块地图画了三天:为什么最后我放弃了现成图表库
拿到“中国地图 + 地级市下钻”这个需求,绝大多数人的第一反应都是打开图表库文档,找地图组件,配一个geo或者series-map就收工。我一开始也是这么干的,而且确实半小时就跑通了。问题出在第二天联调的时候——设计稿里要求点击省份之后,整个画布有一个“镜头推近”的感觉,被点击的省份从原来的位置和大小,平滑地放大并且居中到画布正中间;同时其他省份要淡出,地级市的轮廓要淡入。这种动画在图表库体系里几乎没有现成的口子可以插,因为图表库把你的数据和坐标系封装得太严实了,你只能操作它暴露出来的itemStyle和emphasis,动画的起点终点全由它自己算。
后来我算了一笔账:我要的其实不是一个“图表”,而是一个带交互的矢量图形渲染器。地图只是它渲染的内容之一。把需求抽象到这一层,答案就很清楚了——用 Canvas 自己画。
1.1 图表库、SVG、Canvas 三条路的取舍
在动手之前我把三条技术路线认真对比了一遍,结论写在下表里,这个判断到今天为止我都没后悔过。
| 维度 | 图表库(地图组件) | 原生 SVG | 原生 Canvas |
|---|---|---|---|
| 开发速度 | 极快,配置即出图 | 中等,需要手动建节点 | 慢,投影、命中、重绘全自己写 |
| 下钻展开动画 | 几乎没有定制空间 | 可以,靠 CSS transition 改 path | 完全自由,任意插值 |
| 省级 + 市级节点量 | 无所谓 | 全国市级上千个 path 节点,DOM 会喘 | 一个 Path2D 就搞定,无压力 |
| 文字标注 | 现成 | 现成,但缩放会跟着变形 | 需要自己在屏幕坐标系另画一层 |
| 鼠标命中 | 现成 | 浏览器帮你做 | 要自己实现拾取算法 |
| 高 DPI 适配 | 现成 | 自动 | 手动乘 devicePixelRatio |
| 包体积 | 全量引入几百 KB | 几乎为零 | 几乎为零 |
关键决策点其实只有两个:节点数量和动画自由度。全国省级 34 个,加上地级市接近 400 个,如果每个行政区都是一个 SVG<path>,再加上描边、高亮态、文字,DOM 节点轻松破千。我之前在另一个项目里用 SVG 做过 800 个节点的散点图,Chrome 上勉强能跑,一到低端安卓机就变成幻灯片。Canvas 只有一个 DOM 节点,绘制开销花在 GPU 上,这条路明显更稳。
而动画自由度就更不用说了,视图的scale和translate完全是我自己维护的两个变量,想让镜头怎么飞就怎么飞。
1.2 手绘地图要付出的三笔成本
天下没有免费的午餐,选 Canvas 就等于主动接下了三份活。
第一笔是数据。图表库里内置的地图数据是打包好的,你不需要关心它长什么样。自己画就得自己去拿 GeoJSON,还得关心坐标系、精度、文件体积。全国省界不简化的原始数据有 6MB 以上,直接扔给前端用户是灾难。
第二笔是投影。经纬度是球面上的角度值,画布是平面直角坐标系,中间必须做一次数学映射。用错了投影,地图要么被拉扁,要么在南北方向严重变形。
第三笔是交互。Canvas 里画的图形不是对象,浏览器不知道你画了什么,鼠标滑过去不会给你任何事件。你得自己实现一套“坐标反查”——把鼠标位置映射回某个行政区。
这三笔活听着吓人,但真做下来每一项都有成熟套路。后面几节我会把每一步的完整代码和推导都摊开讲。
1.3 这个特效最终长什么样
先把交互形态说清楚,后面讲实现的时候你才有画面感。
初始状态是全国视图,34 个省级行政区以统一的浅灰蓝填充、白色细分隔线呈现。鼠标在省份上移动时,该省填充变为高亮色,同时画布右上角浮出一个信息卡,显示省名和对应的业务数值。这个过程的响应必须足够跟手,不能有肉眼可见的延迟,否则体验会很差。
点击某个省份后,进入过渡阶段:当前视图的变换矩阵(缩放倍数与平移量)在 600 毫秒内插值到“刚好把这个省框满画布”的目标值,同时省界淡化、该省的地级市边界从中央向外淡入。过渡结束后进入省级视图,此阶段只渲染这一个省的地级市,鼠标悬停高亮的是市而不是省。
再点击画布空白区域(或者右上角的返回按钮),视图反向插值回全国状态,地级市层淡出、省界层淡入,回到初始态。
整个过程中,所有图形都在同一张 Canvas 上绘制,没有第二张图,没有重排重绘,这也是它流畅的根本原因。
2. 地图数据的选型与瘦身:从 6.3MB 压到 380KB
地图能不能跑起来,八成取决于数据准备得好不好。我见过太多人卡在这一步:好不容易找到一份全国地图数据,打开一看傻眼了,一个省的坐标点有几万个,文件大得吓人,浏览器加载完直接卡死。这一节把数据这条线从头捋清楚。
2.1 读懂 GeoJSON 的三层嵌套
不管数据从哪来,落到前端基本都是 GeoJSON 格式。它的结构看着吓人,其实就是一个固定的嵌套层级,理解了就再也不会晕。
{ "type": "FeatureCollection", "features": [ { "type": "Feature", "properties": { "name": "某省", "adcode": "340000" }, "geometry": { "type": "MultiPolygon", "coordinates": [ [ [ [116.1, 33.2], [116.3, 33.4], "..."] ], [ [ [117.0, 30.1], "..."] ] ] } } ] }逐层拆开看:
FeatureCollection是整个文件,features数组里每个元素是一个行政区。Feature包含properties(属性,比如名称和行政区划代码)和geometry(几何形状)。geometry.type有三种可能:Polygon(单块)、MultiPolygon(多块,比如有岛屿的省份)、GeometryCollection(极少见)。- 坐标数组的维度是
[多边形][环][点][经纬度]。注意这个四层结构,写路径构建代码的时候最容易在这里踩坑。
这里的adcode是你后面做数据关联、按需加载的关键字段,务必保留。名称字段反而可能因为数据版本不同而有差异,做唯一标识还是得靠代码。
提示:行政界线数据建议使用官方或正规渠道发布的版本,用于商业产品上线前按相关规定确认是否需要走审图流程,这一点在做地图类产品时不能省。
2.2 用 mapshaper 做拓扑简化
原始测绘数据的精度远超屏幕需要。你在一个 1200 像素宽的画布上画全国地图,一个省能分到的宽度也就一两百像素,那些精确到小数点后六位的顶点,在屏幕上完全是重合的。把它们全留着,纯属浪费带宽和绘制时间。
我用的工具是 mapshaper,命令行和网页版都有,这里给命令行的做法,方便塞进构建流程:
mapshaper china-provinces.json \ -simplify 3% keep-shapes \ -o precision=0.01 format=geojson china-provinces.min.json两个参数值得说一下。-simplify 3%表示保留原顶点的 3%,keep-shapes保证简化后每个行政区都不会消失——这个参数非常关键,不加的话小面积的行政区可能被简化成空几何,地图上直接缺一块。precision=0.01把坐标精度限制在小数点后两位,大概相当于一公里级的精度,对屏幕渲染完全够用。
实测数据:一份 6.3MB 的全国省级数据,简化后 380KB 左右;地级市级别因为数量多,单省文件大概在 40 到 150KB 之间。这个体积对前端是完全可接受的。
还有一个选择是转成 TopoJSON,它会把相邻行政区的公共边界只存一次,体积能再压 60% 以上。代价是前端需要引入解码库,我在这个项目里权衡之后没用,因为省级数据 380KB 已经够小,加一个库反而得不偿失。
2.3 分包加载:省界首屏、市界按需
一个很容易犯的错误是把全国所有省界加所有市界打成一个包。全国地级市三百多个,全量数据加起来轻松超过 10MB,首屏直接完蛋。
我的做法是分成两层文件:
/data ├── provinces.json // 全国省级,380KB └── cities/ ├── 340000.json // 某省地级市 ├── 110000.json └── ...首屏只加载provinces.json,页面能立刻渲染出全国轮廓。当用户点击某个省份时,再用这个省的adcode拼出文件名去请求对应的市界数据。
这里有个细节要处理好:用户点击之后到文件加载完成之间有一段空白期,如果你什么都不做,用户会感觉点击没生效。我的处理是点击瞬间立即启动视图动画,动画时长 600 毫秒,同时并行发起请求。经验上局域网缓存命中时几十毫秒就回来了,公网首次请求通常在 200 毫秒以内,动画还没播完数据就到了,用户感知不到任何等待。为了保险,我还加了 Loading 状态:如果动画播完数据还没到,就在画布中央显示一个细环形进度指示。
另外一定要做内存缓存,用一个Map存已经加载过的省级市界数据,用户来回切换省份时不会重复请求。这个缓存我限制在 8 个省份,超出后按 LRU 淘汰,避免长时间使用后内存持续增长。
3. 经纬度怎么变成屏幕上的像素
这一节是整个特效的数学地基。很多人写地图卡住,就是卡在这一步没想明白:我手里是经纬度,画布上是像素,中间那个转换到底怎么做。
答案拆成两步:先把经纬度做一次球面到平面的投影,得到一个平面坐标系;再从平面坐标系做一次视图变换映射到画布像素。两步分开处理,逻辑会清晰很多。
3.1 墨卡托投影的公式与“度当量”技巧
最偷懒的做法是直接把经度当 x、纬度当 y,这叫等距圆柱投影。在中国这个纬度范围(大约北纬 3° 到 54°)用这种投影,南北方向会被明显拉长,视觉上就是地图看起来“瘦长”,不对劲。
我用的是墨卡托投影,公式如下:
const RAD = Math.PI / 180; const MAX_LAT = 85.05112878; // 墨卡托的极点截断值 // 经度:直接线性映射 function projectX(lng) { return lng; } // 纬度:取墨卡托的 y 值 function projectY(lat) { const l = Math.max(-MAX_LAT, Math.min(MAX_LAT, lat)); return Math.log(Math.tan(Math.PI / 4 + (l * RAD) / 2)) / RAD; }这里有一个我自己琢磨出来的实用技巧,分享出来能省你不少事:注意最后那个/ RAD。墨卡托投影的标准输出是弧度值,x 和 y 的量纲不一致,后续算缩放比例时要分别处理,很容易出错。除以RAD之后,y 的输出被转换成了“度当量”——也就是说,在这个坐标系里,数值 1 代表的范围和经度上的 1 度是同一尺度。这样 x 和 y 就可以用同一个缩放系数处理,代码干净很多。
纬度为什么要在 85.05 度截断?因为墨卡托投影在极点处趋于无穷大。虽然中国最北也就北纬 53 度多,理论上用不到截断,但如果你的数据里混进了异常坐标(这种情况真的遇到过,某份数据里有个顶点纬度写成了 90),没有保护的话Math.tan会算出Infinity,后面整个路径就废了,地图会莫名其妙地消失。
3.2 视图状态只用一个缩放系数和两个平移量
投影完成后,我得到了一个平面坐标系。接下来把这个坐标系映射到画布,用一个最简单的仿射变换就够了:
screenX = mapX * k + tx screenY = mapY * k + ty整个视图状态就三个数:k(缩放倍数)、tx、ty(平移量)。所有操作——缩放、拖拽、下钻动画——本质上都是在改这三个数。
const view = { k: 1, tx: 0, ty: 0 };我额外维护了一个minK和maxK,限制缩放范围。minK取初始“全国刚好铺满画布”的那个缩放值,用户不能再缩小;maxK大概取minK * 40,再大就没意义了,一个地级市会占满整个屏幕。
初始化的时候,我会先遍历所有省级要素,算出投影后的整体包围盒bbox,然后调用一个fitToBounds把全国装进画布:
function fitToBounds(bbox, cssW, cssH, padding = 24) { const kx = (cssW - padding * 2) / (bbox.maxX - bbox.minX); const ky = (cssH - padding * 2) / (bbox.maxY - bbox.minY); const k = Math.min(kx, ky); // 取小的那个,保证完整装下 const cx = (bbox.minX + bbox.maxX) / 2; const cy = (bbox.minY + bbox.maxY) / 2; return { k, tx: cssW / 2 - cx * k, ty: cssH / 2 - cy * k }; }Math.min那一步是必须的,取大的那个会导致某个方向溢出画布。这个函数后面还会复用——下钻到某个省的时候,把那个省的包围盒传进去,就得到了省级视图的目标变换。
3.3 以鼠标为锚点缩放
滚轮缩放如果只是简单改k,地图会以画布左上角为锚点缩放,体验很糟糕。正确的做法是让鼠标指向的那个地图点保持不动,这样才有“放大镜对准这里”的感觉。
推导过程其实只有三行。设鼠标屏幕坐标为(sx, sy),缩放前该点对应地图坐标m = (sx - tx) / k。缩放后希望这个m仍然映射到同一个sx:
sx = m * k2 + tx2 tx2 = sx - m * k2 = sx - (sx - tx) / k * k2代码:
function zoomAt(sx, sy, factor) { const k2 = Math.min(maxK, Math.max(minK, view.k * factor)); const ratio = k2 / view.k; view.tx = sx - (sx - view.tx) * ratio; view.ty = sy - (sy - view.ty) * ratio; view.k = k2; }有了这个函数,滚轮事件里滚一格就factor = 1.1,往下滚就factor = 1 / 1.1,非常顺手。
反过来,把屏幕坐标反查成地图坐标也很简单:
function screenToMap(sx, sy) { return { x: (sx - view.tx) / view.k, y: (sy - view.ty) / view.k }; }这两个函数是整个交互层的基础,后面做命中测试和 tooltip 定位都要用到。写的时候一定要确保鼠标坐标是相对画布左上角的,用event.clientX - canvas.getBoundingClientRect().left换算,否则当页面有滚动或者画布不在视口左上角时,所有交互都会偏移。
4. Path2D 缓存与三层绘制管线
数学搞定之后,就进入真正画画的部分了。这一节的重点不是“怎么画出来”,而是“怎么画得快”。前者随便写写就有结果,后者才是能不能上线跑三百个地级市还保持 60 帧的关键。
4.1 路径只在初始化时构建一次
如果每一帧都遍历 GeoJSON 数组、算投影、moveTo/lineTo,三百个地级市几万个顶点,一秒钟六十帧就是几百万次调用,浏览器直接跪。
正确做法是用Path2D。它可以把一条路径编译成一个对象,绘制的时候直接ctx.fill(path)就完事,不用再重复构建坐标。
function buildPath(feature, px, py) { const path = new Path2D(); const geom = feature.geometry; if (!geom) return path; // 统一成 MultiPolygon 的结构处理 const polygons = geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates; for (const polygon of polygons) { for (const ring of polygon) { for (let i = 0; i < ring.length; i++) { const [lng, lat] = ring[i]; const x = px(lng); const y = py(lat); if (i === 0) path.moveTo(x, y); else path.lineTo(x, y); } path.closePath(); // 每个环都要闭合 } } return path; }注意那个结构转换:Polygon的coordinates是三层数组,MultiPolygon是四层,用一行三元表达式统一成四层之后,后面的循环逻辑就只用写一套。这个技巧能省掉大量重复代码。
还有一点,每个环结束都要closePath,包括内环。少了这句,填充会出现奇怪的缺口,尤其是带岛屿和内陆湖的行政区。
构建好的 Path2D 直接挂在数据对象上:
regions.forEach(r => { r.path = buildPath(r.feature, projectX, projectY); });三百个地级市一次性构建大概 30 到 50 毫秒,这点开销完全可以接受。之后无论怎么缩放平移,都只是改ctx的变换矩阵,路径本身不动。
4.2 三层分离:填充、描边、文字
绘制管线我分成三层,每层的坐标系不一样,这是我在实践中总结出来最重要的一条经验。
第一层是背景层,用屏幕坐标系。画一个渐变底色或者浅色背景板,它不需要跟着地图缩放。
第二层是行政区层,用地图坐标系。用setTransform把视图变换应用上去,然后批量填充和描边。
ctx.save(); ctx.setTransform(dpr * view.k, 0, 0, dpr * view.k, dpr * view.tx, dpr * view.ty); // 1. 批量填充 ctx.fillStyle = '#c9d8e8'; for (const r of regions) { if (r === hovered) continue; // 高亮的单独画,避免重复 ctx.fill(r.path, 'evenodd'); } // 2. 高亮的那一个 if (hovered) { ctx.save(); ctx.shadowColor = 'rgba(40, 120, 220, 0.45)'; ctx.shadowBlur = 12 / view.k; // 阴影也要按比例补偿 ctx.fillStyle = '#4a9eff'; ctx.fill(hovered.path, 'evenodd'); ctx.restore(); } // 3. 统一的白色描边,线宽要补偿 ctx.lineWidth = 0.6 / view.k; ctx.strokeStyle = 'rgba(255,255,255,0.9)'; ctx.lineJoin = 'round'; for (const r of regions) ctx.stroke(r.path); ctx.restore();第三层是标注层,回到屏幕坐标系画文字。这一步特别重要,理由在第七章会详细讲——如果把文字也画在缩放坐标系里,一放大就糊成一坨。
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.font = '12px "PingFang SC", "Microsoft YaHei", sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#2c3e50'; for (const r of regions) { const sx = r.center.x * view.k + view.tx; const sy = r.center.y * view.k + view.ty; if (sx < 0 || sy < 0 || sx > cssW || sy > cssH) continue; // 屏幕外直接跳过 ctx.fillText(r.name, sx, sy); }r.center是每个行政区的标注锚点。它不能简单用包围盒中心,因为像某些形状不规则的省,包围盒中心可能落在省外。我用的做法是取该要素所有环上顶点的平均值,这个点基本都落在行政区内部,效果不错。
4.3 高 DPI 适配与线宽补偿
Canvas 在高分屏上默认会糊,这是老生常谈。解决方案是让画布的像素尺寸等于 CSS 尺寸乘以设备像素比,然后再把上下文整体缩放回去。
const dpr = window.devicePixelRatio || 1; const cssW = canvas.clientWidth; const cssH = canvas.clientHeight; canvas.width = Math.round(cssW * dpr); canvas.height = Math.round(cssH * dpr);关键点是:CSS 尺寸靠样式控制,像素尺寸靠属性控制,两者不能混。我见过有人只改了canvas.width就以为搞定了,结果画布在视觉上被拉大了两倍。
设置完尺寸后,每次渲染开始都要重置变换:ctx.setTransform(dpr, 0, 0, dpr, 0, 0)。注意是setTransform不是scale,因为渲染是多帧循环的,用scale会不断累积,第二帧之后画布就飞出屏幕了。
至于线宽补偿,原理很简单:在setTransform(k, ...)之后,lineWidth = 1意味着屏幕上 1 个地图单位的宽度,实际像素宽是1 * k。想让描边永远是 1 个 CSS 像素,就得写lineWidth = 1 / k。这是个很小的细节,但不做的话,放大到省级视图时省界会变成又粗又难看的白条。
5. 鼠标悬停怎么知道指向哪个市
前端的 Canvas 交互绕不开一个问题:浏览器不知道你画了什么,它只知道有个矩形画布,鼠标在上面移动。判断“鼠标在哪个市上面”这件事,必须自己实现。
5.1 颜色拾取法:比遍历路径省心得多
最直观的思路是遍历。把鼠标位置用screenToMap反算成地图坐标,然后对每个行政区的 Path2D 调用ctx.isPointInPath(path, x, y),第一个返回 true 的就是命中目标。
这个方案能用,但有几个麻烦:isPointInPath的坐标参数是在当前变换上下文下解释的,你得小心地在调用前设置好变换,或者干脆手动把屏幕坐标转成地图坐标传进去;再一个,遍历三百个路径,每次鼠标移动都要跑一遍,虽然单次只要几毫秒,但高频触发时会有明显的 CPU 占用。
我更推荐颜色拾取法,也就是额外准备一张离屏画布,把每个行政区填充成独一无二的颜色,鼠标移动时直接读那一个像素的颜色值,反推出索引。
const pickCanvas = document.createElement('canvas'); pickCanvas.width = cssW; pickCanvas.height = cssH; const pickCtx = pickCanvas.getContext('2d', { willReadFrequently: true }); function indexToColor(i) { const n = i + 1; // r 存低 8 位,g 存高 8 位,b 固定为 0 return `rgb(${n & 255},${(n >> 8) & 255},0)`; } function rebuildPick() { pickCtx.setTransform(1, 0, 0, 1, 0, 0); pickCtx.clearRect(0, 0, pickCanvas.width, pickCanvas.height); // 用和主画布完全一致的视图变换 pickCtx.setTransform(view.k, 0, 0, view.k, view.tx, view.ty); regions.forEach((r, i) => { pickCtx.fillStyle = indexToColor(i); pickCtx.fill(r.path, 'evenodd'); }); } function hitTest(sx, sy) { const d = pickCtx.getImageData(sx | 0, sy | 0, 1, 1).data; if (d[3] === 0) return -1; // 透明,说明点在空白处 return ((d[1] << 8) | d[0]) - 1; // 反解索引 }两个坑要提前说。第一,拾取画布的尺寸用的是 CSS 像素,不要乘 dpr,因为鼠标坐标也是 CSS 像素,两边尺度必须一致,否则所有命中都会偏移。第二,getContext时传{ willReadFrequently: true },这个提示会让浏览器把画布放在内存里而不是显存里,getImageData的读取速度会快一个数量级。不传这个参数的话,在高频读取时控制台会给你告警。
关于填充规则:这里必须和主画布用同样的规则。主画布用'evenodd',拾取画布也要用'evenodd',否则带空洞的行政区会错位。
5.2 拾取画布的重建时机
拾取画布不需要每帧重建,只在视图变换改变时才需要重画,也就是拖拽和缩放的时候。即使这样,拖动过程中每帧重建三百个路径的填充,也有点浪费。
我的优化是分层重建:拖拽过程中,先用一个低精度的拾取画布(只画省级,不画地级市),拖拽结束后再用requestIdleCallback重建高精度版本。多数情况下用户拖拽只是为了调整视野,不会在拖拽的瞬间去悬停,所以这个降级是安全的。
重建本身的开销也做了控制:只对当前视口内的行政区做填充,视口外的直接跳过,用路径包围盒快速判断。全国视图下省界数量少,无所谓;地级市视图下这个裁剪能省掉 70% 以上的填充操作。
5.3 tooltip 定位与防抖
悬停高亮的时候,我需要弹出一个信息卡显示名称和数据。这个东西我一开始是用 DOM 元素做的,绝对定位到鼠标位置。它比在 Canvas 里画要方便得多——圆角、阴影、多行排版全都是 CSS 的事,而且能直接选中文本。
定位的时候有个细节,鼠标靠近画布右边缘或者下边缘时,卡片会溢出容器。我做了一个简单的翻转逻辑:如果sx + tipWidth + 16 > cssW,就把卡片放到鼠标左侧;纵向同理。
防抖方面,getImageData是同步操作,本身很快,但悬停事件触发频率极高,每一帧都读也没必要。我的做法是在mousemove里只记录鼠标坐标,真正的拾取放在requestAnimationFrame回调里做,一帧最多拾取一次。这一个小改动能让悬停时的 CPU 占用下降一半以上。同时用一个变量记录上一次命中的索引,只有索引变了才更新高亮和 tooltip,避免无谓的重绘。
另外,鼠标移出画布的时候一定要记得清空悬停状态并重绘一次,不然高亮会尴尬地留在屏幕上。
6. 点击下钻:省级子地图的加载与展开动画
前面五节把所有基础设施都搭好了,这一节把它们串成一个完整的交互闭环。
6.1 一个极简的状态机
整个应用其实只有两个状态:national(全国视图)和province(省级视图)。我用一个对象描述当前状态:
const state = { mode: 'national', // 'national' | 'province' currentAdcode: null, // 进入省级视图时记录 regions: [], // 当前渲染的行政区数组 animating: false, animStart: 0, animDuration: 600, viewFrom: null, viewTo: null };regions这个字段是关键——它让渲染管线完全不用关心自己现在画的是省还是市,只管遍历数组。切换状态时只需替换regions和视图变换,其他逻辑一行都不用改。
点击处理的重心不是切换本身,而是在切换之前把该准备的东西都准备好。具体来说,点击一个省的时候我要做三件事:算出该省的地理包围盒、用它算出目标视图变换、发起市界数据请求。
async function drillDown(feature, index) { if (state.animating || state.mode !== 'national') return; state.animating = true; const bbox = computeBBox(feature); const target = fitToBounds(bbox, cssW, cssH, 40); const adcode = feature.properties.adcode; startViewAnimation(target, 600); const cities = await loadCities(adcode); state.currentAdcode = adcode; state.mode = 'province'; state.regions = cities.map(c => ({ ...c, path: buildPath(c.feature, projectX, projectY) })); state.animating = false; rebuildPick(); requestRender(); }注意这里没有等数据加载完再启动动画,两个动作是并行的。这样即使网络慢一点,用户也能立刻看到地图在移动,感知上比“点了没反应然后突然切换”好太多。
6.2 视图矩形插值与缓动
动画的本质是让view的三个参数从当前值平滑过渡到目标值。听起来简单,但有几个细节决定了观感好坏。
细节一:缩放倍数用对数插值。如果从 1 缩放到 20 是线性插值,你会感觉动画前半段几乎没动,后半段突然冲过去。原因是人眼对缩放的感知是对数关系的——从 1 到 2 的变化,和从 10 到 20 的变化,视觉冲击是一样的。所以应该插值log(k):
const kFrom = Math.log(viewFrom.k); const kTo = Math.log(viewTo.k); const k = Math.exp(kFrom + (kTo - kFrom) * eased);细节二:平移量要在插值之后再反推。如果单独插值tx和ty,动画过程中缩放中心会飘,看起来像是边走边抖。稳妥做法是先在“地图坐标”里插值出一个中间的视图矩形(左、上、宽、高),然后再统一换算成k/tx/ty。不过我在实际项目里试过,只要缓动曲线够平滑,直接插值tx/ty的观感也能接受,所以图省事就用了简单版。
细节三:缓动函数要选对。线性运动看起来非常机械。我用的是easeInOutCubic:
function easeInOutCubic(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }前段加速、后段减速,收尾很稳,是这类“镜头推进”动画最通用的选择。
动画循环本身就是一个requestAnimationFrame:
function tick(now) { const t = Math.min(1, (now - state.animStart) / state.animDuration); const e = easeInOutCubic(t); const kFrom = Math.log(state.viewFrom.k); const kTo = Math.log(state.viewTo.k); view.k = Math.exp(kFrom + (kTo - kFrom) * e); view.tx = state.viewFrom.tx + (state.viewTo.tx - state.viewFrom.tx) * e; view.ty = state.viewFrom.ty + (state.viewTo.ty - state.viewFrom.ty) * e; // 透明度也一起插值,实现省界淡出、市界淡入 state.fade = e; render(); if (t < 1) requestAnimationFrame(tick); }state.fade这个变量交给渲染管线使用:省界的 alpha 设为1 - e,市界的 alpha 设为e,两者交叉淡入淡出,视觉上就是一个层溶解成另一个层的过程。
6.3 子地图上的二次交互与返回
进入省级视图之后,交互逻辑和全国视图几乎一样,只是regions换成了地级市数组。上一节的颜色拾取、tooltip、高亮,全都不用改代码,这是前面把渲染管线和数据解耦带来的红利。
返回逻辑我做了两个入口。点击空白处返回,这是最符合直觉的;右上角再放一个返回按钮,给那些习惯找按钮的用户用。两者的处理完全一样:
function drillUp() { if (state.animating || state.mode !== 'province') return; state.animating = true; const target = fitToBounds(nationalBBox, cssW, cssH, 24); startViewAnimation(target, 480); state.mode = 'national'; state.regions = provinceRegions; // 切回省级数组,省界 Path2D 早就缓存好了 state.fade = 1 - state.fade; // 反向淡入淡出 setTimeout(() => { state.animating = false; rebuildPick(); }, 480); }有个细节值得单独说:反向动画的时长我设成了 480 毫秒,比下钻的 600 毫秒短。这不是随手写的,而是有心理学依据的——用户主动进入某个细节时,愿意多等一会儿来获得“进入”的仪式感;但退出时用户的心理预期是“赶紧回到全貌”,动画拖长了会让人烦躁。这个 1.25 倍的时长差异,实际体验上的差别相当明显。
判断“点击空白处”靠的就是命中测试返回 -1:
canvas.addEventListener('click', (e) => { const { sx, sy } = getPos(e); const idx = hitTest(sx, sy); if (idx === -1) { if (state.mode === 'province') drillUp(); return; } if (state.mode === 'national') drillDown(state.regions[idx].feature, idx); });7. 上线前踩过的五个坑
功能跑通只是第一步,真正让我熬夜的是上线前那一周的联调。下面这五个问题,每一个都足够让特效在真机上翻车。
7.1 文字跟着缩放糊成一团
这是最容易犯也最明显的错。我第一版把所有东西都画在同一个缩放变换下,包括省名。结果全国视图下文字正常,一旦下钻放大到省级,那些字被放大了十几倍,边缘全是锯齿,而且越大越糊。
根因是 Canvas 的文字渲染是基于当前变换矩阵在光栅化阶段处理的,缩放之后浏览器是把小字号位图放大,而不是用新字号重新排版。所以正确做法就是我前面说的:文字单独一层,在屏幕坐标系里画。
具体就是在渲染流程里先把变换重置成setTransform(dpr, 0, 0, dpr, 0, 0),然后对每个行政区,把它的标注锚点用当前的k/tx/ty换算成屏幕坐标,再fillText。这样无论地图放大多少倍,字都是同一个字号、同一个清晰度。唯一的额外工作是判断锚点是否在视口内——屏幕外的直接跳过,省下大量无用的文字绘制。
另外提醒一句,ctx.font的字符串里中文字体名必须加引号,写12px PingFang SC在某些浏览器里会静默失效回退到默认字体,写成12px "PingFang SC"才稳妥。这个问题我排查了快一个小时才找到。
7.2 空洞填不上:nonzero 与 evenodd
有天测试同事跑过来跟我说,某个省的中间那块明明是个湖,怎么被填成实心的了。我一看,果然是填充规则的问题。
Canvas 的fill默认使用nonzero规则:它按路径的方向(顺时针/逆时针)来判断一个点是在内部还是外部。如果外环和内环的方向一致,内环就不会被当成洞。很多 GeoJSON 数据在简化和转换过程中,环的方向会被破坏,结果就是空洞被填掉。
解决办法就是显式指定evenodd:它按射线穿过路径的次数来判断,奇数次在内部、偶数次在外部,跟方向完全无关,只要有嵌套关系就天然形成洞。
ctx.fill(path, 'evenodd');这一句话解决了我遇到的所有空洞问题。顺便说,evenodd在多重嵌套(比如岛中湖中岛)的情况下也表现正确,比nonzero省心太多。
代价是拾取画布必须也用evenodd,两边规则不一致的话,鼠标滑到空洞上会被误判成命中了行政区。这个坑我当时踩了,表现是鼠标移到湖面上还会高亮,查了半天才发现是拾取画布漏了参数。
7.3 移动端触摸与双指缩放
PC 上调通之后上手机,第一反应是“完全不能用”——单指拖动页面跟着滚,双指想缩放结果页面整体被缩放了。
处理方法是三件事。第一,给画布加touch-action: none,阻止浏览器的默认手势接管。第二,touchstart/touchmove/touchend里按手指数量分派逻辑:一根手指走拖拽,两根手指走缩放。第三,所有touch事件里都要preventDefault,否则滚动会跟拖动打架。
canvas.addEventListener('touchstart', (e) => { if (e.touches.length === 1) { lastPoint = { x: e.touches[0].clientX, y: e.touches[0].clientY }; } else if (e.touches.length === 2) { lastDist = distance(e.touches[0], e.touches[1]); lastCenter = center(e.touches[0], e.touches[1]); } e.preventDefault(); }, { passive: false });注意{ passive: false }这个选项,不加的话preventDefault会失效,并且控制台会给你一个警告。这是 Chrome 从某个版本开始对触摸事件做的优化,很多人会在这里卡住。
双指缩放的中心点要用两指的中点,而不是画布中心,否则缩放手感会很怪。实现上就是算出两指距离的变化比例,然后用它去调zoomAt(centerX, centerY, ratio),复用前面写好的函数。
单指拖动的时候还要处理一个细节:手指抬起后,惯性滑行要不要做?我试过做,但移动端上很容易滑过头,反而不好控制。最后砍掉了惯性,改成直接跟随手指,交互更“实”。
7.4 连续缩放时的帧率掉到 20
功能都做完之后我用性能面板跑了一次,发现连续滚动缩放的时候帧率从 60 掉到了 20 左右,卡顿感明显。
排查下来主要有两个原因。第一,每次缩放我都重建了拾取画布,三百个地级市的填充开销太大。第二,主画布每帧都在遍历所有行政区做填充和描边,视口外的那部分纯属浪费。
第一个问题的解法是前面提到的分层重建:拖拽和缩放的进行中只更新低精度拾取画布,手势结束 120 毫秒后再重建高精度版本。这个改动把缩放时的拾取开销几乎降到了零。
第二个问题的解法是视口裁剪。给每个行政区预先算一个投影坐标系下的包围盒,渲染前先用当前视图把它映射到屏幕坐标,如果完全在画布外就跳过。
function visible(r) { const x0 = r.bbox.minX * view.k + view.tx; const x1 = r.bbox.maxX * view.k + view.tx; const y0 = r.bbox.minY * view.k + view.ty; const y1 = r.bbox.maxY * view.k + view.ty; return x1 > -20 && x0 < cssW + 20 && y1 > -20 && y0 < cssH + 20; }加了 20 像素的余量,避免边界上的图形因为抗锯齿边缘出现闪烁。这两个优化加完,再跑性能面板,缩放全程稳定在 55 到 60 帧,低端安卓机上也能维持在 40 帧以上,基本可用了。
7.5 数据版本更新导致 adcode 对不上
最后一个坑最隐蔽。项目上线后两个月,我更新了一版地图数据,结果有几个省份点击之后加载不出地级市,控制台报 404。
原因是行政区划代码会变。有些省经历过区划调整,代码会新增或废弃。我的cities/{adcode}.json文件是按旧代码命名的,新数据里的代码变了,自然对不上。
解决办法是在构建阶段加一个校验脚本:遍历最新的省级数据,把每个adcode取出来,检查cities/目录下是否有对应的文件,缺失的直接报错中断构建。同时在前端加载失败时做兜底,fetch返回 404 就不进入省级视图,而是给用户一个轻提示,至少不会让整个页面卡在动画状态里出不来。
这个经验说白了就是一句话:凡是依赖 ID 关联的数据,都必须有构建期的完整性校验。手工维护的文件对应关系,迟早会出错。
8. 关于这套方案,我自己的几点使用体会
写到这里核心内容基本都覆盖了。最后分享几个我在多个项目里复用这套代码之后攒下来的心得,可能跟具体实现无关,但能帮你少走弯路。
投影函数的坐标轴方向一定要在项目初期就定死。我见过有人在同一个项目里,地图层用 y 轴向下、标注层用 y 轴向上,最后所有的标注都跑到地图外面去了,排查了很久。我这边的约定是:投影之后 y 值随纬度增大而增大,跟屏幕坐标相反,所有涉及屏幕的地方统一在最后一步翻转换算,绝不在中间层混合使用。
Path2D 的缓存粒度值得仔细想。我现在的做法是省级一层、市级一层,各自独立缓存,切换视图的时候直接换数组,不销毁也不重建。这套结构让全国到省级的来回切换几乎没有开销,用户狂点也能秒响应。内存占用大概在 20 到 40MB 之间,对桌面端完全无压力,移动端上我会在切换超过 8 个省份之后主动清掉最早的缓存。
颜色拾取法还有一个额外好处,就是它能顺便支持“框选”和“批量统计”。我后来在做另一个需求时,直接用拾取画布做了矩形范围内的省份统计,几行代码就搞定了,比遍历路径判断快得多。如果你后续要做区域多选之类的功能,这套基础设施可以原样复用。
如果你也想动手做一版,我的建议是先不要碰交互,就老老实实把全国轮廓画出来、能用滚轮缩放。这一步跑通了,剩下的交互和动画都是在这套视图变换上加逻辑,难度会低很多。真正难的部分从来不是画图,而是把那三个数字k/tx/ty管好。