D3.js d3-chord 弦图实战:用 chord 布局与 ribbon 形状绘制节点间的双向流
本文围绕 d3 仓库中的 d3-chord 模块文档 展开,讲解如何用 d3.chord() 将 n×n 流量矩阵转化为环状弦图布局、用 d3.ribbon() 生成连接各节点的缎带形状,并结合仓库中可运行的官方示例 ExampleChord.vue 完整复现一张带刻度、带排序、带悬停提示的弦图,覆盖从数据建模、布局参数、形状生成器到 Canvas 渲染上下文的全部 API。
弦图与流量矩阵:把 n×n 矩阵映射到圆周
弦图(chord diagram)用于可视化一组节点之间的流动关系,例如有限状态之间的转移概率。d3 的弦图布局用大小为 n×n 的方阵表示流动,其中 n 是图中节点数:每个元素 matrix[i][j] 表示从第 i 个节点流向第 j 个节点的流量。矩阵元素必须是非负数,无流动时可为零。
d3 官方文档用一份假想的染发人群数据集(数据风格仿照 Circos 表格)说明这一模型:矩阵的每行、每列对应一种发色(black、blond、brown、red),每个值表示从一种颜色染到另一种颜色的人数,矩阵对角线表示保持原色的人数。例如 5,871 个黑发的人染成了金色,而 1,951 个金发的人染成了黑色。数据如下(见 d3-chord.md 原文):
const matrix = [
// to black, blond, brown, red
[11975, 5871, 8916, 2868], // from black
[ 1951, 10048, 2060, 6171], // from blond
[ 8010, 16145, 8090, 8045], // from brown
[ 1013, 990, 940, 6907] // from red
];
弦图的绘制方式可以概括为两步:把人群按起始颜色沿圆周排布,再在每种颜色之间绘制缎带(ribbon)。缎带的起点宽度和终点宽度分别正比于对应起始色和结束色的人数;缎带本身的颜色则约定为两端流量中较大值那一方的颜色。
核心布局 API:chord() 与 chord(matrix)
构造函数与布局计算
d3.chord() 以默认设置创建一个新的弦图布局:
const chord = d3.chord();
调用 *chord*(*matrix*) 会对指定的 n×n 方阵计算弦图布局,其中矩阵表示 n 个节点构成的有向网络中的流动。返回值是 chords 数组,每个 chord 表示节点 i 与 j 之间(i 可与 j 相等)合并后的双向流动,结构为:
{
source: { startAngle, endAngle, value, index }, // 源子组
target: { startAngle, endAngle, value, index } // 目标子组
}
每个 source / target 子组(subgroup)的属性含义为:
startAngle/endAngle— 起始角与结束角,单位为弧度;value— 流量值matrix[i][j];index— 节点下标 i。
这些 chords 通常直接传给 ribbon 形状生成器 来渲染网络关系。
返回数组有两个重要约定,直接影响后续遍历逻辑:
- 只包含
matrix[i][j]或matrix[j][i]非零的 chord; - 只包含唯一的 chord:chord ij 同时表示 i→j 和 j→i 的双向流动,不会出现重复的 ji;且 i、j 的选取使得 chord 的 source 总是
matrix[i][j]与matrix[j][i]中较大的那一方。
此外,chords 数组上挂载了次级数组 chords.groups,长度为 n,其中每个 group 代表节点 i 的总出流(即 matrix[i][0 … n-1] 的合计),属性为:
startAngle/endAngle— 弧度制的起始角与结束角;value— 节点 i 的总出流值;index— 节点下标 i。
groups 通常传给 d3.arc() 在弦图圆周外圈生成环状分段(donut 弧段)。
四个可配置参数
| 方法 | 作用 | 默认值 |
|---|---|---|
*chord*.padAngle(angle) |
相邻 group 之间的角度间隙(弧度) | 0 |
*chord*.sortGroups(compare) |
按总出流对 group 排序的比较函数 | null |
*chord*.sortSubgroups(compare) |
对每个 group i 内部的子组(matrix[i][0 … n-1])排序 |
null |
*chord*.sortChords(compare) |
按合并流量对 chord 排序,只影响 z 序(叠放顺序) | null |
四个比较器均接受比较函数或 null(表示不排序),官方文档建议搭配 d3.ascending 与 d3.descending 使用。注意 sortChords 不改变几何位置,只改变渲染时的前后层叠关系,这在调试缎带遮挡问题时很有用。
有向布局:chordDirected() 与 chordTranspose()
d3 v7 在 d3-chord 中新增了两个布局变体(见 CHANGES.md 中 d3 v7.0.0 对 d3-chord 的记录):
d3.chordDirected()— 有向流动的弦图布局。从 i 到 j 的 chord 仅由matrix[i][j]生成,不合并反向流量,适合表现有方向性的数据流,需配合 ribbonArrow() 使用;d3.chordTranspose()— 转置弦图布局。从源码结构看,其效果等价于对矩阵转置后布局,文档指出它“用于突出出向(而非入向)流动”。
缎带形状生成器:ribbon() 全家桶
ribbon 文档 定义了两种缎带:ribbon 表示双向流动,ribbonArrow 表示单向流动(后者适合 chordDirected)。
基本用法
const ribbon = d3.ribbon();
调用 *ribbon*(*...arguments*) 时,参数会被连同 this 对象一起传递给各个访问器函数。默认设置下期望传入一个 chord 对象:
ribbon({
source: {startAngle: 0.7524114, endAngle: 1.1212972, radius: 240},
target: {startAngle: 1.8617078, endAngle: 1.9842927, radius: 240}
})
// => "M164.0162810494058,-175.21032946354026A240,240,0,0,1,216.1595644740915,-104.28347273835429Q0,0,229.9158815306728,68.8381247563705A240,240,0,0,1,219.77316791012538,96.43523560788266Q0,0,164.0162810494058,-175.21032946354026Z"
若已设置 context,则路径以一系列 path method 调用渲染到该上下文并返回 void;否则返回 SVG path 数据字符串。
访问器一览
| 方法 | 默认访问器 | 说明 |
|---|---|---|
*ribbon*.source(source) |
d => d.source |
源访问器 |
*ribbon*.target(target) |
d => d.target |
目标访问器 |
*ribbon*.radius(radius) |
d => d.radius |
同时设置源与目标半径 |
*ribbon*.sourceRadius(radius) |
d => d.radius |
仅源半径 |
*ribbon*.targetRadius(radius) |
d => d.radius |
仅目标半径 |
*ribbon*.startAngle(angle) |
d => d.startAngle |
起始角(弧度) |
*ribbon*.endAngle(angle) |
d => d.endAngle |
结束角(弧度) |
*ribbon*.padAngle(angle) |
() => 0 |
相邻缎带之间的角度间隙 |
*ribbon*.context(context) |
null |
渲染上下文(Canvas 2D 等) |
角度约定与 d3 其他极坐标形状一致:以弧度表示,0 指向 -y(12 点钟方向),正角顺时针增加。
固定半径的用法示例——设置 .radius(240) 之后,source 和 target 数据中就不再需要 radius 属性:
const ribbon = d3.ribbon().radius(240);
ribbon({
source: {startAngle: 0.7524114, endAngle: 1.1212972},
target: {startAngle: 1.8617078, endAngle: 1.9842927}
});
关于 sourceRadius / targetRadius 的独立设置,文档给出了一条重要约定:在非对称(有向)弦图中,目标半径通常要比源半径内缩,从而在定向链接末端与其所属的 group 弧段之间留出间隙,视觉上是区分“流入/流出”的关键手段。
ribbonArrow() 与 headRadius
d3.ribbonArrow() 创建带箭头的单向缎带生成器,其余访问器与 ribbon 一致,另有一个特有方法:
*ribbonArrow*.headRadius(radius)
设置箭头头部半径访问器,默认访问器固定返回 10。
Canvas 渲染
*ribbon*.context(context) 是 d3 v4 引入的能力之一(CHANGES.md 明确记录:“A new ribbon.context method lets you render chord diagrams to Canvas”)。传入 Canvas 2D 上下文后,*ribbon* 不再返回字符串,而是直接以 path method 调用序列绘制到画布上;这与 d3-path 的通用约定一致,使同一份布局数据可以同时驱动 SVG 与 Canvas 两种输出。
完整实战示例:带刻度与排序的 SVG 弦图
仓库文档站中的 ExampleChord.vue 是 d3-chord.md 页面顶部弦图的完整实现,它演示了前述几乎全部 API 的组合方式。核心逻辑如下(已去除 Vue 模板语法,保留全部 d3 调用):
const width = 688;
const height = 688;
const outerRadius = Math.min(width, height) * 0.5 - 30; // 314
const innerRadius = outerRadius - 20; // 294
const matrix = [
// to black, blond, brown, red
[11975, 5871, 8916, 2868], // from black
[ 1951, 10048, 2060, 6171], // from blond
[ 8010, 16145, 8090, 8045], // from brown
[ 1013, 990, 940, 6907] // from red
];
const colors = ["#000000", "#ffdd89", "#957244", "#f26223"];
const names = ["black", "blond", "brown", "red"];
// 根据总流量生成刻度步长与带前缀的格式化器
const sum = d3.sum(matrix.flat());
const tickStepMinor = d3.tickStep(0, sum, 100);
const tickStepMajor = d3.tickStep(0, sum, 20);
function groupTicks(d, step) {
const k = (d.endAngle - d.startAngle) / d.value;
return d3.range(0, d.value, step).map(value => {
return {value: value, angle: value * k + d.startAngle};
});
}
const formatValue = d3.formatPrefix(",.0", tickStepMinor);
// 外圈 group 弧段:使用 chord.groups
const arc = d3.arc()
.innerRadius(innerRadius)
.outerRadius(outerRadius);
// 内圈缎带:chord 输出直接喂给 ribbon
const ribbon = d3.ribbon()
.radius(innerRadius);
// 布局:0.05 弧度组间隙 + 组内子组按降序
const chords = d3.chord()
.padAngle(0.05)
.sortSubgroups(d3.descending)
(matrix);
SVG 模板部分由两层组成:
<svg :width="width" :height="height" :viewBox="[-width/2, -height/2, width, height].join(' ')" fill="currentColor" style="font: 10px sans-serif;">
<!-- 外圈:每个 group 一段弧 + 数值刻度 -->
<g v-for="d in chords.groups">
<path :d="arc(d)" :fill="colors[d.index]" :stroke="colors[d.index]">
<title>{{ d.value.toLocaleString("en-US") }} {{ names[d.index] }}</title>
</path>
<g v-for="t in groupTicks(d, tickStepMinor)"
:transform="`rotate(${t.angle * 180 / Math.PI - 90}) translate(${outerRadius},0)`">
<line stroke="currentColor" x2="6" />
<text v-if="t.value % tickStepMajor === 0" x="8" dy="0.35em"
:transform="t.angle > Math.PI ? 'rotate(180) translate(-16)' : null"
:text-anchor="t.angle > Math.PI ? 'end' : null">{{ formatValue(t.value) }}</text>
</g>
</g>
<!-- 内圈:每条 chord 一条缎带,颜色取 target 一侧(即较大流量方) -->
<g fill-opacity="0.67" stroke="var(--vp-c-bg)">
<path v-for="d in chords" :d="ribbon(d)" :fill="colors[d.target.index]">
<title>
{{ d.source.value.toLocaleString("en-US") }} {{ names[d.source.index] }} → {{ names[d.target.index] }}…
</title>
</path>
</g>
</svg>
该示例有几个值得复用的细节:
- 半径分层:
outerRadius与innerRadius相差 20px,group 弧段占据外环(d3.arc的内外半径),缎带贴在内半径上(d3.ribbon().radius(innerRadius)),形成典型的“外环分组 + 内圈流”的弦图结构; - 刻度映射:
groupTicks利用k = (endAngle - startAngle) / value把 group 的数值线性映射回角度,d3.tickStep(0, sum, count)根据总量自动挑选合适的刻度步长,再配合d3.formatPrefix输出带 SI 前缀的标签; - 角度翻转处理:当刻度角度超过 π 时(圆下半侧),文字执行
rotate(180)并将text-anchor改为end,保证标签始终朝外可读; - 颜色约定落地:缎带填充色取
colors[d.target.index]。因为chord()已保证 source 一侧是双向流量中较大的一方,所以这里等价于“取较大流量方的颜色”这一文档约定; - 排序策略:只用了
.padAngle(0.05)与.sortSubgroups(d3.descending),即组按默认顺序、组内子组按出流降序——这是让大流量靠组起点聚集、读图更直观的常用组合。
仓库中的模块组织与版本说明
从仓库结构看,本项目的 d3 包是一个聚合入口:src/index.js 第 4 行 export * from "d3-chord"; 将 d3-chord 的全部 API 重新导出,因此安装聚合包后可直接使用 d3.chord()、d3.chordDirected()、d3.ribbon()、d3.ribbonArrow()。package.json 显示当前版本为 7.9.0,依赖 d3-chord: ^3.0.1,即本文所依据的 d3-chord v3 API(含 v7 新增的 chordDirected、chordTranspose、ribbonArrow、ribbon.padAngle、ribbon.sourceRadius、ribbon.targetRadius,见 CHANGES.md)。
文档站中完整的 API 索引见 docs/api.md 的 d3-chord 小节,其列出的条目与 docs/d3-chord/chord.md、docs/d3-chord/ribbon.md 一一对应;官方文档中各 API 标注的独立源码文件位于 d3-chord 子模块的 src/chord.js 与 src/ribbon.js,本仓库通过依赖引入而非内联源码。若需要本地预览文档站点(含本文示例的交互式弦图),可按 package.json 中 docs:dev 脚本运行 VitePress 开发服务器,相关构建脚本均基于 node >= 12(package.json 的 engines 字段)。
小结
d3-chord 的 API 面虽小,但职责划分清晰:chord() 负责把 n×n 非负流量矩阵折算为角度(groups 与 chords 两级结构,并内置双向合并、唯一性、source 取大值的约定),ribbon() 负责把角度区间转成路径(双向缎带或带箭头单向缎带,可输出 SVG 字符串或直写 Canvas context)。掌握 padAngle、sortGroups / sortSubgroups / sortChords 三个维度的排序控制,以及 sourceRadius / targetRadius 分离带来的内缩间隙,再配合 ExampleChord.vue 中“arc 外环 + ribbon 内圈 + 角度刻度”的组合方式,即可在 d3 v7 环境下快速构建出可用于状态转移、流量桑基类场景的弦图。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00