首页
/ OpenMontage d3-viz 技能库精读:覆盖层级图、热力图、地图与交互动画的 D3.js 可视化模式全解

OpenMontage d3-viz 技能库精读:覆盖层级图、热力图、地图与交互动画的 D3.js 可视化模式全解

2026-09-07 19:10:42作者:彭桢灵Jeremy

本篇技术指南以 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 侧为例):

.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 遵循高度一致的书写约定,掌握这些约定有助于你把每个片段无缝移植到自己的工程里:

  1. 两种代码壳:分层与多数图表使用 React 风格(useEffect + svgRef),而 Chord 图、Heatmap 使用独立函数(drawChordDiagram(data) / drawHeatmap(data),内部 d3.select('#chart'))。前者适合组件化场景,后者适合"数据变了就重画一遍"的命令式场景。
  2. 统一清空策略const svg = d3.select(svgRef.current); svg.selectAll("*").remove(); —— 重绘前先清空,规避重复绑定的脏数据。
  3. 布局流水线:凡层级数据一律先经 d3.hierarchy(data) 建树,再交给 d3.tree() / d3.treemap() / d3.partition() 计算坐标;凡表格型数据则先定义 scale,再做 data().join()
  4. 色彩约定:分类色大量使用 d3.scaleOrdinal(d3.schemeCategory10),连续色使用 d3.scaleSequential(d3.interpolateYlOrRd / interpolateBlues)。更系统的取色建议见 .agents/skills/d3-viz/references/colour-schemes.md
  5. 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.x0d.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) 作数值刻度。
  • 想换成其他连续色阶(如 interpolateBluesinterpolateViridis)只需替换一行;完整候选见 .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.mdscalePow/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),属于单色渐变;若要突出异常值,可换 interpolateYlOrRdinterpolateViridis。分级着色的另一种常见做法是把连续值用 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 状态:在第一个图(散点)里框选,第二个图(柱状)同步高亮同一批数据点。核心是 selectedPointsSet 数据结构)在两图之间充当唯一事实源。

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 提供的每个模式都是"自包含函数",拼接成完整交付的路径在技能内是闭环的:

  1. 选型:先判断图形家族——树/占比关系进"层级可视化",矩阵/构成/多维比较进"进阶图表",经纬度数据进"地理可视化",探索性筛选进"交互",数据随时间演进进"动画"。
  2. 取脚手架:以 .agents/skills/d3-viz/assets/chart-template.jsx(基础模板)或 .agents/skills/d3-viz/assets/interactive-template.jsx(含 tooltip、zoom 与交互)为组件壳,把本文件的模式函数作为渲染主体填入。
  3. 准备数据:用 .agents/skills/d3-viz/assets/sample-data.json 快速验证逻辑,再替换为真实数据;注意各模式的字段契约(name/valuesource/target/valuerow/column/valuex/y/size/categorylongitude/latitudeid/value)。
  4. 校准视觉:尺标参数(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
  5. 落到视频/演示场景:若最终产出是画面而非纯网页,可结合 skills/creative/data-visualization.md 的图表选择与出场规则(例如"图表必须动起来,从空画框起步、柱体自基线生长、折线从左到右画出"),再用 diagram_gen(D3/Mermaid 通道)或 Remotion 动画图表组件(见 remotion-composer/src/components/charts)完成后期合成。

结语:一份"可检索、可引用"的 D3 模式清单

d3-patterns.md 的价值不在于引入新概念,而在于把高频可视化需求压缩成带完整代码与数据契约的最小模式,并内嵌在 OpenMontage 的 Agent 技能体系中随时可被引用。无论是快速原型、需要精确到像素的自定义图表,还是数据驱动的视频画面,都可以按"选中模式 → 粘贴 → 换数据 → 调样式"的四步流程落地;遇到标尺、配色、响应式与交互细节时,请继续翻阅同目录的 scale-reference.mdcolour-schemes.md 以及 SKILL.md 中关于组件结构、Responsive sizing、性能优化与无障碍的最佳实践。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388