饼图与堆叠图 — 布局生成器与 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() 出扇形。
- 布局生成器产数据(不产图):pie → 分片对象,stack → 数对矩阵;
- arc 四参数:innerRadius/outerRadius/startAngle/endAngle(弧度、12 点起);
centroid()是标签锚点;doughnut = innerRadius>0,cornerRadius 圆角,padAngle 留缝;- pie 默认降序;
value()取对象字段;startAngle/endAngle可切半饼; - stack 的 keys 是列名;每个数对 = [下层顶, 本层顶],带 .data 回原行;
- 数据形状优先级:宽表最顺;tidy 用 d3.index + d3.union 绕行;转置表先 transpose;
- 堆叠柱状 = 层
<g>join 序列 + 内层 rect join 数对;y 域flat(2)取最大; - 顺序:None/Reverse/Ascending/Descending/Appearance/InsideOut(流图);
- 基线:None/Expand(百分比)/Diverging(有负有正)/Silhouette/Wiggle(流图);
- Canvas:
.context(ctx)让生成器直发命令;d3.path()让同一段代码双端输出; - Canvas 图表的轴:底下垫一层透明 SVG。
8. 原文地图
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| 布局生成器 vs 形状生成器 | Creating Pies and Stacks | text/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 Stacks | text/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 Stacks | text/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/padAngle | Creating Pies and Stacks | text/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)」) |
| centroid | Creating Pies and Stacks | text/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 Stacks | text/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 Stacks | text/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 Stacks | text/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 Stacks | text/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 Stacks | text/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.path | Creating Pies and Stacks | text/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 Stacks | text/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 Stacks | text/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 Stacks | text/19-ch12-chapter-12-creating-pies-and-stacks.txt:1042(搜「Summary」) |
Footnotes
-
出处:「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」)。 ↩ -
出处:「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」)。 ↩ -
出处:「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 -
出处:「Creating Pies and Stacks」第 212–248 段(
text/19-ch12-chapter-12-creating-pies-and-stacks.txt:221,搜「115.48」;:227,搜「centroid({innerRadius:0」)。 ↩ -
出处:「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。 ↩ -
出处:「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」)。 ↩ -
出处:「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'])」)。 ↩ -
出处:「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 -
出处:「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]))」)。 ↩ -
出处:「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 -
出处:「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 -
出处:「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)」)。 ↩ -
出处:「Creating Pies and Stacks」第 947–1033 段(
text/19-ch12-chapter-12-creating-pies-and-stacks.txt:962,搜「createView」;:975,搜「svg-layer」;:1016,搜「beginPath()」)。 ↩ -
出处:「Creating Pies and Stacks」第 364–370 段(
text/19-ch12-chapter-12-creating-pies-and-stacks.txt:364,搜「comparing GDPs」)与第 802–811 段(:802,搜「slave trade」)。 ↩