编程语言研究书库

地图(下)— 动画、缩放与旋转交互

地图(下)— 动画、缩放与旋转交互

这一篇讲什么: 第 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/translate

invert + 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. 可带走的

  1. 自转动画:d3.timer 里每帧 rotate([yaw++,0,0]) + 重算全部 path;
  2. 沿大圆运动:geoInterpolate 按行程比例取值;非大圆先重采样;
  3. 轨道运动:多个 geoRotation 函数复合(定位→倾角→漂移);
  4. 地图缩放首选「改投影参数」:scale×k、translate 加偏移,语义缩放白送;
  5. 真语义缩放:预画密网格与标签,按 k 分级开关;
  6. 框选缩放 = brush 选框 → invert 回经纬度 → fitSize;
  7. 点选缩放先 rotate([-λ,-φ,0]) 到质心再 fit——最小变形、不跨界;
  8. 拖拽旋转两角近似会翻车(漂移+反向);正解 versor 四元数,库四件套;
  9. 惯性拖拽 d3-inertia 一行接入;
  10. 大数据组合:低分辨率数据 + Canvas + versor + rAF。

9. 原文地图

主题原书章原文位置
自转动画Cartographical Visualizationstext/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 Visualizationstext/27-ch19-chapter-19-cartographical-visualizations.txt:1357(搜「jerky」)
沿大圆运动Cartographical Visualizationstext/27-ch19-chapter-19-cartographical-visualizations.txt:1364(搜「objects on the globe」) · text/27-ch19-chapter-19-cartographical-visualizations.txt:1382(搜「createRouteMover」)
轨道复合旋转Cartographical Visualizationstext/27-ch19-chapter-19-cartographical-visualizations.txt:1406(搜「geoRotation()」) · text/27-ch19-chapter-19-cartographical-visualizations.txt:1412(搜「Baikonur」)
投影缩放Cartographical Visualizationstext/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 Visualizationstext/27-ch19-chapter-19-cartographical-visualizations.txt:1506(搜「k > 2」) · text/27-ch19-chapter-19-cartographical-visualizations.txt:1511(搜「k > 4」)
框选缩放Cartographical Visualizationstext/27-ch19-chapter-19-cartographical-visualizations.txt:1532(搜「bounding box」) · text/27-ch19-chapter-19-cartographical-visualizations.txt:1551(搜「boundingObject」)
点选与旋转质心Cartographical Visualizationstext/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)」)
两角近似与 versorCartographical Visualizationstext/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 Visualizationstext/27-ch19-chapter-19-cartographical-visualizations.txt:1792(搜「Inertia dragging」) · text/27-ch19-chapter-19-cartographical-visualizations.txt:1802(搜「geoInertiaDrag」)
其他工具指路Cartographical Visualizationstext/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

  1. 出处:「Cartographical Visualizations」第 1326–1347 段(text/27-ch19-chapter-19-cartographical-visualizations.txt:1346,搜「20 degrees per second」)。 ↩

  2. 出处:「Cartographical Visualizations」第 1349–1362 段(text/27-ch19-chapter-19-cartographical-visualizations.txt:1357,搜「jerky」;:1360,搜「versor.js library」)。 ↩

  3. 出处:「Cartographical Visualizations」第 1364–1404 段(text/27-ch19-chapter-19-cartographical-visualizations.txt:1382,搜「createRouteMover」;:1388,搜「traveled % distance」)。 ↩

  4. 出处:「Cartographical Visualizations」第 1406–1429 段(text/27-ch19-chapter-19-cartographical-visualizations.txt:1412,搜「Baikonur」;:1425,搜「drift(fromLocal(orbit」)。 ↩

  5. 出处:「Cartographical Visualizations」第 1366–1372 段(text/27-ch19-chapter-19-cartographical-visualizations.txt:1367,搜「animateMotion」)。 ↩

  6. 出处:「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

  7. 出处:「Cartographical Visualizations」第 1487–1527 段(text/27-ch19-chapter-19-cartographical-visualizations.txt:1506,搜「k > 2」;:1511,搜「k > 4」;:1516,搜「k > 12」)。 ↩

  8. 出处:「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)」)。 ↩

  9. 出处:「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

  10. 出处:「Cartographical Visualizations」第 1627–1675 段(text/27-ch19-chapter-19-cartographical-visualizations.txt:1644,搜「tricky part」;:1677,搜「opposite direction」)。 ↩ ↩2

  11. 出处:「Cartographical Visualizations」第 1676–1721 段(text/27-ch19-chapter-19-cartographical-visualizations.txt:1684,搜「versor@0.2」;:1695,搜「versor.delta(v0, v1)」)。 ↩

  12. 出处:「Cartographical Visualizations」第 1723–1791 段(text/27-ch19-chapter-19-cartographical-visualizations.txt:1738,搜「rotateStart()」;:1748,搜「rotating(evt)」;:1772,搜「d3.zoom().on("start"」)。 ↩

  13. 出处:「Cartographical Visualizations」第 1792–1813 段(text/27-ch19-chapter-19-cartographical-visualizations.txt:1802,搜「geoInertiaDrag」)。 ↩

  14. 出处:「Cartographical Visualizations」第 1823–1854 段(text/27-ch19-chapter-19-cartographical-visualizations.txt:1829,搜「d3-geo-voronoi」;:1834,搜「d3-tile」;:1843,搜「cartograms」)。 ↩

On this page