编程语言研究书库

饼图与堆叠图 — 布局生成器与 Canvas 渲染

饼图与堆叠图 — 布局生成器与 Canvas 渲染

这一章讲什么: 上一章的形状生成器把点变成 path;本章的布局生成器(layout generators) 把数据表变成另一种数据形状——它不画任何东西,只负责重排。 d3.pie() 把一列数变成带起止角的分片对象,d3.stack() 把宽表变成一摞 [下界, 上界] 坐标对。 章末回答一个工程问题:图形量巨大时,怎么把同一份代码渲染到 Canvas。

1. 顶层全景:布局生成器的分工

  饼图链路:                       堆叠图链路:
  数值数组 [30736, 101720, 281983]   宽表 [{day:'Mon', a:213, b:100, …}, …]
      │ d3.pie()                     │ d3.stack().keys(['a','b','c','d'])
      ▼                              ▼
  分片对象数组:                     坐标对矩阵(每序列一行):
  [{data, value, startAngle,        [ [[0,213],[0,121],[0,637]],    ← a 层
    endAngle, index, padAngle}, …]      [[213,313],[121,271],…],   ← b 层
      │ d3.arc()                        … ]
      ▼                              │ d3.area()(y0=d[0], y1=d[1])
  path 字符串 → <path> 扇形            ▼
                                     path → 堆叠面积;或 rect → 堆叠柱状

  图说:生成器(generator)产函数,布局(layout)产数据——这是两类生成器的本质分别。

2. 核心原理

2.1 d3.arc():四个参数一个扇形

arc 是形状生成器:内外半径 × 起止角度(弧度,12 点钟起顺时针)四个参数定一个扇形1:

  const arc = d3.arc().innerRadius(0).outerRadius(100)
    .startAngle(Math.PI * 45/180).endAngle(Math.PI * 135/180);
  arc()   // 'M70.71,-70.71A100,100,0,0,1,70.71,70.71L0,0Z'

  // 参数也可以来自数据对象:
  const arc = d3.arc().innerRadius(0).outerRadius(100);   // 半径固定
  arc({startAngle: 0, endAngle: Math.PI/2})               // 角度来自绑定的数据

参数可以全静态、全来自数据、或混合——这决定了 join 的形态。原书的手工饼图: d3.pairs([0, 0.2, 0.55, 1]) 先把百分比序列配成段对 [[0,.2],[.2,.55],[.55,1]], 再让 startAngle/endAngle 两个访问器各取一对中的一项2。样式三旋钮3:

旋钮效果
innerRadius(30)内半径 >0 → 环形图(doughnut)
cornerRadius(10)扇形角圆整
padAngle(0.2).padRadius(14)片间缝隙(比 CSS 描边法干净,不依赖背景色)

arc.centroid(对象?) 返回扇形中线的中点 [x,y]——标签、引导点、径向线全以它为锚; 注意它是唯一「不返回 arc 自己」的方法,静态配置的 arc 无参调用,数据配置的要把对象传进去4。

2.2 d3.pie():角度计算的自动化

手工配角度太累了。d3.pie() 吃数值、吐出配好角度的分片对象数组—— 它默认按值降序排列(所以 index 是名次)5:

  const data = [30736, 101720, 281983];
  const pie = d3.pie();
  pie(data)
  // [
  //   {data:281983, value:281983, startAngle:0,    endAngle:4.28, index:0, padAngle:0},
  //   {data:101720, value:101720, startAngle:4.28, endAngle:5.82, index:1, padAngle:0},
  //   {data:30736,  value:30736,  startAngle:5.82, endAngle:6.28, index:2, padAngle:0}
  // ]
  // 4.28 ≈ 281983/414439 × 2π —— 每个分片的角度 = 占比 × 整圆

  图说:data 是原始数据(对象数组时是整个对象),value 是参与计算的数,
  startAngle/endAngle 直接喂给 d3.arc();全圆 6.28 = 2π 弧度。

对象数组要配 value(访问器) 和 sortValues(比较器);startAngle/endAngle 还能做 半饼:d3.pie().startAngle(-Math.PI/2).endAngle(Math.PI/2) 得 9 点到 3 点的半个圆6。 完整饼图 = pie 产数据 + join 绑分片 + arc 出 path:

  const slices = d3.pie().value(d => d.population).sortValues(d3.ascending)(data);
  const chart = svg.selectAll("g").data(slices).join("g")
    .attr("transform", `translate(${width/2},${height/2})`);
  chart.append("path").attr("d", arc).style("fill", color);
  chart.append("text").text(d => d.data.country);   // 标签从 .data 取原始字段

2.3 d3.stack():把宽表压成坐标对

堆叠 = 同一观察点上,各序列的值依次累加。d3.stack() 把这件事变成矩阵变换。 主线例子(原书贯穿全节的同一组数)7:

  data2 = [
    {day:"Monday", a:213, b:100, c:200, d:300},
    {day:"Friday", a:121, b:150, c:250, d:350},
    {day:"Sunday", a:637, b:10,  c:20,  d:30}
  ]

  const stack = d3.stack().keys(['a','b','c','d']);
  const stackedData = stack(data2);
  // [
  //   [ [0,213],   [0,121],   [0,637]   ],   ← 序列 a:每对 = [下界,上界]
  //   [ [213,313], [121,271], [637,647] ],    ← 序列 b:从 a 的顶继续往上垒
  //   [ [313,513], [271,521], [647,667] ],    ← 序列 c
  //   [ [513,813], [521,871], [667,697] ]     ← 序列 d
  // ]
  // 每个数对还带 .data 属性,指回原行(取 day 标签用)

  图说:行 = 序列(画图的层),列 = 观察点(周一/周五/周日);
  数对的第二个值是该序列的累计顶,第一个值是前一层的顶——[下界,上界] 直接喂 area 的 y0/y1。

数据准备是全章的暗主线。stack 最爱宽表;遇到别的形状就要变形8:

输入形状处理手法
宽表 CSV(列即序列)keys 从 data.columns 滤出表头
转置表(序列在行上)d3.text() + csvParseRows + d3.transpose() 转回去
值嵌在对象里.value((obj,key) => obj[key].v) 给访问器
嵌套 JSON先 forEach 拍平展宽(比复杂 value 访问器省事)
tidy 长表d3.index(数据, d=>d.day, d=>d.type) 造两级 Map + d3.union 取 keys + 绕一点的 value 访问器

最后一行值得记住:tidy 是 D3 工具链的通用最优形状,唯独 stack 偏爱宽表—— 第 07 章埋的「少数例外」在这里兑现。

画图:堆叠柱状用两层 join(层 <g> 绑序列、rect 绑数对), y 域用 d3.max(stackedData.flat(2)) 展平两次取最大;堆叠面积把第 11 章的 area 配上 y0(d => scale.y(d[0])) / y1(d => scale.y(d[1])),x 用 point 尺9。

2.4 顺序与基线:order 与 offset

order() 决定哪层在底(六种算法),offset() 决定基线摆在哪(六种)。 原书用同一组数据把全部算法画成对照图10:

order效果
stackOrderNone按 keys 顺序(默认)
stackOrderReverse反过来
stackOrderAscending/Descending小/大序列在底
stackOrderAppearance先到峰值的序列在底
stackOrderInsideOut峰值早的在内侧——流图专用
offset效果
stackOffsetNone基线在零(默认)
stackOffsetExpand每层归一,顶恒为 1(100% 堆叠;只支持正值)
stackOffsetDiverging负值向下垒、正值向上(有负有正时的正确基线)
stackOffsetSilhouette整体下移,图中心对准零线
stackOffsetWiggle最小化层间摆动——与 InsideOut 搭配就是 streamgraph
  const stackedData = d3.stack().keys(keys)
    .order(d3.stackOrderInsideOut)
    .offset(d3.stackOffsetWiggle)(data);
  // → 一条蜿蜒的流图

3. Canvas 渲染:同一份代码,两种输出

上万元素时 SVG 的 DOM 节点会成为性能瓶颈;Canvas 是像素位图,画完不保留对象, 快但单个图形不可选(交互要靠坐标反查)。D3 给两条路避免重写绘图逻辑11:

路一:形状生成器的 context(ctx)。 配了 Canvas 上下文后,生成器不再返回字符串, 而是直接往上下文发命令:

  const line = d3.line().x(...).y(...).context(ctx);

  data.forEach((d,i) => {
    ctx.beginPath();
    line(d);                       // 生成器 → 直接向 ctx 发 moveTo/lineTo
    ctx.strokeStyle = colors[i];
    ctx.stroke();
  });

路二:d3.path() 做适配层。 它的方法和 Canvas 上下文同名同签名, 但攒的是 SVG path 字符串——于是同一段 draw(context) 函数,传 ctx 出 Canvas、 传 d3.path() 出 SVG12:

  function draw(context) {
    context.moveTo(280,160);
    context.lineTo(170,130); … context.closePath();
  }
  draw(ctx);                        // Canvas 版
  const path = d3.path(); draw(path);   // SVG 版
  svg.append("path").attr("d", path.toString());

  // 顺带:自定义符号就是"实现 draw(context,size) 的对象"
  const path = d3.path();
  d3.symbolStar.draw(path, 5000);   // 星形直接画进 path

轴怎么办? d3-axis 只产 SVG。原书的方案:Canvas 图表底下垫一层透明 SVG 专画轴, CSS 绝对定位叠齐13。

4. 在线教程

两个:世界银行 GDP 饼图(Tutorial1/);跨大西洋奴隶贸易数据的堆叠面积图—— 1500–1870 年各国旗号的运输量,数据来自 slavevoyages.org(Tutorial2/)14。

5. 作者的判断与证据

判断性质依据
「stack 偏好宽表」数据形状判据tidy 要走 index+union 的弯路,原书明说 works best with wide8
「流图 = InsideOut + Wiggle」配方两个算法的文档字符串级建议10
「Canvas 适合静态大批量,SVG 适合交互」工程权衡位图不可选 vs DOM 开销的对照分析11

6. 边界与局限

  • 饼图的最佳实践(何时不该用饼图、角度 vs 弧长的知觉精度)全书没讨论——D3 教你画,不教你该不该画。
  • padAngle 的几何(缝隙何时平行)讲得含糊,原书自己说「can be hard to control」3。
  • Canvas 侧的交互只提了一句「更具挑战」,反查坐标的具体模式(如用 d3.quadtree)不在本章。
  • expand offset 不支持负值,此限制只在表格脚注里10。

判断(我们的,不是书里的): 布局生成器是「数据 → 数据」的纯函数, 这是 D3 架构里最干净的一层:可测试、可组合、与渲染完全解耦。 想验证 stack 对不对,console.log(stack(data)) 看数对即可,不需要打开浏览器。 如果错,会错在: 这种「重排数据再交给图形层」的间接性是有学习成本的—— 初学者常以为 d3.stack() 会直接画出图(像 Chart.js 那样),它却是「再加工一步」; 这个误会也是 D3「不给成品图、只给工具」哲学的代价。

7. 可带走的

主线例子一行收束: [{day:'Mon',a:213,b:100,…},…] → d3.stack().keys(['a','b','c','d']) 得 [下界,上界] 数对矩阵 → area 配 y0/y1 即成堆叠面积; pie([30736,101720,281983]) 得带角度的分片 → d3.arc() 出扇形。

  1. 布局生成器产数据(不产图):pie → 分片对象,stack → 数对矩阵;
  2. arc 四参数:innerRadius/outerRadius/startAngle/endAngle(弧度、12 点起);
  3. centroid() 是标签锚点;doughnut = innerRadius>0,cornerRadius 圆角,padAngle 留缝;
  4. pie 默认降序;value() 取对象字段;startAngle/endAngle 可切半饼;
  5. stack 的 keys 是列名;每个数对 = [下层顶, 本层顶],带 .data 回原行;
  6. 数据形状优先级:宽表最顺;tidy 用 d3.index + d3.union 绕行;转置表先 transpose;
  7. 堆叠柱状 = 层 <g> join 序列 + 内层 rect join 数对;y 域 flat(2) 取最大;
  8. 顺序:None/Reverse/Ascending/Descending/Appearance/InsideOut(流图);
  9. 基线:None/Expand(百分比)/Diverging(有负有正)/Silhouette/Wiggle(流图);
  10. Canvas:.context(ctx) 让生成器直发命令;d3.path() 让同一段代码双端输出;
  11. Canvas 图表的轴:底下垫一层透明 SVG。

8. 原文地图

主题原书章原文位置
布局生成器 vs 形状生成器Creating Pies and Stackstext/19-ch12-chapter-12-creating-pies-and-stacks.txt:23(搜「Layout generators」) · text/19-ch12-chapter-12-creating-pies-and-stacks.txt:52(搜「transformed data array」)
arc 配置表Creating Pies and Stackstext/19-ch12-chapter-12-creating-pies-and-stacks.txt:72(搜「innerRadius(value)」) · text/19-ch12-chapter-12-creating-pies-and-stacks.txt:95(搜「centroid(args)」)
手工弧与 pairs 饼图Creating Pies and Stackstext/19-ch12-chapter-12-creating-pies-and-stacks.txt:110(搜「innerRadius(0).outerRadius(100)」) · text/19-ch12-chapter-12-creating-pies-and-stacks.txt:162(搜「[0, 0.2, 0.55, 1]」)
doughnut/corner/padAngleCreating Pies and Stackstext/19-ch12-chapter-12-creating-pies-and-stacks.txt:182(搜「doughnut chart」) · text/19-ch12-chapter-12-creating-pies-and-stacks.txt:189(搜「cornerRadius(10)」) · text/19-ch12-chapter-12-creating-pies-and-stacks.txt:201(搜「padAngle(0.2)」)
centroidCreating Pies and Stackstext/19-ch12-chapter-12-creating-pies-and-stacks.txt:214(搜「Finding the centroid」) · text/19-ch12-chapter-12-creating-pies-and-stacks.txt:221(搜「115.48」)
pie 生成器与输出对象Creating Pies and Stackstext/19-ch12-chapter-12-creating-pies-and-stacks.txt:265(搜「startAngle(angle)」) · text/19-ch12-chapter-12-creating-pies-and-stacks.txt:287(搜「30736, 101720, 281983」) · text/19-ch12-chapter-12-creating-pies-and-stacks.txt:293(搜「startAngle:5.82」) · text/19-ch12-chapter-12-creating-pies-and-stacks.txt:349(搜「Math.PI/2」)
stack 矩阵变换Creating Pies and Stackstext/19-ch12-chapter-12-creating-pies-and-stacks.txt:434(搜「213, 100, 200, 300」) · text/19-ch12-chapter-12-creating-pies-and-stacks.txt:453(搜「0,213]」)
数据准备五种形态Creating Pies and Stackstext/19-ch12-chapter-12-creating-pies-and-stacks.txt:528(搜「data3.columns.filter」) · text/19-ch12-chapter-12-creating-pies-and-stacks.txt:557(搜「d3.transpose」) · text/19-ch12-chapter-12-creating-pies-and-stacks.txt:587(搜「obj[key].v」) · text/19-ch12-chapter-12-creating-pies-and-stacks.txt:613(搜「obj.series.forEach」) · text/19-ch12-chapter-12-creating-pies-and-stacks.txt:644(搜「d3.index(data7」)
堆叠柱状与面积Creating Pies and Stackstext/19-ch12-chapter-12-creating-pies-and-stacks.txt:672(搜「stackedData.map(d => d[0].data.day)」) · text/19-ch12-chapter-12-creating-pies-and-stacks.txt:677(搜「flat(2)」) · text/19-ch12-chapter-12-creating-pies-and-stacks.txt:688(搜「g.layer」) · text/19-ch12-chapter-12-creating-pies-and-stacks.txt:720(搜「y0(d => scale.y(d[0]))」)
order/offset 算法Creating Pies and Stackstext/19-ch12-chapter-12-creating-pies-and-stacks.txt:742(搜「stackOrderNone」) · text/19-ch12-chapter-12-creating-pies-and-stacks.txt:751(搜「stackOrderInsideOut」) · text/19-ch12-chapter-12-creating-pies-and-stacks.txt:776(搜「stackOffsetNone」) · text/19-ch12-chapter-12-creating-pies-and-stacks.txt:783(搜「stackOffsetWiggle」) · text/19-ch12-chapter-12-creating-pies-and-stacks.txt:798(搜「except d3.stackOffsetExpand」)
Canvas 与 d3.pathCreating Pies and Stackstext/19-ch12-chapter-12-creating-pies-and-stacks.txt:829(搜「getContext」) · text/19-ch12-chapter-12-creating-pies-and-stacks.txt:859(搜「select shapes individually」) · text/19-ch12-chapter-12-creating-pies-and-stacks.txt:874(搜「d3.path()」) · text/19-ch12-chapter-12-creating-pies-and-stacks.txt:897(搜「function draw(context)」) · text/19-ch12-chapter-12-creating-pies-and-stacks.txt:942(搜「context(ctx)」)
Canvas 线图与垫层轴Creating Pies and Stackstext/19-ch12-chapter-12-creating-pies-and-stacks.txt:962(搜「createView」) · text/19-ch12-chapter-12-creating-pies-and-stacks.txt:1017(搜「beginPath()」)
两个在线教程Creating Pies and Stackstext/19-ch12-chapter-12-creating-pies-and-stacks.txt:364(搜「comparing GDPs」) · text/19-ch12-chapter-12-creating-pies-and-stacks.txt:802(搜「slave trade」)
章小结Creating Pies and Stackstext/19-ch12-chapter-12-creating-pies-and-stacks.txt:1042(搜「Summary」)

Footnotes

  1. 出处:「Creating Pies and Stacks」第 63–131 段(text/19-ch12-chapter-12-creating-pies-and-stacks.txt:72,搜「innerRadius(value)」;:110,搜「innerRadius(0).outerRadius(100)」;:114,搜「angles are expressed in radians」)。 ↩

  2. 出处:「Creating Pies and Stacks」第 159–179 段(text/19-ch12-chapter-12-creating-pies-and-stacks.txt:162,搜「[0, 0.2, 0.55, 1]」;:166,搜「d3.pairs(points)」;:170,搜「startAngle(d => Math.PI」)。 ↩

  3. 出处:「Creating Pies and Stacks」第 181–211 段(text/19-ch12-chapter-12-creating-pies-and-stacks.txt:189,搜「cornerRadius(10)」;:200,搜「padAngle(0.2)」;:204,搜「can be hard to control」)。 ↩ ↩2

  4. 出处:「Creating Pies and Stacks」第 212–248 段(text/19-ch12-chapter-12-creating-pies-and-stacks.txt:221,搜「115.48」;:227,搜「centroid({innerRadius:0」)。 ↩

  5. 出处:「Creating Pies and Stacks」第 255–315 段(text/19-ch12-chapter-12-creating-pies-and-stacks.txt:287,搜「30736, 101720, 281983」;:293,搜「startAngle:5.82」)。「The default order is descending」见原书 Table 12.3。 ↩

  6. 出处:「Creating Pies and Stacks」第 316–361 段(text/19-ch12-chapter-12-creating-pies-and-stacks.txt:328,搜「value(d => d.population)」;:349,搜「Math.PI/2」;:355,搜「-Math.PI/2」)。 ↩

  7. 出处:「Creating Pies and Stacks」第 402–505 段(text/19-ch12-chapter-12-creating-pies-and-stacks.txt:434,搜「213, 100, 200, 300」;:453,搜「0,213]」;:473,搜「stackedData[0][0].data」;:492,搜「keys(['a','b','c','d'])」)。 ↩

  8. 出处:「Creating Pies and Stacks」第 509–660 段(text/19-ch12-chapter-12-creating-pies-and-stacks.txt:528,搜「data3.columns.filter」;:557,搜「d3.transpose」;:587,搜「obj[key].v」;:612,搜「obj.series.forEach」;:644,搜「d3.index(data7」;:481,搜「works best with data in wide format」)。 ↩ ↩2

  9. 出处:「Creating Pies and Stacks」第 664–733 段(text/19-ch12-chapter-12-creating-pies-and-stacks.txt:672,搜「stackedData.map(d => d[0].data.day)」;:677,搜「flat(2)」;:688,搜「g.layer」;:719,搜「y0(d => scale.y(d[0]))」)。 ↩

  10. 出处:「Creating Pies and Stacks」第 735–799 段(text/19-ch12-chapter-12-creating-pies-and-stacks.txt:742,搜「stackOrderNone」;:751,搜「stackOrderInsideOut」;:776,搜「stackOffsetNone」;:783,搜「stackOffsetWiggle」;:798,搜「except d3.stackOffsetExpand」)。算法表照原书 Table 12.5、12.6 转写。 ↩ ↩2 ↩3

  11. 出处:「Creating Pies and Stacks」第 822–865 段(text/19-ch12-chapter-12-creating-pies-and-stacks.txt:859,搜「select shapes individually」;:861,搜「significantly affect performance」)。 ↩ ↩2

  12. 出处:「Creating Pies and Stacks」第 866–936 段(text/19-ch12-chapter-12-creating-pies-and-stacks.txt:874,搜「d3.path()」;:897,搜「function draw(context)」;:932,搜「symbolStar.draw(path,5000)」)。 ↩

  13. 出处:「Creating Pies and Stacks」第 947–1033 段(text/19-ch12-chapter-12-creating-pies-and-stacks.txt:962,搜「createView」;:975,搜「svg-layer」;:1016,搜「beginPath()」)。 ↩

  14. 出处:「Creating Pies and Stacks」第 364–370 段(text/19-ch12-chapter-12-creating-pies-and-stacks.txt:364,搜「comparing GDPs」)与第 802–811 段(:802,搜「slave trade」)。 ↩

On this page