第 4 章 低维数据可视化

坐标、曲线与等值线 · 5 个小节

Alt + ← / → 切换章节课件

4.1 数据转换

你的 Crime 数据是宽格式,可视化常需要长格式。转换函数一行解决。

🎨 本节使用了 colormap(Viridis)画热图。colormap 理论详见 第 3 章 3.2 colormap 三类选择。

你的原案例

原 5.1 节用了 1985 美国 50 州犯罪数据(CSV),含 8 个犯罪指标列。

嵌入陈为教材理论

  • 长格式(long):每行一个观测,列 = 字段(如 {state, metric, value})。优点:方便过滤、分组、绑定到 d3 join。
  • 宽格式(wide):每行一个实体,列 = 指标(如 {state, murder, robbery, ...})。优点:人眼读得懂、直观。
  • 转换:flatMap + 循环键名 = 几行代码搞定。
  • d3.autoType:自动推断列类型(数字、日期、布尔),省得手动 +d.field。

⭐ 关键代码

⭐ 关键点 1:d3.csv 第二参 = 类型转换回调

// ⭐ 自动类型推断(数字/日期/布尔)
const raw = await d3.csv('./data/crime.csv', d3.autoType);
console.log(raw[0]);  // {state: 'Alabama', population: 3979000, murder: 350, ...}

⭐ d3.autoType 内部就是遍历每列、用 Number() / Date() / 字符串检测。能识别的就转、识别不了保持字符串。

⭐ 关键点 2:宽 → 长(flatMap 模式)

// ⭐ flatMap:每行展开成多个观测
const long = raw.flatMap(d =>
  ['murder', 'robbery', 'burglary'].map(key => ({
    state:  d.state,
    metric: key,
    value:  d[key]
  }))
);
// 现在可以做:d3.group(long, d => d.metric)

⭐ flatMap 是 ES2019,是 "map + 一维 flatten" 的简写。比 map().flat() 链式更直观。

加载中…

4.2 坐标轴变换

数据空间 → 坐标系空间 → 屏幕空间。三层职责分明,错了就乱套。

你的原案例

原 5.1.2 节用散点展示 Crime 数据(murder vs robbery),坐标轴是 1985 数据。

嵌入陈为教材理论

  • 三层空间:
    1. 数据空间:原始数值(如人口 27058000、谋杀率 8)
    2. 坐标系空间:归一化到 0..innerWidth(标准化坐标)
    3. 屏幕空间:加上 margin 偏移(最终像素位置)
  • 为什么分层:坐标系空间内的元素(线、点)可以共享,切换屏幕尺寸时无需重算。d3.axisBottom 自动帮你做坐标系→屏幕。
  • scale 选型:
    • scaleLinear:均匀分布,最常用
    • scaleLog:跨数量级(人口从 53 万到 2700 万)
    • scaleSqrt:面积感知(圆面积映射到值)
    • scaleBand:类别轴(柱状图)

⭐ 关键代码

⭐ 关键点 1:数据空间 vs 屏幕空间,两层 scale

// 数据空间:值 → 坐标系
const xData = d3.scaleLinear()
  .domain([0, d3.max(data, d => d.x)])
  .range([0, innerW]);

// 屏幕空间:坐标系 → 屏幕(加 margin)
const xScreen = d3.scaleLinear()
  .domain([0, innerW])
  .range([M.l, M.l + innerW]);

// 渲染:数据 → 坐标系 → 屏幕
.attr('cx', d => xScreen(xData(d.x)))

⭐ 错位示范:直接把 domain/max 和 range/[M.l, M.l+innerW] 写在一个 scale 里 → 改 margin 要改两处。三层分立就清爽。

⭐ 关键点 2:log scale 跨数量级

// 50 州人口从 53 万到 2700 万(差 50 倍)
// linear 缩放下,小州挤成一团,CA/NY/TX 占满屏幕
const xLog = d3.scaleLog()
  .domain([1, d3.max(data, d => d.x)])
  .range([0, innerW]);

⭐ log 轴对"指数增长"数据特别合适。人的视觉是"乘法"而不是"加法",所以 log 让"等比数列"在屏幕上"等距"。

加载中…

4.3 曲线拟合

同 50 个点,用 10 种 line.curve 画风各异。什么时候用哪种?

你的原案例

原 5.1.3 节用折线 + 散点展示趋势。原始代码用了 curveCatmullRom,没讲为什么。

嵌入陈为教材理论

  • 插值 vs 拟合:
    • 插值(interpolation):曲线经过所有数据点(如 Catmull-Rom、Natural)
    • 拟合(fitting):曲线不经过数据点,只反映趋势(如线性回归 LOESS)
  • d3.line().curve() 提供 10+ 种 curve,最常用:
  • step 系:用于离散阶跃(数字信号、状态切换)
  • catmullRom / natural:过所有点的张力样条,"看起来平滑"
  • monotoneX:保持原数据序列单调(不会出现伪震荡)
  • 选择原则:数据有顺序性 → monotoneX / linear;数据本身抖动大 → catmullRom / basis 平滑;离散状态 → step

⭐ 关键代码

⭐ 关键点 1:d3.line + curve 一行切换

const line = d3.line()
  .x(d => xScale(d.x))
  .y(d => yScale(d.y))
  .curve(d3.curveCatmullRom);     // <- 这一行换 curve 类型

svg.append('path').datum(data).attr('d', line);

⭐ datum(data) 把数组当整体传给 path(不是每个元素绑一个)。d3.line 内部循环生成 M/L/C 命令。

⭐ 关键点 2:拟合 vs 插值的取舍

// 拟合:d3.regressionLinear / d3.regressionLoess
import { regressionLinear } from 'd3-regression';
const lin = regressionLinear()
  .x(d => d.population)
  .y(d => d.murder)
  .domain([0, d3.max(data, d => d.population)]);
const points = lin(data);  // 起点-终点
svg.append('path').attr('d', d3.line()(points));

⭐ 经验法则:散点超过 30 个 → 用拟合;散点 < 10 个 → 用插值(保持信息完整)。Crime 数据 50 个州 → 拟合更稳。

加载中…

4.4 倾角感知(45° 黄金角)

人眼对斜率的估计 不是 线性的。45° 是视觉"中点"。

你的原案例

原 5.1.4 节提到"平均倾斜度最优为 45°",但没解释原因。

嵌入陈为教材理论

  • Cleveland 1984 实验:让被试估计直线的斜率,发现:
    • 斜率 = 0° 或 90° 时,估计误差最大
    • 斜率 = 45° 时,估计最准
    • 呈 U 型曲线
  • Stevens 幂律:感知斜率 ≈ (真实斜率)^1.0 ~ 1.3。即人眼把"陡度"夸张了 30%。
  • 设计原则:
    • 关键数据斜率落在 30°~60° 区间最稳
    • 对比增长曲线时,用 log 轴让陡度落在 45° 左右
    • 避免在 5° 以下或 85° 以上做趋势对比(误差太大)

⭐ 关键代码

⭐ 关键点 1:极坐标转笛卡尔坐标

// 角度 → 屏幕坐标
function lineAtAngle(angleDeg, length = 240, origin = {x: 100, y: 240}) {
  const r = (angleDeg * Math.PI) / 180;
  return {
    x1: origin.x,
    y1: origin.y,
    x2: origin.x + length * Math.cos(r),
    y2: origin.y - length * Math.sin(r)   // SVG y 向下为正,所以取负
  };
}

⭐ SVG 的 y 轴向下(与数学相反),所以 sin 角度项要取负。这是初学者最常踩的坑。

⭐ 关键点 2:把抽象理论变成可调参数

// 让用户拖滑块改角度,立刻看到"哪段斜率最稳"
const slider = document.getElementById('angle');
slider.addEventListener('input', e => {
  const angle = +e.target.value;
  renderLine(angle);     // 30°~60° 误差小,0°/90° 误差大
});
加载中…

4.5 等值线(Marching Squares)

给定标量场 + 阈值,追踪 值 = 阈值 的曲线。

你的原案例

原 5.2.2 节用 Marching Squares 提取 Crime 数据的等值线。原始代码是截图占位。

嵌入陈为教材理论

  • Marching Squares 原理:把标量场离散化为网格,每个 cell 4 个角的"是否大于阈值"决定 case 编号 0~15(2⁴=16 种)。每种 case 对应 0/1/2 条线段,把"超过阈值"和"未超过阈值"的两片区域分开。
  • 16 种 case:编码 bit0=BL、bit1=BR、bit2=TR、bit3=TL。
  • 扩展到 3D:Marching Cubes(Lorensen & Cline 1987),2⁸=256 种 case,常用于医学 MRI / CT 等值面提取。
  • 应用场景:
    • 地形等高线(地图)
    • 医学影像(MRI/CT 等值面)
    • 密度等高图(核密度估计)
    • 天气预报(等温线、等压线)

⭐ 关键代码

⭐ 关键点 1:交点插值与 16 case 拓扑

// 边编号:0=左, 1=下, 2=右, 3=上
// 交点必须沿网格边线性插值,而不是固定在边中点
function crossing(v1, v2, p1, p2, threshold) {
  if ((v1 >= threshold) === (v2 >= threshold)) return null;
  const t = (threshold - v1) / (v2 - v1);
  return {
    x: p1.x + t * (p2.x - p1.x),
    y: p1.y + t * (p2.y - p1.y)
  };
}

// 14 个无歧义 case;5/10 交给渐近判别器
const EDGE_TABLE = {
  0: [], 1: [[0, 1]], 2: [[1, 2]], 3: [[0, 2]],
  4: [[2, 3]], 5: null, 6: [[1, 3]], 7: [[0, 3]],
  8: [[0, 3]], 9: [[1, 3]], 10: null,
  11: [[3, 2]], 12: [[0, 2]], 13: [[1, 2]],
  14: [[0, 1]], 15: []
};

⭐ saddle case(5 和 10)存在两种合法连接。演示根据 cell 双线性标量场的鞍点值选择拓扑,即使用渐近判别器;不能简单用四角平均值代替。

⭐ 关键点 2:位运算拼 case 编号

// 4 个角是否 ≥ 阈值 → 4 位二进制 → case 编号
let code = 0;
if (bl >= threshold) code |= 1;   // bit 0
if (br >= threshold) code |= 2;   // bit 1
if (tr >= threshold) code |= 4;   // bit 2
if (tl >= threshold) code |= 8;   // bit 3

const lines = code === 5 || code === 10
  ? resolveSaddle(code, bilinearSaddleValue(cell, threshold))
  : EDGE_TABLE[code];
加载中…