第 2 章 数据可视化流程

从原始数据到视图与交互 · 4 个小节

Alt + ← / → 切换章节课件

2.1 数据可视化流程

从「拿到一张表」到「画出一张图」的完整链路

你的原案例

3 个可视化流程模型:① 参考 Card 1999 模型绘制流程图(Box Flowcharts)→ ② Mackinlay 自动化准则 → ③ van Wijk 模型(含交互反馈)。

原始图片是你的概念图。本节课用 D3 重画这张概念图,每个节点点击后能展开细节。

嵌入理论:可视化流程的三个经典模型

模型作者 / 年份核心思想
Card 数据流模型 Card, Mackinlay & Shneiderman, 1999 原始数据 → 数据表 → 视觉结构 → 视图(嵌套变换 D→T→V→C/L/S)
Mackinlay 自动化准则 Mackinlay 1986 对每种数据类型,自动选「量化准确性最高」的视觉通道(如:数值→位置、类别→色调)
van Wijk 反馈模型 van Wijk 2005 View ⇆ Filter ⇆ Param ⇆ Other Views —— 强调交互反馈循环

核心教学要点:三个模型描述同一件事的不同侧面—— Card 给"管道",Mackinlay 给"自动决策",van Wijk 给"反馈环"。 三者是叠加关系,不是冲突关系。

  • Card 模型:告诉你"transform 之后该 mapping,mapping 之后该 view"
  • Mackinlay:告诉你"mapping 这一步怎么自动决策"
  • van Wijk:告诉你"view 之后还要把用户操作 feed 回去"

关键代码 ① SVG 流程图节点 + 箭头

5 个 step 节点横向排列,相邻节点之间用 SVG marker 画的箭头连接。

⭐ // 1. 用 <defs> + <marker> 定义箭头
svg.append("defs").append("marker")
   .attr("id", "arrowhead")
   .attr("viewBox", "0 -5 10 10")
   .attr("refX", 8)            // 箭头位置校准
   .attr("markerWidth", 6)
   .attr("orient", "auto")       // 跟随线方向旋转
   .append("path").attr("d", "M0,-5L10,0L0,5").attr("fill", "#94a3b8");

⭐ // 2. 用 marker-end 引用箭头(重要)
svg.append("line")
   .attr("marker-end", "url(#arrowhead)")
   .attr("x1", x1).attr("y1", y).attr("x2", x2).attr("y2", y);

⭐ // 3. 节点 ↔ 数据 绑定:d3.data + click 事件
svg.selectAll(".node").data(nodes)
   .join("g")
   .on("click", (event, d) => {
     // ⭐ 关键:classed 是 D3 状态管理核心 API
     svg.selectAll(".node").classed("selected", n => n.id === d.id);
     document.getElementById("d-4-1-detail").innerHTML = d.detail;
   });

关键点解释: marker-end="url(#arrowhead)" 是 SVG 引用的方式 —— 把 <defs> 里定义的形状"嵌入"到线段末端; classed("selected", predicate) 接收布尔函数,一次性切换所有节点的"selected"状态,避免自己写 forEach。

▶ 交互流程图演示

👆 点击任一节点查看在 Card / Mackinlay / van Wijk 三个模型中的对应

2.2 数据分类树(d3.rollup 多层聚合)

把"按专业分"变成"按维度组合分"

📌 本节在流程中的位置:数据变换(Data Transformation)

Card 1999 数据流模型把可视化拆成四段: 原始数据 → (变换) → 数据表 → (映射) → 视觉结构 → (渲染) → 视图。 本节讲的 2.2,就是第一段箭头 「原始数据 → 数据表」—— 这正是 数据变换(Data Transformation)。

你的原案例

用新生数据按 "专业 / 班级" 做 rollup 分桶聚合。 你演示的核心是 d3.rollup 的链式调用 —— 一个函数完成多维度分组。

理论补丁:数据变换的四大操作(Card 1999 D 层)

从「原始数据」到「数据表」,D 层做四类操作(注意:原文 4.2 节只讲了"分桶聚合",这里把它放到完整的数据变换框架里):

操作做什么D3 实现
清洗 / 过滤 (clean / filter)去缺失、去异常、筛选满足条件的行.filter()、d3.csv(row) 回调
聚合 / 汇总 (aggregate)按 key 分组,把同组归约为一个值d3.rollup()、d3.group()
连接 / 关联 (join)按键把两张表拼起来(如把学生表 join 班级表)d3.map + 手动索引
派生 / 衍生 (derive)由已有列算出新列(如从"班级"切出"专业")row 回调里算一个新字段

本节演示的 rollup 聚合,属于"聚合"这一行。但读懂这张表,你才知道 数据变换是整个可视化流水线的"第一公里" —— 数据表没整理好,后面映射/渲染全都白搭。

嵌入理论:函数式聚合与 OLAP 切片

d3.rollup 是"按 key 分桶、再 reduce 一次"的函数式组合:

const result = d3.rollup(
  data,                    // 输入数组
  v => v.length,          // reducer
  d => d.major,           // key1(外层)
  d => d.class           // key2(内层,可选)
);

它返回 Map(不是数组、不是对象),所以:

  • 遍历用 for ([k, v] of m) 或 m.entries()
  • 嵌套 rollup 出嵌套 Map
  • 与 d3.group 的差别:rollup 把组内数据归约成一个值,group 保留原数组

理论补丁:OLAP 切片/切块/钻取

1993 年 Codd 提出 OLAP 12 条准则,其中 3 个核心操作用 rollup 就能模拟:

  • 切片 (Slice):固定一维度(如只看 2025 级)→ 等价于 .filter() 后 rollup
  • 切块 (Dice):按两个维度分组 → 等价于双 key rollup
  • 钻取 (Drill):层层下钻 → 等价于嵌套 rollup + 递归展开

教学要点:可视化系统的"下钻交互"通常背后就是 rollup + hierarchy 的组合。本节课演示 d3.rollup 的多层级能力。

关键代码 ① 双层 rollup

两层 key:外层 = 专业,内层 = 班级,reducer = 计数。

⭐ // 双层 rollup:返回嵌套 Map,外层 key=专业,内层 key=班级
const nested = d3.rollup(
  students,
  v => v.length,           // 每组聚合为单一值(这里是 count)
  d => d.major,            // 外层 key
  d => d.class             // 内层 key(rest 参数,可继续加)
);

// 遍历嵌套 Map:
for (const [major, inner] of nested) {
  for (const [cls, count] of inner) {
    console.log(major, cls, count);
  }
}

关键点解释: d3.rollup 的 key 参数是rest 参数(...keys),所以传 1 个 → 单层 Map,传 2 个 → 嵌套 Map,传 3 个 → 3 层嵌套。 这就是 OLAP 的"维度组合"。

关键代码 ② 用正则提取专业

CSV 里只有"班级"字段("软件工程1班"),需要用正则切出"专业"和"班级序号"。

⭐ // d3.csv 第二参 row 回调里逐行 transform
const data = await d3.csv("./data/newstudent.csv", d => {
  const cls = d["班级"].trim();
  const m = cls.match(/^(.+?)(\d+班)$/);  // 中间非贪婪 + 末尾"数字+班"
  return {
    id:    +d["序号"],
    name:  d["姓名"].trim(),
    class: cls,
    major: m ? m[1] : cls,  // "软件工程1班" → "软件工程"
  };
});

关键点解释: 正则 /^(.+?)(\d+班)$/ 的 .+? 是非贪婪匹配,会让中间尽可能短、末尾留给 \d+班。 没这个非贪婪:.+ 会把整串都匹配走,剩下空,m[1] 就是 undefined。 这类正则坑学生常踩,必须掌握。

▶ d3.rollup 多层聚合演示

2.3 树布局(Reingold-Tilford 算法)

层级数据 → 屏幕坐标的算法核心

📌 本节在流程中的位置:视觉映射(Visual Mapping)

接上一节 2.2 数据变换,我们有了"数据表"。本节的 2.3 是第二段箭头 「数据表 → 视觉结构」 —— 也就是 视觉映射(Visual Mapping)。 它回答一个核心问题:数据里每个属性,该用哪个视觉通道来表达?

你的原案例

你的 4.2 节提到"树形结构可视化"。这节展开树布局算法原理 —— 拿到层级数据后,怎么把节点摆到屏幕上不打架?

理论核心:什么是视觉通道?Mackinlay 的排名

视觉通道(visual channel)是指"把数据编码到视觉元素上"的载体: 位置、长度、面积、角度、颜色、形状、纹理…… Mackinlay 1986 给出经典排名:对不同数据类型,各通道的量化精确度不一样。 这就是下面这张图。

Mackinlay 1986 视觉通道排名
图 1 · Mackinlay 1986 通道排名:对 Quantitative(定量)/ Ordinal(有序)/ Nominal(名义)三类数据,各通道的量化精确度排序。位置(Position)永远最准。
  • 位置(Position):三种数据类型都是第一 —— 人眼对位置最敏感
  • 定量数据:长度 → 角度 → 斜率 → 面积(长条形图比面积图准)
  • 名义数据:位置 → 色调(hue)→ 纹理 → 连接 → 形状

理论核心:图元(marks)与视觉通道的构成

视觉结构由 图元(mark)打底:点(Point)、线(Line)、面(Area)。 然后通过 通道把这些图元"做记号":位置、颜色、形状、倾斜、大小。 下面两张图拆开讲。

图元:点/线/面
图 2 · 图元(marks)三族:点(Points)、线(Lines)、面(Areas)。图元决定"一份数据长什么样",是视觉结构的骨架。
视觉通道:位置/颜色/形状/倾斜/大小
图 3 · 常用视觉通道族:位置(Position,含水平/垂直/两者)、颜色(Color)、形状(Shape)、倾斜(Tilt)、大小(Size,含长度/面积/体积)。

理论核心:两类通道 —— 幅度 vs 标识

按"能不能表达量的多少",通道分两类: 幅度通道(Magnitude)表达有序/定量属性;标识通道(Identity)表达类别属性。 选错类别 = 误导读者。

幅度通道:有序属性
图 4 · 幅度通道(Magnitude Channels):表达"有序属性"。按精确度排序 —— 共用尺度位置最准,长度/倾斜/面积次之,颜色亮度、饱和度和体积最弱(都标 "Same" 即排序有歧义)。
标识通道:类别属性
图 5 · 标识通道(Identity Channels):表达"类别属性" —— 空间区域、色调(hue)、运动(motion)、形状(shape)。它们能区分"不同类",但分不出"谁多谁少"。

本节案例的落点:树布局(Reingold-Tilford)本质上就是一种视觉映射 —— 用 位置通道 把层级关系编码成"父在上、子在下的树"。下面讲它的算法。

嵌入理论:Reingold-Tilford 算法(d3.tree 的核心)

Reingold & Tilford 1981 年提出的算法,至今仍是 d3.tree() 的实现核心。核心思路:

  1. 第一遍(自底向上):对每个父节点,计算其左右子树"占用多少横向空间"
  2. 第二遍(自顶向下):给每个节点分配绝对 x 坐标(保证兄弟节点紧贴父节点中线且不重叠)
  3. 第三遍:处理"线程"(threads)—— 修复子节点跨父中线时的对齐

复杂度的处理:

  • O(n) 时间 —— 比简单的"递归平分"要快
  • 节点的左右子树宽度的最小值 = 至少容纳所有子节点的水平空间
  • 不同布局策略选型:d3.tree(左右)、d3.cluster(左右紧贴)、d3.treemap(面积)

理论补丁:d3-hierarchy 模块全家桶

  • hierarchy(data):把任意嵌套 data 转成 hierarchy 结构(每个节点加 parent/children/depth)
  • tree().size([h, w]):reingold-tilford 算法,左右布局
  • cluster().size([h, w]):所有叶子节点对齐到同一深度
  • treemap().tile(...):面积(slice-and-dice / squarified / resquarify 算法)
  • pack().radius(d => ...):圆环打包(circle packing)
  • partition().size([h, w]):icicle / sunburst 布局

关键代码 ① hierarchy + tree 三步走

这是 d3-hierarchy 的标准模式:先建 hierarchy、再求 tree layout、最后画 links + nodes。

⭐ // 1. hierarchy:从嵌套 JSON → 计算每个节点的 parent/children/depth
const root = d3.hierarchy(rootData);

⭐ // 2. tree layout:给每个节点算 x/y 坐标
const treeLayout = d3.tree().size([360, 720]);
const treeData = treeLayout(root);

⭐ // 3. 画 links:父-子连线(Reingold-Tilford 算法结果就在这里)
g.selectAll(".link").data(treeData.links()).join("path")
  .attr("d", d3.linkHorizontal().x(d => d.y).y(d => d.x));

⭐ // 4. 画 nodes:圆 + 文字
const ng = g.selectAll(".node").data(treeData.descendants()).join("g")
  .attr("transform", d => `translate(${d.y}, ${d.x})`);

ng.append("circle").attr("r", 5);
ng.append("text")
  .attr("text-anchor", d => d.children ? "end" : "start")
  .attr("x", d => d.children ? -8 : 8)
  .text(d => d.data.name);

关键点解释: treeData.x 是深度方向(默认垂直),treeData.y 是水平方向(同一深度的不同节点)。 所以 linkHorizontal 用 x=d.y, y=d.x —— 横线方向走 y,垂直方向走 x。 这种"x 轴走 y、y 轴走 x"的 swap 是横向树特有的,记住。

关键代码 ② separation 控制父子间距

separation 接收函数,返回 [node, parent] 之间的"间隔倍数"。

const treeLayout = d3.tree()
  .size([360, 720])
  .separation((a, b) => (a.parent === b.parent ? 1 : 2));
                              //   ↑ 兄弟节点紧贴        ↑ 非兄弟至少隔 2 倍

关键点解释: separation 默认值 (a, b) => a.parent === b.parent ? 1 : 2。 改成 0.5 → 全部挤一起;改成 2.0 → 树变宽。 Demo 里有个 slider 让你实时调看效果。

▶ 树布局演示

👆 切换聚合层次(专业→班级 / 性别→专业→班级 等),滑动 slider 调 separation 看效果 · 这就是用「位置通道」做视觉映射

2.4 树形列表渲染(classed + 事件委托)

把树画成可折叠的 HTML 列表,处理点击、冒泡、委托三个坑

📌 本节在流程中的位置:视图渲染 + 用户交互

接上节 2.3 视觉映射,我们有了"视觉结构"。本节 2.4 是最后两段箭头: 「视觉结构 → 视图」(渲染) 和 「视图 ↔ 用户」(交互反馈)。 Card 模型的 V 层,加上 van Wijk 的反馈环,都落在这里。

你的原案例

原文档用了 classed 控制节点样式、用 stopPropagation 阻止冒泡。重点是把 D3 树布局输出变成可交互的 HTML 列表。

嵌入理论:DOM 事件三阶段 + 事件委托

浏览器的事件流分三阶段(DOM 规范):

  1. 捕获 (capturing):window → document → ... → 目标元素(从外到内)
  2. 目标 (target):目标元素本身
  3. 冒泡 (bubbling):目标元素 → ... → document → window(从内到外)

默认监听的是冒泡阶段。你可以在监听器里 e.stopPropagation() 截断冒泡,让上层的监听器不再触发。

理论补丁:动画 —— 视图渲染的"时间维"(van Wijk 反馈的直观体现)

动画(transition)不是"花哨",而是视图渲染在时间维上的展开。 D3 里用 .transition().duration(ms), 让属性从旧值平滑过渡到新值。教学价值在 duration 这一个参数:

  • duration=0:瞬间跳变,师生"看不出变化过程"
  • duration=500:0.5 秒过渡,能看到展开/收缩/位移的流畅过程
  • duration>1500:太慢,学生开始走神

本节的交互 demo 特意加了 duration 滑块, 让你现场改变这个参数,直观看到"渲染如何随时间展开"。这是理解"视图渲染 + 交互反馈"最直接的一课。

理论补丁:事件委托 (Event Delegation)

问题:列表里有 1000 个 li,给每个 li 都绑 click 监听器 → 慢、内存大、新增元素要重新绑。

解法:只给父元素(ul/div)绑 1 个 click 监听器,利用冒泡机制 —— 子元素的 click 最终会触发父元素的监听器。用 e.target.closest('.label') 找到"实际点击的是哪个子元素"。

优势:1 个监听器代替 N 个、未来新增元素自动支持。

关键 API:D3 classed()

selection.classed("selected", true);          // 1) 添加 class
selection.classed("selected", false);         // 2) 移除 class
selection.classed("selected", d => d.id === pickId); // 3) 函数式批量切换

关键代码 ① 事件委托(一对多监听)

用递归 buildList 构建可折叠树;用 liSel 上的 click 做事件委托,不绑到每个 label。

⭐ // 父 ul 上绑一次 click,所有子 li 共享
function buildList(liSel, d) {
  const label = liSel.append("span").attr("class", "label");

  label.on("click.toggle", (event) => {
    // ⭐ 关键 ①:阻止冒泡 —— toggle 按钮不该触发"选中"
    event.stopPropagation();
    d.data._collapsed = !d.data._collapsed;
    render();
  });

  liSel.on("click.select", (event) => {
    // ⭐ 关键 ②:如果 click 来自 toggle,已经被 stopPropagation 阻断,不会到这里
    document.querySelectorAll("li").forEach(li => li.classList.remove("selected"));
    liSel.classed("selected", true);
  });
}

关键点解释: 这是经典的「阻止 toggle 按钮冒泡到 li 的 select 监听器」模式。如果不 stopPropagation, 点 toggle 时会同时触发"切换折叠"和"选中节点",用户看着会困惑。 这是 DOM 事件机制的核心教学点。

关键代码 ② 嵌套递归 buildList

d3 selection 的递归渲染:每个 li 内部再有 ul/li,再递归 buildList。

function render() {
  $tree.innerHTML = "";
  const ul = d3.create("ul");
  ul.selectAll("li").data([root]).join("li")
    .each(function(d) { buildList(d3.select(this), d); });
  $tree.appendChild(ul.node());
}

关键代码 ③ D3 过渡动画(transition + duration)

视图渲染的"时间维":让节点展开/收缩时平滑过渡,不是瞬间跳变。duration 由滑块实时控制。

⭐ // 关键:transition + duration = 让属性随时间平滑变化
svg.selectAll(".node")
  .transition()                                  // 进入过渡状态
  .duration(DURATION)                          // 时长(ms),滑块控制
  .attr("transform", d => `translate(${d.y}, ${d.x})`)
  .attr("opacity", 1);

// 新节点进入时:先设 opacity=0,再过渡到 1 → 淡入效果
ng.attr("opacity", 0)
  .transition().duration(DURATION).attr("opacity", 1);

关键代码 ④ 动画时长滑块(直接在 HTML/CSS 里控制)

<!-- HTML 滑块:监听 input 事件即可改 DURATION -->
<input id="dur" type="range" min="0" max="1500" step="50" value="500">

// JS:改变 DURATION 后重放动画
document.getElementById("dur").addEventListener("input", e => {
  DURATION = +e.target.value;
  render();   // 用新时长重新做一次过渡
});

关键点解释: 为什么用 innerHTML = "" 清空再重画? D3 没有"智能更新"树结构(层级变化)的 API —— 节点展开/折叠会改变父子结构,最简单的做法是全量重新渲染。 用 selection.each() 配合 d3.select(this) 把原生元素转回 selection,这是 D3 与原生 DOM 互操作的关键模式。

▶ 可折叠树形列表

👆 点击三角形 toggle 折叠/展开 · 点击条目本身选中(看下方日志)· 拖动「动画时长」滑块看 transition 效果