OpenMontage d3-viz 技能库精读:覆盖层级图、热力图、地图与交互动画的 D3.js 可视化模式全解
本篇技术指南以 OpenMontage 仓库中 .agents/skills/d3-viz/references/d3-patterns.md 为骨架,系统讲解该技能库沉淀的十余种可直接复用的 D3.js 可视化代码模式,涵盖层级可视化、进阶统计图表、地理可视化、交互与动画五大家族。读者读完可直接按"选择图表类型 → 拷贝对应函数 → 替换数据结构与尺寸"的方式,在 React、Vue、Svelte 或原生 JavaScript 环境中产出可运行、可继续打磨的自定义 SVG 数据可视化。
OpenMontage 将 d3-viz 封装为可被 Agent 按需加载的技能(Skill),配合仓库内 100+ 工具与创意类技能中的 data-visualization(见 skills/creative/data-visualization.md,其中明确将 diagram_gen 标注为 "Generate charts via Mermaid or D3")共同服务于"把数据变成画面"的制作链路。d3-patterns.md 正是这套技能体系中按图索骥的"模式库",任何需要超越标准图表库、追求像素级控制的场景都可以从中取用。
背景:d3-patterns.md 在整个 d3-viz 技能中的定位
在仓库中,本文件以双份镜像存在,分别服务不同 Agent 运行时的技能发现机制:
- 主副本:.agents/skills/d3-viz/references/d3-patterns.md
- 镜像副本:.claude/skills/d3-viz/references/d3-patterns.md
围绕它的是一套完整的技能资源树(以 .agents 侧为例):
.agents/skills/d3-viz/
├── SKILL.md # 技能入口:何时用 d3、核心工作流、交互与动画示范
├── references/
│ ├── d3-patterns.md # 本文主题:常见可视化类型的代码模式大全
│ ├── scale-reference.md # 标尺(scale)完整参考
│ └── colour-schemes.md # 色板与配色推荐
└── assets/
├── chart-template.jsx # 基础图表脚手架模板
├── interactive-template.jsx # 带 tooltip/zoom/交互的模板
└── sample-data.json # 可直接测试的示例数据集
.agents/skills/d3-viz/SKILL.md 在 "Resources" 一节对本文件给出的定位是:"Comprehensive collection of visualisation patterns and code examples"(可视化模式与代码示例的综合集),并建议"当需要某一类可视化或特定模式的详细指导时,阅读对应参考文件"。也就是说,本文梳理的每一个模式都应当是 Agent 或开发者生成代码时"照抄即用、按需裁剪"的最小完整单元。
通读前需要理解的统一代码惯例
通读全部模式可以发现,d3-patterns.md 遵循高度一致的书写约定,掌握这些约定有助于你把每个片段无缝移植到自己的工程里:
- 两种代码壳:分层与多数图表使用 React 风格(
useEffect+svgRef),而 Chord 图、Heatmap 使用独立函数(drawChordDiagram(data)/drawHeatmap(data),内部d3.select('#chart'))。前者适合组件化场景,后者适合"数据变了就重画一遍"的命令式场景。 - 统一清空策略:
const svg = d3.select(svgRef.current); svg.selectAll("*").remove();—— 重绘前先清空,规避重复绑定的脏数据。 - 布局流水线:凡层级数据一律先经
d3.hierarchy(data)建树,再交给d3.tree()/d3.treemap()/d3.partition()计算坐标;凡表格型数据则先定义scale,再做data().join()。 - 色彩约定:分类色大量使用
d3.scaleOrdinal(d3.schemeCategory10),连续色使用d3.scaleSequential(d3.interpolateYlOrRd / interpolateBlues)。更系统的取色建议见 .agents/skills/d3-viz/references/colour-schemes.md。 - d3 版本:遵循 .agents/skills/d3-viz/SKILL.md 的约定,使用 d3 v7 风格 API:
import * as d3 from 'd3',或用 CDN 的d3.v7.min.js。
一、层级可视化(Hierarchical visualisations)
这类图表处理"树"与"部分—整体"关系。四个模式的共同入口都是 d3.hierarchy(data),差异只在布局算法与坐标系映射。
1.1 树图(Tree diagram)
树图将层级数据展开为父子连线结构。示例将 800×600 的画布拆成横向布局:d3.tree().size([height - 100, width - 200]) 预留了 100px 纵向、200px 横向的边距;绘制连线时借助 d3.linkHorizontal() 并把 x/y 对调(x(d => d.y)、y(d => d.x)),从而把默认的纵向树翻转成"根在左、叶子在右"的横向树。
useEffect(() => {
if (!data) return;
const svg = d3.select(svgRef.current);
svg.selectAll("*").remove();
const width = 800;
const height = 600;
const tree = d3.tree().size([height - 100, width - 200]);
const root = d3.hierarchy(data);
tree(root);
const g = svg.append("g")
.attr("transform", "translate(100,50)");
// Links
g.selectAll("path")
.data(root.links())
.join("path")
.attr("d", d3.linkHorizontal()
.x(d => d.y)
.y(d => d.x))
.attr("fill", "none")
.attr("stroke", "#555")
.attr("stroke-width", 2);
// Nodes
const node = g.selectAll("g")
.data(root.descendants())
.join("g")
.attr("transform", d => `translate(${d.y},${d.x})`);
node.append("circle")
.attr("r", 6)
.attr("fill", d => d.children ? "#555" : "#999");
node.append("text")
.attr("dy", "0.31em")
.attr("x", d => d.children ? -8 : 8)
.attr("text-anchor", d => d.children ? "end" : "start")
.text(d => d.data.name)
.style("font-size", "12px");
}, [data]);
要点:用 root.links() 取父子边、root.descendants() 取全部节点;节点圆点用 d.children 区分"有子节点(深灰 #555)"与"叶子(浅灰 #999)";文本标签依据是否含子节点决定放在左侧还是右侧,并配合 text-anchor: end/start 防止文字压线。叶节点默认无值参与布局,若需要按数值分配空间,则改用下述 Treemap。
1.2 矩形树图(Treemap)
Treemap 用面积表达数值,是"在固定矩形内展示多级占比"的标准解法。关键在于 d3.hierarchy(data).sum(d => d.value).sort((a,b) => b.value - a.value):sum() 让每个节点的值自底向上累加,sort() 保证大值优先参与布局。
useEffect(() => {
if (!data) return;
const svg = d3.select(svgRef.current);
svg.selectAll("*").remove();
const width = 800;
const height = 600;
const root = d3.hierarchy(data)
.sum(d => d.value)
.sort((a, b) => b.value - a.value);
d3.treemap()
.size([width, height])
.padding(2)
.round(true)(root);
const colourScale = d3.scaleOrdinal(d3.schemeCategory10);
const cell = svg.selectAll("g")
.data(root.leaves())
.join("g")
.attr("transform", d => `translate(${d.x0},${d.y0})`);
cell.append("rect")
.attr("width", d => d.x1 - d.x0)
.attr("height", d => d.y1 - d.y0)
.attr("fill", d => colourScale(d.parent.data.name))
.attr("stroke", "white")
.attr("stroke-width", 2);
cell.append("text")
.attr("x", 4)
.attr("y", 16)
.text(d => d.data.name)
.style("font-size", "12px")
.style("fill", "white");
}, [data]);
要点:d3.treemap() 布局后,每个节点带上 x0/y0/x1/y1 四个边界坐标,矩形宽高直接由 d.x1 - d.x0、d.y1 - d.y0 得到;.padding(2) 控制单元格间距、.round(true) 将坐标取整为整数以消除亚像素锯齿。着色取父节点的名称(d.parent.data.name),让同一分支的叶子共享一个色系,视觉上形成"大区块内嵌套小区块"的分组感知。注意这里绘制的是 root.leaves(),仅叶子承担实际数值。
1.3 旭日图(Sunburst diagram)
旭日图是"环形饼图嵌套层级"的变体:每一环对应一层深度,扇形圆心角代表占比,径向位置(内径/外径)代表层级。d3.partition().size([2 * Math.PI, radius]) 第一个维度给出满圆 2π 的角范围,第二个维度是径向范围。
useEffect(() => {
if (!data) return;
const svg = d3.select(svgRef.current);
svg.selectAll("*").remove();
const width = 600;
const height = 600;
const radius = Math.min(width, height) / 2;
const root = d3.hierarchy(data)
.sum(d => d.value)
.sort((a, b) => b.value - a.value);
const partition = d3.partition()
.size([2 * Math.PI, radius]);
partition(root);
const arc = d3.arc()
.startAngle(d => d.x0)
.endAngle(d => d.x1)
.innerRadius(d => d.y0)
.outerRadius(d => d.y1);
const colourScale = d3.scaleOrdinal(d3.schemeCategory10);
const g = svg.append("g")
.attr("transform", `translate(${width / 2},${height / 2})`);
g.selectAll("path")
.data(root.descendants())
.join("path")
.attr("d", arc)
.attr("fill", d => colourScale(d.depth))
.attr("stroke", "white")
.attr("stroke-width", 1);
}, [data]);
要点:与 Treemap 用 scaleBand 定位不同,旭日图直接用角度量制图——d3.arc() 将 partition 输出的 x0/x1/y0/y1(角起止与径起止)翻译为 SVG path。整图先平移 (width/2, height/2) 到圆心。这里的着色使用 d.depth(树的深度)而非分类名,使同一深度的层级共享颜色,突出"环"的结构感。由于绘制的是 root.descendants()(含根与中间节点),根节点会形成完整的中心圆盘,实际业务中可按需过滤 d.depth > 0。
1.4 和弦图(Chord diagram)
和弦图适合展示多实体之间的流动关系(流量、迁移、依赖)。它的数据输入不是层级树,而是"边列表":[{ source, target, value }],示例代码随后手动把它累加成邻接矩阵。
function drawChordDiagram(data) {
// data format: array of objects with source, target, and value
// Example: [{ source: 'A', target: 'B', value: 10 }, ...]
if (!data || data.length === 0) return;
const svg = d3.select('#chart');
svg.selectAll("*").remove();
const width = 600;
const height = 600;
const innerRadius = Math.min(width, height) * 0.3;
const outerRadius = innerRadius + 30;
// Create matrix from data
const nodes = Array.from(new Set(data.flatMap(d => [d.source, d.target])));
const matrix = Array.from({ length: nodes.length }, () => Array(nodes.length).fill(0));
data.forEach(d => {
const i = nodes.indexOf(d.source);
const j = nodes.indexOf(d.target);
matrix[i][j] += d.value;
matrix[j][i] += d.value;
});
// Create chord layout
const chord = d3.chord()
.padAngle(0.05)
.sortSubgroups(d3.descending);
const arc = d3.arc()
.innerRadius(innerRadius)
.outerRadius(outerRadius);
const ribbon = d3.ribbon()
.source(d => d.source)
.target(d => d.target);
const colourScale = d3.scaleOrdinal(d3.schemeCategory10)
.domain(nodes);
const g = svg.append("g")
.attr("transform", `translate(${width / 2},${height / 2})`);
const chords = chord(matrix);
// Draw ribbons
g.append("g")
.attr("fill-opacity", 0.67)
.selectAll("path")
.data(chords)
.join("path")
.attr("d", ribbon)
.attr("fill", d => colourScale(nodes[d.source.index]))
.attr("stroke", d => d3.rgb(colourScale(nodes[d.source.index])).darker());
// Draw groups (arcs)
const group = g.append("g")
.selectAll("g")
.data(chords.groups)
.join("g");
group.append("path")
.attr("d", arc)
.attr("fill", d => colourScale(nodes[d.index]))
.attr("stroke", d => d3.rgb(colourScale(nodes[d.index])).darker());
// Add labels
group.append("text")
.each(d => { d.angle = (d.startAngle + d.endAngle) / 2; })
.attr("dy", "0.31em")
.attr("transform", d => `rotate(${(d.angle * 180 / Math.PI) - 90})translate(${outerRadius + 30})${d.angle > Math.PI ? "rotate(180)" : ""}`)
.attr("text-anchor", d => d.angle > Math.PI ? "end" : null)
.text((d, i) => nodes[i])
.style("font-size", "12px");
}
// Data format example:
// const data = [
// { source: 'Category A', target: 'Category B', value: 100 },
// { source: 'Category A', target: 'Category C', value: 50 },
// { source: 'Category B', target: 'Category C', value: 75 }
// ];
// drawChordDiagram(data);
要点拆解(这一节是全文信息密度最高的模式):
- 建矩阵:先
new Set(data.flatMap(d => [d.source, d.target]))去重得到节点列表,再初始化n×n零矩阵;对每条边同时累加matrix[i][j]与matrix[j][i],使矩阵对称、双向流量都能被画出来。若需表达方向性流量,可去掉第二行累加。 - 布局与几何:
d3.chord().padAngle(0.05).sortSubgroups(d3.descending)计算弦布局;.padAngle在扇区之间留 0.05 弧度空隙。d3.arc()负责外围分组弧(innerRadius/outerRadius 各为画面短边 0.3 倍、再加 30px),d3.ribbon()负责内部流量缎带。 - 双层结构:缎带以
fill-opacity: 0.67半透明叠加,stroke 用d3.rgb(...).darker()压深同色相边框,制造层次;分组弧stroke同理。 - 标签防翻转:文本先旋转到弧中点角度,弧跨越下半圆(
angle > Math.PI)时再整体rotate(180)并切到text-anchor: end,避免标签倒置。
二、进阶图表类型(Advanced chart types)
2.1 热力图(Heatmap)
热力图把"行 × 列 × 值"的三元组数据用色阶映射到矩形网格,适合发现矩阵型数据的模式与异常。行/列各自由 d3.scaleBand() 承担(均匀条带 + 0.01 极窄 padding),颜色则由 d3.scaleSequential(d3.interpolateYlOrRd) 承担——从浅黄到深红的连续渐变为数值大小编码。
function drawHeatmap(data) {
// data format: array of objects with row, column, and value
// Example: [{ row: 'A', column: 'X', value: 10 }, ...]
if (!data || data.length === 0) return;
const svg = d3.select('#chart');
svg.selectAll("*").remove();
const width = 800;
const height = 600;
const margin = { top: 100, right: 30, bottom: 30, left: 100 };
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
// Get unique rows and columns
const rows = Array.from(new Set(data.map(d => d.row)));
const columns = Array.from(new Set(data.map(d => d.column)));
const g = svg.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
// Create scales
const xScale = d3.scaleBand()
.domain(columns)
.range([0, innerWidth])
.padding(0.01);
const yScale = d3.scaleBand()
.domain(rows)
.range([0, innerHeight])
.padding(0.01);
// Colour scale for values (sequential from light to dark red)
const colourScale = d3.scaleSequential(d3.interpolateYlOrRd)
.domain([0, d3.max(data, d => d.value)]);
// Draw rectangles
g.selectAll("rect")
.data(data)
.join("rect")
.attr("x", d => xScale(d.column))
.attr("y", d => yScale(d.row))
.attr("width", xScale.bandwidth())
.attr("height", yScale.bandwidth())
.attr("fill", d => colourScale(d.value));
// Add x-axis labels
svg.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`)
.selectAll("text")
.data(columns)
.join("text")
.attr("x", d => xScale(d) + xScale.bandwidth() / 2)
.attr("y", -10)
.attr("text-anchor", "middle")
.text(d => d)
.style("font-size", "12px");
// Add y-axis labels
svg.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`)
.selectAll("text")
.data(rows)
.join("text")
.attr("x", -10)
.attr("y", d => yScale(d) + yScale.bandwidth() / 2)
.attr("dy", "0.35em")
.attr("text-anchor", "end")
.text(d => d)
.style("font-size", "12px");
// Add colour legend
const legendWidth = 20;
const legendHeight = 200;
const legend = svg.append("g")
.attr("transform", `translate(${width - 60},${margin.top})`);
const legendScale = d3.scaleLinear()
.domain(colourScale.domain())
.range([legendHeight, 0]);
const legendAxis = d3.axisRight(legendScale).ticks(5);
// Draw colour gradient in legend
for (let i = 0; i < legendHeight; i++) {
legend.append("rect")
.attr("y", i)
.attr("width", legendWidth)
.attr("height", 1)
.attr("fill", colourScale(legendScale.invert(i)));
}
legend.append("g")
.attr("transform", `translate(${legendWidth},0)`)
.call(legendAxis);
}
// Data format example:
// const data = [
// { row: 'Monday', column: 'Morning', value: 42 },
// { row: 'Monday', column: 'Afternoon', value: 78 },
// { row: 'Tuesday', column: 'Morning', value: 65 },
// { row: 'Tuesday', column: 'Afternoon', value: 55 }
// ];
// drawHeatmap(data);
要点:
- 边距设计:
margin.top: 100 / margin.left: 100为行、列文字标签预留空间,正文区由innerWidth / innerHeight收窄,这是全文唯一把行/列标签手工画在网格外的模式(不依赖 d3.axis,因为 band 网格的刻度要落在每个单元格中心)。 - legend 手工渐变:这是最值得复用的技巧——用
scaleLinear建立"数值 → 像素"的映射,把 200px 高的 legend 纵向切成 1px 见方的矩形,每个矩形填colourScale(legendScale.invert(i)),等效于把连续色阶"逐像素还原"出来;右侧再叠加d3.axisRight().ticks(5)作数值刻度。 - 想换成其他连续色阶(如
interpolateBlues、interpolateViridis)只需替换一行;完整候选见 .agents/skills/d3-viz/references/colour-schemes.md。
2.2 带渐变面积图(Area chart with gradient)
面积图 = 折线下方的填充区域。此模式在同一张图里叠加"渐变填充面积 + 折线描边",是时间序列可视化的高质感实现。
useEffect(() => {
if (!data || data.length === 0) return;
const svg = d3.select(svgRef.current);
svg.selectAll("*").remove();
const width = 800;
const height = 400;
const margin = { top: 20, right: 30, bottom: 40, left: 50 };
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
// Define gradient
const defs = svg.append("defs");
const gradient = defs.append("linearGradient")
.attr("id", "areaGradient")
.attr("x1", "0%")
.attr("x2", "0%")
.attr("y1", "0%")
.attr("y2", "100%");
gradient.append("stop")
.attr("offset", "0%")
.attr("stop-color", "steelblue")
.attr("stop-opacity", 0.8);
gradient.append("stop")
.attr("offset", "100%")
.attr("stop-color", "steelblue")
.attr("stop-opacity", 0.1);
const g = svg.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
const xScale = d3.scaleTime()
.domain(d3.extent(data, d => d.date))
.range([0, innerWidth]);
const yScale = d3.scaleLinear()
.domain([0, d3.max(data, d => d.value)])
.range([innerHeight, 0]);
const area = d3.area()
.x(d => xScale(d.date))
.y0(innerHeight)
.y1(d => yScale(d.value))
.curve(d3.curveMonotoneX);
g.append("path")
.datum(data)
.attr("fill", "url(#areaGradient)")
.attr("d", area);
const line = d3.line()
.x(d => xScale(d.date))
.y(d => yScale(d.value))
.curve(d3.curveMonotoneX);
g.append("path")
.datum(data)
.attr("fill", "none")
.attr("stroke", "steelblue")
.attr("stroke-width", 2)
.attr("d", line);
g.append("g")
.attr("transform", `translate(0,${innerHeight})`)
.call(d3.axisBottom(xScale));
g.append("g")
.call(d3.axisLeft(yScale));
}, [data]);
要点:
- X 轴是时间:
d3.scaleTime().domain(d3.extent(data, d => d.date)),因此数据里的date字段必须是Date对象(可用d3.timeParse预转换,参见 .agents/skills/d3-viz/SKILL.md 数据预处理一节)。 - 渐变:在
<defs>里声明linearGradient,两个stop把同一色(steelblue)从 80% 透明度渐变到 10%,填充区用url(#areaGradient)引用。SVG 中id全局唯一,同一页面多个面积图需改为唯一 id。 - 双路径叠加:
d3.area()以y0 = innerHeight为基线填充到底部形成面积;d3.line()再以 2px 实线描出上缘轮廓;二者共用.curve(d3.curveMonotoneX)保持曲线形态一致,避免面积与折线错位。
2.3 堆叠柱状图(Stacked bar chart)
堆叠柱把多个序列"纵向累加"进同一根柱子,强调总量的同时保留构成比例。数据形态是"每行一组 + 每个序列一列",代码通过 Object.keys(data[0]).filter(k => k !== 'group') 动态探测序列名,无需硬编码。
useEffect(() => {
if (!data || data.length === 0) return;
const svg = d3.select(svgRef.current);
svg.selectAll("*").remove();
const width = 800;
const height = 400;
const margin = { top: 20, right: 30, bottom: 40, left: 50 };
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
const g = svg.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
const categories = Object.keys(data[0]).filter(k => k !== 'group');
const stackedData = d3.stack().keys(categories)(data);
const xScale = d3.scaleBand()
.domain(data.map(d => d.group))
.range([0, innerWidth])
.padding(0.1);
const yScale = d3.scaleLinear()
.domain([0, d3.max(stackedData[stackedData.length - 1], d => d[1])])
.range([innerHeight, 0]);
const colourScale = d3.scaleOrdinal(d3.schemeCategory10);
g.selectAll("g")
.data(stackedData)
.join("g")
.attr("fill", (d, i) => colourScale(i))
.selectAll("rect")
.data(d => d)
.join("rect")
.attr("x", d => xScale(d.data.group))
.attr("y", d => yScale(d[1]))
.attr("height", d => yScale(d[0]) - yScale(d[1]))
.attr("width", xScale.bandwidth());
g.append("g")
.attr("transform", `translate(0,${innerHeight})`)
.call(d3.axisBottom(xScale));
g.append("g")
.call(d3.axisLeft(yScale));
}, [data]);
要点:d3.stack().keys(categories)(data) 是核心——它对每个序列输出 [[y0, y1], ...] 的累加区间,序列间互锁。y 轴上界取最后一个序列的 d[1](即各组总量最大值)。渲染采用"外层 join 序列、内层 join 每段矩形"的两层嵌套结构:外层 g 决定填充色(colourScale(i) 按序列索引取色),内层矩形位置由 xScale(d.data.group) 定位、高度由 yScale(d[0]) - yScale(d[1]) 决定。外层用索引 i 而不是 key 传色,是 join 二阶段数据绑定的经典示例。
2.4 分组柱状图(Grouped bar chart)
分组柱与堆叠柱输入相同({group, cat1, cat2, ...}),但序列不累加,而是并排排列。实现上使用双 scale:x0Scale 把每组定位到画布横坐标,x1Scale 在每组的带宽内为各序列二次分配子条位置。
useEffect(() => {
if (!data || data.length === 0) return;
const svg = d3.select(svgRef.current);
svg.selectAll("*").remove();
const width = 800;
const height = 400;
const margin = { top: 20, right: 30, bottom: 40, left: 50 };
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
const g = svg.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
const categories = Object.keys(data[0]).filter(k => k !== 'group');
const x0Scale = d3.scaleBand()
.domain(data.map(d => d.group))
.range([0, innerWidth])
.padding(0.1);
const x1Scale = d3.scaleBand()
.domain(categories)
.range([0, x0Scale.bandwidth()])
.padding(0.05);
const yScale = d3.scaleLinear()
.domain([0, d3.max(data, d => Math.max(...categories.map(c => d[c])))])
.range([innerHeight, 0]);
const colourScale = d3.scaleOrdinal(d3.schemeCategory10);
const group = g.selectAll("g")
.data(data)
.join("g")
.attr("transform", d => `translate(${x0Scale(d.group)},0)`);
group.selectAll("rect")
.data(d => categories.map(key => ({ key, value: d[key] })))
.join("rect")
.attr("x", d => x1Scale(d.key))
.attr("y", d => yScale(d.value))
.attr("width", x1Scale.bandwidth())
.attr("height", d => innerHeight - yScale(d.value))
.attr("fill", d => colourScale(d.key));
g.append("g")
.attr("transform", `translate(0,${innerHeight})`)
.call(d3.axisBottom(x0Scale));
g.append("g")
.call(d3.axisLeft(yScale));
}, [data]);
要点:
- 内层数据规整:内层
data(d => categories.map(key => ({ key, value: d[key] })))把每行"宽数据"展平成{key, value}列表,再以.key参与定位与配色——这是"宽表转长表"以适配 D3 join 的最小实现。 - y 上界:
d3.max(data, d => Math.max(...categories.map(c => d[c])))取所有行所有序列的最大单值(分组图不需要叠加总量)。 - 层级 transform:每组平移
x0Scale(d.group),组内子条再用x1Scale定位,避免手动累加偏移量。
2.5 气泡图(Bubble chart)
气泡图是散点图的扩展:x/y 编码两个数值维度,圆的半径 r 编码第三个维度(此处 d.size),颜色编码分类 d.category,实现"三维数据 + 分类"一图展示。
useEffect(() => {
if (!data || data.length === 0) return;
const svg = d3.select(svgRef.current);
svg.selectAll("*").remove();
const width = 800;
const height = 600;
const margin = { top: 20, right: 30, bottom: 40, left: 50 };
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
const g = svg.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
const xScale = d3.scaleLinear()
.domain([0, d3.max(data, d => d.x)])
.range([0, innerWidth]);
const yScale = d3.scaleLinear()
.domain([0, d3.max(data, d => d.y)])
.range([innerHeight, 0]);
const sizeScale = d3.scaleSqrt()
.domain([0, d3.max(data, d => d.size)])
.range([0, 50]);
const colourScale = d3.scaleOrdinal(d3.schemeCategory10);
g.selectAll("circle")
.data(data)
.join("circle")
.attr("cx", d => xScale(d.x))
.attr("cy", d => yScale(d.y))
.attr("r", d => sizeScale(d.size))
.attr("fill", d => colourScale(d.category))
.attr("opacity", 0.6)
.attr("stroke", "white")
.attr("stroke-width", 2);
g.append("g")
.attr("transform", `translate(0,${innerHeight})`)
.call(d3.axisBottom(xScale));
g.append("g")
.call(d3.axisLeft(yScale));
}, [data]);
要点:半径映射必须用 d3.scaleSqrt()(平方根标尺)而不是线性标尺——因为圆面积 ∝ r²,若 r 随数值线性增长,视觉面积会夸大差异。.opacity(0.6) + 白色描边缓解了圆与圆的重叠遮挡。关于"面积类编码为什么要开方"的更多尺规说明,可对照 .agents/skills/d3-viz/references/scale-reference.md 中 scalePow/scaleSqrt 小节。
三、地理可视化(Geographic visualisations)
地理可视化的共同基座是 projection(投影) 与 path generator:先选一种球面投影(此处为 Mercator),用 .fitSize([width, height], geoData) 把目标地理区域自动适配进画布;再用 d3.geoPath().projection(projection) 把 GeoJSON 转成 SVG path。
3.1 基础地图 + 散点标记(Basic map with points)
useEffect(() => {
if (!geoData || !pointData) return;
const svg = d3.select(svgRef.current);
svg.selectAll("*").remove();
const width = 800;
const height = 600;
const projection = d3.geoMercator()
.fitSize([width, height], geoData);
const pathGenerator = d3.geoPath().projection(projection);
// Draw map
svg.selectAll("path")
.data(geoData.features)
.join("path")
.attr("d", pathGenerator)
.attr("fill", "#e0e0e0")
.attr("stroke", "#999")
.attr("stroke-width", 0.5);
// Draw points
svg.selectAll("circle")
.data(pointData)
.join("circle")
.attr("cx", d => projection([d.longitude, d.latitude])[0])
.attr("cy", d => projection([d.longitude, d.latitude])[1])
.attr("r", 5)
.attr("fill", "steelblue")
.attr("opacity", 0.7);
}, [geoData, pointData]);
要点:
projection([lon, lat])返回[x, y]屏幕坐标,因此经纬度打点只需取返回值第 0/1 位赋给cx/cy。示例中底图浅灰(#e0e0e0)、描边#999、点位 steelblue 半透明,层次清爽。useEffect依赖数组写[geoData, pointData]——地图底图与点数据任一变化都会整体重绘。- 将底图与点位解耦:
geoData是 GeoJSON(含.features),pointData是普通[{longitude, latitude}]数组,可各自更新。
3.2 分级统计图(Choropleth map)
Choropleth 把行政区划按某指标着色,是"区域 + 数值"的标配表达。它与散点地图的唯一差别是:先用 Map 建立 id → value 查找表,再对每个区域的 path 按数值填色。
useEffect(() => {
if (!geoData || !valueData) return;
const svg = d3.select(svgRef.current);
svg.selectAll("*").remove();
const width = 800;
const height = 600;
const projection = d3.geoMercator()
.fitSize([width, height], geoData);
const pathGenerator = d3.geoPath().projection(projection);
// Create value lookup
const valueLookup = new Map(valueData.map(d => [d.id, d.value]));
// Colour scale
const colourScale = d3.scaleSequential(d3.interpolateBlues)
.domain([0, d3.max(valueData, d => d.value)]);
svg.selectAll("path")
.data(geoData.features)
.join("path")
.attr("d", pathGenerator)
.attr("fill", d => {
const value = valueLookup.get(d.id);
return value ? colourScale(value) : "#e0e0e0";
})
.attr("stroke", "#999")
.attr("stroke-width", 0.5);
}, [geoData, valueData]);
要点:
- 关联键:
valueData形如[{id, value}],其id必须与geoData.features[].id(区域标识)一致,否则无法命中。 - 缺数兜底:
valueLookup.get(d.id)取不到值时返回#e0e0e0浅灰,视觉上明确区分"无数据"与"数值为零"。 - 色阶用了
d3.scaleSequential(d3.interpolateBlues),属于单色渐变;若要突出异常值,可换interpolateYlOrRd或interpolateViridis。分级着色的另一种常见做法是把连续值用scaleQuantize分成 5~7 档,见 .agents/skills/d3-viz/references/scale-reference.md。
四、高级交互(Advanced interactions)
4.1 框选 + 高亮(Brush and zoom)
d3.brush() 提供"鼠标拖拽画矩形选区"的能力。此模式把选区坐标实时映射回数据空间,命中者变色为 orange、未命中保持 steelblue。
useEffect(() => {
if (!data || data.length === 0) return;
const svg = d3.select(svgRef.current);
svg.selectAll("*").remove();
const width = 800;
const height = 400;
const margin = { top: 20, right: 30, bottom: 40, left: 50 };
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
const xScale = d3.scaleLinear()
.domain([0, d3.max(data, d => d.x)])
.range([0, innerWidth]);
const yScale = d3.scaleLinear()
.domain([0, d3.max(data, d => d.y)])
.range([innerHeight, 0]);
const g = svg.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
const circles = g.selectAll("circle")
.data(data)
.join("circle")
.attr("cx", d => xScale(d.x))
.attr("cy", d => yScale(d.y))
.attr("r", 5)
.attr("fill", "steelblue");
// Add brush
const brush = d3.brush()
.extent([[0, 0], [innerWidth, innerHeight]])
.on("start brush", (event) => {
if (!event.selection) return;
const [[x0, y0], [x1, y1]] = event.selection;
circles.attr("fill", d => {
const cx = xScale(d.x);
const cy = yScale(d.y);
return (cx >= x0 && cx <= x1 && cy >= y0 && cy <= y1)
? "orange"
: "steelblue";
});
});
g.append("g")
.attr("class", "brush")
.call(brush);
}, [data]);
要点:
- 选择与重算:
event.selection给出选区两个对角点的像素坐标[[x0,y0],[x1,y1]]。由于点的 x/y 已由 scale 换算过,直接把每个点的xScale(d.x)/yScale(d.y)与选区做区间比较即可,无需反算数据值。 - 事件绑定:
.on("start brush", ...)让拖拽过程中的每一帧都实时重算高亮(而不只在下一次brush end)。if (!event.selection) return处理鼠标点击后未形成矩形的情况。 - 若想让用户擦除选区后恢复原色,可在
end分支里判断event.selection为空时全部复原(见 4.2 的做法)。
4.2 跨图表联动框选(Linked brushing between charts)
联动框选把"选中"提升为跨图表共享的 React 状态:在第一个图(散点)里框选,第二个图(柱状)同步高亮同一批数据点。核心是 selectedPoints(Set 数据结构)在两图之间充当唯一事实源。
function LinkedCharts({ data }) {
const [selectedPoints, setSelectedPoints] = useState(new Set());
const svg1Ref = useRef();
const svg2Ref = useRef();
useEffect(() => {
// Chart 1: Scatter plot
const svg1 = d3.select(svg1Ref.current);
svg1.selectAll("*").remove();
// ... create first chart ...
const circles1 = svg1.selectAll("circle")
.data(data)
.join("circle")
.attr("fill", d => selectedPoints.has(d.id) ? "orange" : "steelblue");
// Chart 2: Bar chart
const svg2 = d3.select(svg2Ref.current);
svg2.selectAll("*").remove();
// ... create second chart ...
const bars = svg2.selectAll("rect")
.data(data)
.join("rect")
.attr("fill", d => selectedPoints.has(d.id) ? "orange" : "steelblue");
// Add brush to first chart
const brush = d3.brush()
.on("start brush end", (event) => {
if (!event.selection) {
setSelectedPoints(new Set());
return;
}
const [[x0, y0], [x1, y1]] = event.selection;
const selected = new Set();
data.forEach(d => {
const x = xScale(d.x);
const y = yScale(d.y);
if (x >= x0 && x <= x1 && y >= y0 && y <= y1) {
selected.add(d.id);
}
});
setSelectedPoints(selected);
});
svg1.append("g").call(brush);
}, [data, selectedPoints]);
return (
<div>
<svg ref={svg1Ref} width="400" height="300" />
<svg ref={svg2Ref} width="400" height="300" />
</div>
);
}
要点:
- 状态驱动重绘:
useEffect依赖数组同时包含[data, selectedPoints],每次框选触发setSelectedPoints,两个图表的 effect 重新执行,依据selectedPoints.has(d.id)决定填色。这里必须给每个数据点配备唯一d.id作身份键。 - 回环风险与规避:刷子事件 → setState → 重绘 → 重建 brush。为打破无限循环,示例在刷子绘制后不再重新
call(brush)于已存在的 DOM(第一次 run 即完成绑定),并将 brush 图层语义上独立出来。扩展阅读:完整的"zoom + brush 双行为共存"细节可对照 .agents/skills/d3-viz/SKILL.md 交互章节的d3.zoom().scaleExtent([0.5, 10])用法。 event.selection为 null(用户点击空白处清空选区)时setSelectedPoints(new Set())实现一键复位。
五、动画模式(Animation patterns)
5.1 Enter / Update / Exit 三态过渡
这是 D3 数据绑定动画的"正确写法":同一个 selection 先声明 key function(d => d.id)保证对象恒常性(同一数据元素在更新前后仍对应同一 DOM 节点),再显式处理三批元素——离开的淡出缩小、存留的平滑位移、新进的从小变大。
useEffect(() => {
if (!data || data.length === 0) return;
const svg = d3.select(svgRef.current);
const circles = svg.selectAll("circle")
.data(data, d => d.id); // Key function for object constancy
// EXIT: Remove old elements
circles.exit()
.transition()
.duration(500)
.attr("r", 0)
.remove();
// UPDATE: Modify existing elements
circles
.transition()
.duration(500)
.attr("cx", d => xScale(d.x))
.attr("cy", d => yScale(d.y))
.attr("fill", "steelblue");
// ENTER: Add new elements
circles.enter()
.append("circle")
.attr("cx", d => xScale(d.x))
.attr("cy", d => yScale(d.y))
.attr("r", 0)
.attr("fill", "steelblue")
.transition()
.duration(500)
.attr("r", 5);
}, [data]);
要点:
- 顺序即语义:先
exit()(旧元素缩小到r=0后.remove()),再更新存量,最后enter()(新元素以r=0起始再过渡到 5)。若不传 key function,D3 会按数组下标机械对齐,增删数据时动画会错乱。 - 该模式是 .agents/skills/d3-viz/SKILL.md "Transitions and animations" 章节(链式过渡、
delay((d,i) => i*50)错峰、ease(d3.easeBounceOut)缓动)的可组合基础,两者配合可实现入场编排。
5.2 路径形变(Path morphing)
在两个折线数据集之间做路径过渡。标准技巧是使用 attrTween("d", ...) 手动补间:取当前 DOM 的旧 d 字符串、计算新数据的 path 字符串,交给 d3.interpolatePath(d3-interpolate-path 提供的数值路径插值器)逐坐标过渡,从而让线"流动"而非生硬跳变。
useEffect(() => {
if (!data1 || !data2) return;
const svg = d3.select(svgRef.current);
const line = d3.line()
.x(d => xScale(d.x))
.y(d => yScale(d.y))
.curve(d3.curveMonotoneX);
const path = svg.select("path");
// Morph from data1 to data2
path
.datum(data1)
.attr("d", line)
.transition()
.duration(1000)
.attrTween("d", function() {
const previous = d3.select(this).attr("d");
const current = line(data2);
return d3.interpolatePath(previous, current);
});
}, [data1, data2]);
要点:
- 为什么不用
.attr("d", ...)直接过渡:d3.attr的默认插值不适用于 path 字符串,因此必须显式attrTween("d", ...)返回一个t => string的插值函数。 - 该模式对折线/面积外轮廓同样有效,是"新旧两组时间序列平滑切换"的标准动画手法;大面积路径形变可进一步与 5.1 的 key function 结合以保持元素稳定。
六、如何把这些模式串成完整的可视化交付物
d3-patterns.md 提供的每个模式都是"自包含函数",拼接成完整交付的路径在技能内是闭环的:
- 选型:先判断图形家族——树/占比关系进"层级可视化",矩阵/构成/多维比较进"进阶图表",经纬度数据进"地理可视化",探索性筛选进"交互",数据随时间演进进"动画"。
- 取脚手架:以 .agents/skills/d3-viz/assets/chart-template.jsx(基础模板)或 .agents/skills/d3-viz/assets/interactive-template.jsx(含 tooltip、zoom 与交互)为组件壳,把本文件的模式函数作为渲染主体填入。
- 准备数据:用 .agents/skills/d3-viz/assets/sample-data.json 快速验证逻辑,再替换为真实数据;注意各模式的字段契约(
name/value、source/target/value、row/column/value、x/y/size/category、longitude/latitude、id/value)。 - 校准视觉:尺标参数(
scaleLinear/scaleBand/scaleSqrt/scaleTime的 domain/range/padding)查 .agents/skills/d3-viz/references/scale-reference.md;颜色(分类色 vs 连续色 vs 发散色、色盲安全选型)查 .agents/skills/d3-viz/references/colour-schemes.md。 - 落到视频/演示场景:若最终产出是画面而非纯网页,可结合 skills/creative/data-visualization.md 的图表选择与出场规则(例如"图表必须动起来,从空画框起步、柱体自基线生长、折线从左到右画出"),再用
diagram_gen(D3/Mermaid 通道)或 Remotion 动画图表组件(见 remotion-composer/src/components/charts)完成后期合成。
结语:一份"可检索、可引用"的 D3 模式清单
d3-patterns.md 的价值不在于引入新概念,而在于把高频可视化需求压缩成带完整代码与数据契约的最小模式,并内嵌在 OpenMontage 的 Agent 技能体系中随时可被引用。无论是快速原型、需要精确到像素的自定义图表,还是数据驱动的视频画面,都可以按"选中模式 → 粘贴 → 换数据 → 调样式"的四步流程落地;遇到标尺、配色、响应式与交互细节时,请继续翻阅同目录的 scale-reference.md、colour-schemes.md 以及 SKILL.md 中关于组件结构、Responsive sizing、性能优化与无障碍的最佳实践。
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 StartedRust0627
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