跳转至

缩放与平移 — 几何缩放、程序缩放与语义缩放

这一篇讲什么: 第 14 章的另一半。缩放看似简单(套个 transform 而已), 但原书用三个递进的层次拆开:基础(transform 挂对层、加约束)、 程序(按钮/回放,行为方法驱动)、语义(缩放不只是变大变小—— 轴要跟、线宽要稳、细节要分级出现)。这三层也是真实项目里逐个踩过去的坑序。

1. 顶层全景:一个对象 {x, y, k} 贯穿全篇

  用户手势(双击/滚轮/捏合/拖拽)        你的代码(按钮/回放/联动)
        │                                    │
        └──────────► d3.zoom() 行为 ◄────────┘
                        │ on('zoom', evt)
                        ▼
                 evt.transform = {x, y, k}
                   x,y:平移量  k:缩放倍率
                        │
        ┌───────────────┼───────────────────────┐
        ▼               ▼                       ▼
  挂到容器属性      重算比例尺(rescaleX/Y)    反算坐标(apply/invert)
  (几何缩放)       (轴跟着动的语义缩放)        (tooltip/加点对齐)

  图说:全部缩放语义浓缩成一个三元组;三种用法是它的三个消费方向。

2. 基础缩放:挂对层、加约束

2.1 最小例子与一个坑

  const zoom = d3.zoom().on('zoom', evt => {
    d3.select("svg").attr("transform", evt.transform);   // ← 坑在这
  });
  d3.select("svg").call(zoom);

直接 transform 整个 <svg> 会让视口本身跟着缩放——内容越界、边框变形。 正解:行为 call 在 svg 上(它监听事件),transform 却施加给内部一个 <g> 数据层1:

  svg.call(d3.zoom().on('zoom', evt =>
    svg.select("g").attr("transform", evt.transform)));

2.2 容器层级:数据区 ≠ SVG 区

带轴的图表里,轴要固定、只有绘图区缩放。原书的标准结构是四层:

  <svg>                 ← 监听 zoom 行为
    <g class="context"> ← 平移出边距
      <svg class="chart">  ← 固定尺寸的「数据视口」(必须是 <svg> 才能设宽高)
        <g class="container"> ← 真正吃 transform 的数据层

数据层外包一层固定尺寸的内嵌 <svg>,等于天然「剪辑窗口」——点缩放后越界即被裁掉。 等效替代:不给内层 <svg>,改用 <clipPath> 裁数据层(原书两案并列,效果相同)2。

2.3 三 extent 与事件过滤

方法 约束什么
scaleExtent([0.5, 2]) 缩放倍率范围(默认不限)
translateExtent([[0,0],[w,h]]) 平移边界(图角始终贴着视口)
extent([[…],[…]]) 缩放原点的参照区域,默认视口尺寸

事件分流也在这层:filter(evt => !evt.shiftKey) 让 Shift 场景让给刷选; svg.on("dblclick.zoom", null) 直接拆掉某个内置监听(如双击缩放)3。

3. transform 对象:不可变的

事件对象里的 transform(也可用 d3.zoomTransform(node) 随时取)是个不可变对象: {x, y, k} 默认恒等 {0, 0, 1}。塞进字符串上下文自动变 translate(x,y) scale(k)——所以 attr("transform", evt.transform) 一行就够4。

两个派生方法,顺序有讲究(先 translate 后 scale,反了平移量会被缩放)5:

  const trsf1 = new d3.ZoomTransform(2, 25, 10);   // {k:2, x:25, y:10}
  const trsf2 = trsf1.translate(50,50).scale(1.5);  // {k:3, x:75, y:60}
  // 还有现成常量 d3.zoomIdentity = {0,0,1}

别混淆:zoom.transform(行为的方法,往选择器上套变换)与 evt.transform (变换数据对象)是两个东西,只是名字撞车——原书专门加了 Important note6。

4. 程序缩放:行为方法驱动

把「套变换」从事件里解放出来,就是按钮、回放、联动地图的玩法。 方法都走 selection.call(zoom.方法, …) 的形式7:

方法 语义
transform(sel, t) 套上指定的变换对象(可配 transition 动画)
translateBy(sel, dx, dy) 相对平移
translateTo(sel, x, y) 把视野中心对准 (x,y)——注意内容反向移动
scaleBy(sel, k) 倍率相乘(受 scaleExtent 约束)
scaleTo(sel, k) 倍率设为绝对值(同样受约束)
  // 重置按钮:过渡回恒等变换
  d3.select(".chart").transition().call(zoom.transform, d3.zoomIdentity);

  // 动画序列:链式 call
  d3.select(".chart").transition().delay(1000)
    .call(zoom.transform, trsf1).transition().delay(1000)
    .call(zoom.transform, trsf2)…

  // 三连放大再缩回:scaleBy 累积 ×2 ×2 ×0.25
  .call(zoom.scaleBy, 2).transition().call(zoom.scaleBy, 2).transition().call(zoom.scaleBy, .25)

两个细节:transform() 方法无视 scaleExtent,scaleBy/scaleTo 遵守; translateTo 移动的是「视野」而非「内容」——中心对准 (0,0) 等于内容往反方向走8。 程序缩放默认用 d3.interpolateZoom 平滑(平移中尺寸连续变化), 不想要可 interpolate(d3.interpolate) 换成线性9。

5. 语义缩放:缩放改变「看到的」,不只是「大小」

5.1 几何 vs 语义

container.attr("transform", evt.transform) 是几何缩放:一切跟着 k 变大变小—— 散点的 1px 点变成大圆,地图的细国界变粗矩形。语义缩放则让图保持可读、并按层级揭示细节: 放大地图时出现更多城市,放大星图时出现更多星10。

5.2 三件工具

  ① 反缩放(保持视觉尺寸):一切想「不变粗」的量都除以 k
     function update() {
       d3.selectAll("circle").attr("r", 5/k).style("stroke-width", 1/k);
       d3.selectAll("text").style("font-size", 10/k);
       d3.selectAll("path.grid.major").style("stroke-width", 3/k);
     }
     .on('zoom', evt => { container.attr("transform", evt.transform); update(); });

  ② 重算比例尺(让轴跟着数据动)
     d3.select(".x-axis").call(xAxis.scale(evt.transform.rescaleX(xScale)));
     d3.select(".y-axis").call(yAxis.scale(evt.transform.rescaleY(yScale)));
     // rescaleX 返回一个「domain 已按当前变换调整」的比例尺副本,原尺不动

  ③ 点级取舍(按 k 决定画不画、画多大)
     function radius(d, k) {
       if (d.n * k > 1)  return 1/k + d.n * k/2000;  // 大点缓慢变大
       else if (d.n * k < 0.1) return 0;             // 太小不画,防糊
       else return d.n/k;                          // 中等点保持视觉大小
     }

①+③ 合起来的效果(原书 5 万点散点实验):缩放时点的屏幕尺寸稳定, 太密的点自动隐去,坐标轴数字随视野更新11。

5.3 性能:Canvas + requestAnimationFrame

5 万个圆点在 SVG 里做语义缩放会卡——DOM 太多。换成 Canvas 重绘后还有一个坑: zoom 事件触发得比屏幕刷新快,画不过来。原书的提速只有一行: requestAnimationFrame(() => paint())——把重绘对齐到浏览器的帧节奏12。

5.4 坐标换算:apply 与 invert

transform 还提供单点换算:apply([x,y]) 把数据点投到缩放后的屏幕; invert([x,y]) 反向——缩放状态下要按点击位置加一个新点、或给悬停点摆 tooltip, 就得用它把屏幕坐标译回数据坐标13。

6. 作者的判断与证据

判断 性质 依据
「transform 挂内层 <g>,别挂 <svg>」 实操铁律 视口随内容变形的反例演示1
「语义缩放要除以 k」 核心技法 反缩放 update() 的最小实现11
「Canvas + rAF 是大数据缩放标配」 性能经验 5 万点实验的卡顿与修复12

7. 边界与局限

  • apply/invert 正文明说「not covered」,只有仓库示例13。
  • interpolateZoom 的高级用法(视图间平滑飞行)同样只在 Extras。
  • 触摸双指捏合的数学(两指中点、距离比)由行为内部处理,不拆。
  • 语义缩放的「细节层级」策略(何时出现城市名)只有示例没有普适配方。

8. 可带走的

  1. zoom 的全部状态 = {x, y, k};行为挂外层、transform 施加内层数据 <g>;
  2. 三层容器(svg → context → 内层 svg/clipPath → 数据层)是带轴缩放的标准结构;
  3. scaleExtent 限倍率、translateExtent 限平移、filter 分流事件、xxx.zoom 拆内置监听;
  4. transform 不可变:translate/scale 方法派生新对象,先 translate 后 scale;
  5. 程序缩放四方法:transform/translateBy/translateTo/scaleBy/scaleTo,走 .call(zoom.方法, …);
  6. scaleBy 相对、scaleTo 绝对;translateTo 移动的是视野(内容反向);
  7. 语义缩放三件套:视觉尺寸 ÷k、rescaleX/Y 重算轴、按 k 分级取舍细节;
  8. 大数据语义缩放:Canvas 重绘 + requestAnimationFrame 对齐帧率;
  9. apply/invert 做缩放状态下的单点坐标换算(tooltip、加点)。

9. 原文地图

主题 原书章 原文位置
最小 zoom 与挂层之坑 Events and Interactivity text/21-ch14-chapter-14-events-and-interactivity.txt:1024(搜「d3.zoom().on('zoom'」) · text/21-ch14-chapter-14-events-and-interactivity.txt:1030(搜「keep SVG dimensions fixed」)
配置方法与三 extent Events and Interactivity text/21-ch14-chapter-14-events-and-interactivity.txt:1050(搜「scaleExtent(extent)」) · text/21-ch14-chapter-14-events-and-interactivity.txt:1078(搜「scaleExtent([0.5, 2])」) · text/21-ch14-chapter-14-events-and-interactivity.txt:1084(搜「translateExtent」)
容器层级与裁剪 Events and Interactivity text/21-ch14-chapter-14-events-and-interactivity.txt:1105(搜「context」) · text/21-ch14-chapter-14-events-and-interactivity.txt:1111(搜「chart = context.append("svg")」) · text/21-ch14-chapter-14-events-and-interactivity.txt:1135(搜「clipPath」)
filter 与拆监听 Events and Interactivity text/21-ch14-chapter-14-events-and-interactivity.txt:1162(搜「evt.type === 'wheel'」) · text/21-ch14-chapter-14-events-and-interactivity.txt:1172(搜「dblclick.zoom」)
transform 对象与方法 Events and Interactivity text/21-ch14-chapter-14-events-and-interactivity.txt:1179(搜「x, y, and k」) · text/21-ch14-chapter-14-events-and-interactivity.txt:1194(搜「toString()」) · text/21-ch14-chapter-14-events-and-interactivity.txt:1220(搜「new d3.ZoomTransform(2, 25, 10)」) · text/21-ch14-chapter-14-events-and-interactivity.txt:1225(搜「trsf2」)
transform 名混淆警告 Events and Interactivity text/21-ch14-chapter-14-events-and-interactivity.txt:1332(搜「Important note」)
程序缩放方法表 Events and Interactivity text/21-ch14-chapter-14-events-and-interactivity.txt:1288(搜「transform(selection,transform)」) · text/21-ch14-chapter-14-events-and-interactivity.txt:1349(搜「scaleBy, 2」) · text/21-ch14-chapter-14-events-and-interactivity.txt:1381(搜「translateBy, 200, 150」)
interpolateZoom Events and Interactivity text/21-ch14-chapter-14-events-and-interactivity.txt:1405(搜「interpolateZoom」) · text/21-ch14-chapter-14-events-and-interactivity.txt:1410(搜「interpolate(d3.interpolate)」)
语义缩放三件套 Events and Interactivity text/21-ch14-chapter-14-events-and-interactivity.txt:1424(搜「Semantic zoom」) · text/21-ch14-chapter-14-events-and-interactivity.txt:1464(搜「5/k」) · text/21-ch14-chapter-14-events-and-interactivity.txt:1484(搜「rescaleX(xScale)」) · text/21-ch14-chapter-14-events-and-interactivity.txt:1522(搜「radius(d, k)」)
Canvas + rAF Events and Interactivity text/21-ch14-chapter-14-events-and-interactivity.txt:1556(搜「requestAnimationFrame」)
apply/invert Events and Interactivity text/21-ch14-chapter-14-events-and-interactivity.txt:1569(搜「apply([x,y])」) · text/21-ch14-chapter-14-events-and-interactivity.txt:1582(搜「not covered in this book」)

  1. 出处:「Events and Interactivity」第 1016–1042 段(text/21-ch14-chapter-14-events-and-interactivity.txt:1024,搜「d3.zoom().on('zoom'」;:1031,搜「keep SVG dimensions fixed」;:1033,搜「select("g").attr("transform"」)。 ↩↩

  2. 出处:「Events and Interactivity」第 1094–1144 段(text/21-ch14-chapter-14-events-and-interactivity.txt:1109,搜「const context = svg.append」;:1111,搜「chart = context.append("svg")」;:1114,搜「created with <svg> instead of <g>」;:1135,搜「clipPath」)。 ↩

  3. 出处:「Events and Interactivity」第 1044–1065、1158–1176 段(text/21-ch14-chapter-14-events-and-interactivity.txt:1078,搜「scaleExtent([0.5, 2])」;:1162,搜「evt.type === 'wheel'」;:1172,搜「dblclick.zoom」)。 ↩

  4. 出处:「Events and Interactivity」第 1177–1235 段(text/21-ch14-chapter-14-events-and-interactivity.txt:1179,搜「x, y, and k」;:1194,搜「toString()」;:1216,搜「immutable」)。 ↩

  5. 出处:「Events and Interactivity」第 1220–1228 段(text/21-ch14-chapter-14-events-and-interactivity.txt:1220,搜「new d3.ZoomTransform(2, 25, 10)」;:1225,搜「trsf2」;:1227,搜「translate first, then scale」)。 ↩

  6. 出处:「Events and Interactivity」第 1332–1338 段(text/21-ch14-chapter-14-events-and-interactivity.txt:1332,搜「Important note」)。 ↩

  7. 出处:「Events and Interactivity」第 1252–1402 段(text/21-ch14-chapter-14-events-and-interactivity.txt:1288,搜「transform(selection,transform)」;:1307,搜「call(evt.target.transform, d3.zoomIdentity)」)。方法表照原书 Table 14.13 转写。 ↩

  8. 出处:「Events and Interactivity」第 1341–1399 段(text/21-ch14-chapter-14-events-and-interactivity.txt:1372,搜「ignored by transform()」;:1385,搜「translateTo() doesn't translate the selection, but the view」)。 ↩

  9. 出处:「Events and Interactivity」第 1404–1413 段(text/21-ch14-chapter-14-events-and-interactivity.txt:1405,搜「interpolateZoom」;:1410,搜「interpolate(d3.interpolate)」)。 ↩

  10. 出处:「Events and Interactivity」第 1424–1438 段(text/21-ch14-chapter-14-events-and-interactivity.txt:1429,搜「geometric zooming」;:1431,搜「semantic zooming」)。 ↩

  11. 出处:「Events and Interactivity」第 1441–1550 段(text/21-ch14-chapter-14-events-and-interactivity.txt:1464,搜「5/k」;:1483,搜「rescaleX(xScale)」;:1522,搜「radius(d, k)」)。 ↩↩

  12. 出处:「Events and Interactivity」第 1551–1560 段(text/21-ch14-chapter-14-events-and-interactivity.txt:1556,搜「requestAnimationFrame」)。 ↩↩

  13. 出处:「Events and Interactivity」第 1562–1586 段(text/21-ch14-chapter-14-events-and-interactivity.txt:1569,搜「apply([x,y])」;:1582,搜「not covered in this book」)。 ↩↩