地图(下)— 动画、缩放与旋转交互
地图(下)— 动画、缩放与旋转交互
这一篇讲什么: 第 19 章的下半。地图的交互和图表有一个本质不同: 你可以改投影本身。缩放不一定套 SVG transform——直接调投影的 scale/translate, 整张图(含经纬网、标签)自动「语义缩放」;旋转更是地图独有——那是在转一个球。 本篇把动画(自转、沿轨运动)和交互(缩放四式、拖拽旋转)全部走完。
1. 顶层全景:地图交互的两条路线
路线 A:SVG transform 缩放(第 14 章的用法)
简单,但经纬网/标签会跟着变粗变大——几何缩放,要自己反缩放
路线 B:改投影参数 ★ 地图的默认路线
zoom 事件 → projection.scale(k × 原scale).translate(…)
→ 重算所有 path → 线宽字号天然稳定(语义缩放白送)
旋转:拖动 = 改 projection.rotate()
两角近似(λ,φ)——能跑,但放大后鼠标漂移、倒地球反向
正解:四元数 versor.js ——鼠标始终黏在球面的同一点
图说:路线 B 是「改变观察方式」,路线 A 是「改变图形本身」;地图要的是前者。2. 动画
2.1 地球自转:每帧加一个偏航角
骨架就三行(在 d3.timer 里):偏航角随时间线性增长 → projection.rotate(rotation)
→ 全部 path 重算 attr("d", geoPath)。原书的常数:每秒 20°1:
const rotation = [0, 0, 0];
const paths = svg.selectAll("path");
const initialYaw = rotation[0];
d3.timer(elapsed => {
rotation[0] = (initialYaw + (elapsed * 20) / 1000) % 360;
projection.rotate(rotation);
paths.attr("d", geoPath);
});卡顿四件套(原书列的优化):低分辨率数据、对齐帧率(d3.timer / rAF)、
Canvas 渲染、更高效的旋转算法(versor.js)。进阶玩法:晨昏影固定在原地,
球在它底下转(Animation/3-earth-night-day.html)2。
2.2 物体沿球面运动
沿大圆:d3.geoInterpolate(a, b) 造插值器,按行程比例取中间经纬度,
投影后平移标记。原书封装了一个「航线移动器」——输入起终点与角速度,
每帧返回当前位置;% distance 让飞机到终点后折返3:
function createRouteMover(sourceCoords, targetCoords, degreesPerSecond) {
const interpolate = d3.geoInterpolate(sourceCoords, targetCoords);
const distance = d3.geoDistance(sourceCoords, targetCoords) * 180/Math.PI;
return elapsed => {
const traveled = (elapsed / 1000) * degreesPerSecond;
const t = (traveled % distance) / distance;
return interpolate(t); // → [lon,lat]
};
}非大圆路径(等角航线、任意路线):先重采样成短线段,再逐段插值。
轨道级运动:d3.geoRotation() 函数复合——原书的卫星例:
三个旋转叠加(①发射场定位 ②轨道倾角 51.6° ③每圈漂移 20°),
每帧把三个四元数式的旋转依次作用在卫星坐标上4:
const toLocal = d3.geoRotation([-63.3052, -45.9646, 0]); // 发射场(拜科努尔)
const fromLocal = coords => toLocal.invert(coords);
const orbit = d3.geoRotation([0, 0, 51.6]); // 轨道倾角
d3.timer(elapsed => {
const angle = (elapsed * 40) / 1000; // 40°/秒
const drift = d3.geoRotation([angle/18, 0, 0]); // 每圈 20° 漂移
const geoCoords = drift(fromLocal(orbit([angle, 0])));
// …投影后摆标记…
});另一条偷懒路线:SVG 的 <animateMotion> 引用已投影的 path——
物体沿「屏幕上的路线」走,不是沿球面(第 13 章技法的直接复用)5。
3. 缩放与平移:从几何到语义的四个级别
3.1 投影缩放(语义缩放,白送)
保存投影初始 scale/translate,zoom 事件里按 transform 重设:
map.scale = projection.scale(); map.translate = projection.translate();
function projectionZoom(evt) {
const {k, x, y} = evt.transform;
projection.scale(k * map.scale)
.translate([x + map.translate[0]*k, y + map.translate[1]*k]);
view.selectAll("path").attr("d", geoPath); // 重算所有 path
}经纬网、国界、标签全部经投影重算——线宽字号天然不变(语义缩放的免费版)6。
3.2 细节分级:真·语义缩放
缩放时换内容:k>2 显示 10° 标签、k>4 换上 1° 密经纬网、k>12 显示 1° 标签。 预先把密的网格画好(opacity 0),zoom 里按 k 开关——第 14 章语义缩放思想 在地图上的直接翻译7。
3.3 框选缩放:brush + invert + fitSize
brush 选框(evt.selection 是像素角点对)
→ projection.invert 把两个角点换算回经纬度
→ 包成 MultiPoint 喂 fitSize(整幅视口)
→ 更新 path;brush.move(null) 释放选框
双击复位:重设回保存的 scale/translateinvert + fit 的组合拳是地理交互的惯用范式——像素与经纬度之间的桥就是这一对函数8。
3.4 点选缩放:先 rotate 到质心,再 fit
点击国家 → fitSize(视口, feature) 直接放大,有两个毛病:
变形不可控(高纬国家在 Mercator 里拉得老长)、跨投影边界的要素(过对向子午线的
俄罗斯)会画崩。正解:先 rotate([-质心经度, -质心纬度, 0]) 把观察者
转到该国的头顶,再 fit——任何投影下都得到最小变形9:
function featureZoom(evt, feature) {
const centroid = d3.geoCentroid(feature);
projection.rotate([-centroid[0], -centroid[1], 0]); // 转到头顶
projection.fitSize([w, h], feature);
view.selectAll("path").attr("d", geoPath);
}4. 拖拽旋转:从两角近似到四元数
4.1 两角近似的做法与它的两个坑
把横向鼠标位移线性映射成偏航、纵向映射成俯仰(scaleLinear 各一条),
zoom 事件里 rotate([λ(x), φ(y), 0])。能跑,但10:
- 放大后鼠标和地表点对不上(拖动一段距离,球转的角度与预期不符);
- 把地球转颠倒之后,鼠标方向全反(少算了第三个角 γ)。
4.2 正解:versor.js(四元数旋转)
数学一句话:拖拽期间保持鼠标压在球面的同一点。四元数(versor)
用四个数表示三维旋转,没有欧拉角的万向锁问题。versor.js 四件套:
versor(欧拉角)→ 四元数、versor.rotation(四元数)→ 欧拉角(投影只认后者)、
versor.cartesian([lon,lat])→[x,y,z]、versor.delta(v0,v1)→ 两点的相对旋转11:
const initial = {}; // start 时记下四项
function rotateStart(evt) {
initial.geoCoords = projection.invert(d3.pointer(evt, view.node()));
initial.xyzCoords = versor.cartesian(initial.geoCoords);
initial.rotationQ = versor(projection.rotate()); // 当前旋转 → 四元数
}
function rotating(evt) {
const finalGeo = projection.invert(d3.pointer(evt, view.node()));
const deltaQ = versor.delta(initial.xyzCoords, versor.cartesian(finalGeo));
const finalQ = versor.multiply(initial.rotationQ, deltaQ);
projection.rotate(versor.rotation(finalQ)); // 四元数 → 欧拉角 → 投影
view.selectAll("path").attr("d", geoPath);
}
view.call(d3.drag().on("start", rotateStart).on("drag", rotating));换成 zoom 行为 + projection.scale(原scale × transform.k) 即得旋转+缩放合一
(不再区分鼠标与滚轮事件)。翻转、倒转随便玩,鼠标永远黏在抓的那一点12。
4.3 惯性拖拽:d3-inertia 一行接入
松手即停太生硬。Philippe Rivière 的 d3-inertia 插件一行解决:
inertia.geoInertiaDrag(容器, updateMap, projection)——
updateMap 是你的重绘函数。要求投影支持 invert()(所有标准投影都支持)13。
5. 边界与更多工具(原书指路)
- d3-geo-voronoi:球面 Voronoi/三角剖分——最近邻(离目标最近的点)高亮、tooltip 响应分区;
- d3-tile:256px 栅格瓦片坐标计算,给 D3 投影自动变形裁剪——卫星底图;
- Leaflet:只用 Mercator 时的省事替代,可与 D3 混用;
- d3-contour / d3-tricontour:等值线多边形;
- d3-cartogram:变形统计地图(按数据扭曲面积);
- d3-composite-projections:法/西/葡/日等国本土+海外领地的复合投影14。
6. 作者的判断与证据
| 判断 | 性质 | 依据 |
|---|---|---|
| 「地图缩放该改投影而非套 transform」 | 路线选择 | 投影缩放自动语义化的论证6 |
| 「两角近似不够,正解是四元数」 | 技术定论 | 翻地球后鼠标反向的反例演示10 |
| 「点选缩放先转质心」 | 最佳实践 | 跨子午线失败与最小变形的对照9 |
7. 边界与局限
- versor 的数学只讲到「库怎么用」,四元数推导不展开;
- Canvas + 交互(取点)的组合只给了实现路径(geoContains/force.find 思路),无完整案例;
- 变形统计地图(cartogram)与其他星体地图只在指路清单里;
- 栅格底图集成的篇幅极小,真要用瓦片工程还得看 d3-tile/Leaflet 自己的文档。
判断(我们的,不是书里的): 本篇的收敛点:「投影即视图状态」。 图表的视图状态是 transform(外加几把要同步的比例尺), 地图的视图状态是投影参数(rotate/scale/translate 三件套)—— 缩放、平移、旋转、点选、框选,全部是这三参数的不同驱动方式, 而 invert/fit 是像素世界与经纬世界之间的双向门。 学会用投影参数思考,地图交互就从「一堆技巧」变成「三个数的更新策略」。 如果错,会错在: 这条收敛对矢量地图成立;一旦叠加栅格瓦片(d3-tile), 视图状态还要加上瓦片层级与原点,投影参数只是其中一层——但那是另一本书的内容。
8. 可带走的
- 自转动画:d3.timer 里每帧
rotate([yaw++,0,0])+ 重算全部 path; - 沿大圆运动:geoInterpolate 按行程比例取值;非大圆先重采样;
- 轨道运动:多个 geoRotation 函数复合(定位→倾角→漂移);
- 地图缩放首选「改投影参数」:scale×k、translate 加偏移,语义缩放白送;
- 真语义缩放:预画密网格与标签,按 k 分级开关;
- 框选缩放 = brush 选框 → invert 回经纬度 → fitSize;
- 点选缩放先
rotate([-λ,-φ,0])到质心再 fit——最小变形、不跨界; - 拖拽旋转两角近似会翻车(漂移+反向);正解 versor 四元数,库四件套;
- 惯性拖拽 d3-inertia 一行接入;
- 大数据组合:低分辨率数据 + Canvas + versor + rAF。
9. 原文地图
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| 自转动画 | Cartographical Visualizations | text/27-ch19-chapter-19-cartographical-visualizations.txt:1326(搜「Animating globe rotations」) · text/27-ch19-chapter-19-cartographical-visualizations.txt:1346(搜「20 degrees per second」) |
| 旋转性能优化 | Cartographical Visualizations | text/27-ch19-chapter-19-cartographical-visualizations.txt:1357(搜「jerky」) |
| 沿大圆运动 | Cartographical Visualizations | text/27-ch19-chapter-19-cartographical-visualizations.txt:1364(搜「objects on the globe」) · text/27-ch19-chapter-19-cartographical-visualizations.txt:1382(搜「createRouteMover」) |
| 轨道复合旋转 | Cartographical Visualizations | text/27-ch19-chapter-19-cartographical-visualizations.txt:1406(搜「geoRotation()」) · text/27-ch19-chapter-19-cartographical-visualizations.txt:1412(搜「Baikonur」) |
| 投影缩放 | Cartographical Visualizations | text/27-ch19-chapter-19-cartographical-visualizations.txt:1462(搜「Interactive projection zooming」) · text/27-ch19-chapter-19-cartographical-visualizations.txt:1477(搜「projectionZoom(evt)」) · text/27-ch19-chapter-19-cartographical-visualizations.txt:1485(搜「stroke widths」) |
| 细节分级 | Cartographical Visualizations | text/27-ch19-chapter-19-cartographical-visualizations.txt:1506(搜「k > 2」) · text/27-ch19-chapter-19-cartographical-visualizations.txt:1511(搜「k > 4」) |
| 框选缩放 | Cartographical Visualizations | text/27-ch19-chapter-19-cartographical-visualizations.txt:1532(搜「bounding box」) · text/27-ch19-chapter-19-cartographical-visualizations.txt:1551(搜「boundingObject」) |
| 点选与旋转质心 | Cartographical Visualizations | text/27-ch19-chapter-19-cartographical-visualizations.txt:1576(搜「projected feature」) · text/27-ch19-chapter-19-cartographical-visualizations.txt:1601(搜「anti-meridian」) · text/27-ch19-chapter-19-cartographical-visualizations.txt:1614(搜「geoCentroid(feature)」) |
| 两角近似与 versor | Cartographical Visualizations | text/27-ch19-chapter-19-cartographical-visualizations.txt:1644(搜「tricky part」) · text/27-ch19-chapter-19-cartographical-visualizations.txt:1294(搜「opposite direction」) · text/27-ch19-chapter-19-cartographical-visualizations.txt:1684(搜「versor@0.2」) · text/27-ch19-chapter-19-cartographical-visualizations.txt:1748(搜「rotating(evt)」) |
| 惯性拖拽 | Cartographical Visualizations | text/27-ch19-chapter-19-cartographical-visualizations.txt:1792(搜「Inertia dragging」) · text/27-ch19-chapter-19-cartographical-visualizations.txt:1802(搜「geoInertiaDrag」) |
| 其他工具指路 | Cartographical Visualizations | text/27-ch19-chapter-19-cartographical-visualizations.txt:1829(搜「d3-geo-voronoi」) · text/27-ch19-chapter-19-cartographical-visualizations.txt:1834(搜「d3-tile」) · text/27-ch19-chapter-19-cartographical-visualizations.txt:1844(搜「cartograms」) |
Footnotes
-
出处:「Cartographical Visualizations」第 1326–1347 段(
text/27-ch19-chapter-19-cartographical-visualizations.txt:1346,搜「20 degrees per second」)。 ↩ -
出处:「Cartographical Visualizations」第 1349–1362 段(
text/27-ch19-chapter-19-cartographical-visualizations.txt:1357,搜「jerky」;:1360,搜「versor.js library」)。 ↩ -
出处:「Cartographical Visualizations」第 1364–1404 段(
text/27-ch19-chapter-19-cartographical-visualizations.txt:1382,搜「createRouteMover」;:1388,搜「traveled % distance」)。 ↩ -
出处:「Cartographical Visualizations」第 1406–1429 段(
text/27-ch19-chapter-19-cartographical-visualizations.txt:1412,搜「Baikonur」;:1425,搜「drift(fromLocal(orbit」)。 ↩ -
出处:「Cartographical Visualizations」第 1366–1372 段(
text/27-ch19-chapter-19-cartographical-visualizations.txt:1367,搜「animateMotion」)。 ↩ -
出处:「Cartographical Visualizations」第 1462–1485 段(
text/27-ch19-chapter-19-cartographical-visualizations.txt:1463,搜「more effectively than SVG transforms」;:1480,搜「translate([ x + map.translate[0]」;:1485,搜「stroke widths」)。 ↩ ↩2 -
出处:「Cartographical Visualizations」第 1487–1527 段(
text/27-ch19-chapter-19-cartographical-visualizations.txt:1506,搜「k > 2」;:1511,搜「k > 4」;:1516,搜「k > 12」)。 ↩ -
出处:「Cartographical Visualizations」第 1532–1572 段(
text/27-ch19-chapter-19-cartographical-visualizations.txt:1551,搜「boundingObject」;:1557,搜「fitSize([map.dim.width」;:1568,搜「scale(map.scale).translate(map.translate)」)。 ↩ -
出处:「Cartographical Visualizations」第 1574–1623 段(
text/27-ch19-chapter-19-cartographical-visualizations.txt:1601,搜「anti-meridian」;:1605,搜「Rotating to the geographical centroid」;:1615,搜「rotate([-centroid[0]」)。 ↩ ↩2 -
出处:「Cartographical Visualizations」第 1627–1675 段(
text/27-ch19-chapter-19-cartographical-visualizations.txt:1644,搜「tricky part」;:1677,搜「opposite direction」)。 ↩ ↩2 -
出处:「Cartographical Visualizations」第 1676–1721 段(
text/27-ch19-chapter-19-cartographical-visualizations.txt:1684,搜「versor@0.2」;:1695,搜「versor.delta(v0, v1)」)。 ↩ -
出处:「Cartographical Visualizations」第 1723–1791 段(
text/27-ch19-chapter-19-cartographical-visualizations.txt:1738,搜「rotateStart()」;:1748,搜「rotating(evt)」;:1772,搜「d3.zoom().on("start"」)。 ↩ -
出处:「Cartographical Visualizations」第 1792–1813 段(
text/27-ch19-chapter-19-cartographical-visualizations.txt:1802,搜「geoInertiaDrag」)。 ↩ -
出处:「Cartographical Visualizations」第 1823–1854 段(
text/27-ch19-chapter-19-cartographical-visualizations.txt:1829,搜「d3-geo-voronoi」;:1834,搜「d3-tile」;:1843,搜「cartograms」)。 ↩