第 5 章 时序数据可视化

趋势、周期与叙事 · 5 个小节

Alt + ← / → 切换章节课件

5.1 时间属性

时间有 5 个核心属性:有序 / 连续 / 周期 / 独立于空间 / 结构性。每个属性都影响可视化设计。

你的原案例

原 6.1 节列出 5 个时间属性,没有可视化。这章把这 5 个属性变成可交互卡片。

嵌入陈为教材理论

  • 有序性:时间是有序的,两个事件有先后次序,与因果关系紧密相连。
  • 连续性:时间连续,两个时间点之间总存在另一个时间点。
  • 周期性:自然过程常有循环规律(季节、昼夜),可采用循环时间域。
  • 独立于空间:时间与空间紧相关,但科学过程大多独立处理。
  • 结构性:时间尺度分年/月/日/时/分/秒,分割既有自然反映(昼夜)也有人为定义(60 秒)。

为什么 5 个属性都重要?

设计时序图前先想清楚这 5 个属性,能避免常见错误:

  • 有序性缺失 → 散点图错位成噪声
  • 连续性混淆 → 离散数据用错连线
  • 周期性忽略 → 季节趋势被"抹平"
  • 结构性不匹配 → 工作日/周末、闰年都出错

⭐ 关键代码

⭐ 关键点 1:5 个属性以对象数组组织

// ⭐ 单一数据源:5 个属性 + 含义 + 例子 + 颜色
const TIME_ATTRIBUTES = [
  { key: 'ordered',          name: '有序性',     icon: '↗',
    desc: '时间是有序的。两个事件发生的时间有先后次序,与因果关系紧密相连。',
    example: '火车时刻表、事件日志、git commit history', color: '#3b82f6' },
  { key: 'continuous',       name: '连续性',     icon: '∞',
    desc: '时间是连续的。两个时间点之间总存在另一个时间点。',
    example: '实时传感器数据(温度、心率)、流式数据流', color: '#10b981' },
  { key: 'periodic',         name: '周期性',     icon: '🔁',
    desc: '许多自然过程有循环规律(季节、昼夜)。可采用循环时间域表示。',
    example: '24h 体温、12 个月气温、年周期销售', color: '#f59e0b' },
  { key: 'spatial_independent', name: '独立于空间', icon: '✂️',
    desc: '时间与空间紧相关,但科学过程大多将它们独立处理。',
    example: '折线图 x 轴只用时间、y 轴只用度量', color: '#8b5cf6' },
  { key: 'structured',       name: '结构性',     icon: '📐',
    desc: '时间尺度分年/月/日/时/分/秒,分割既有自然反映(昼夜)也有人为定义。',
    example: '日历/周排列、闰年调整、工作日 vs 周末', color: '#ec4899' }
];

⭐ 对象数组 = 一份数据 + 多处使用。卡片渲染、详细解释、筛选过滤都从同一数据源派生,避免改一处漏一处。

⭐ 关键点 2:d3.timeDay.range 生成等间距日序列

// 生成 2024-01-01 到 2024-12-31 之间所有日期(366 天)
const days = d3.timeDay.range(new Date(2024, 0, 1), new Date(2025, 0, 1));
// 输出:[Date(2024-01-01), Date(2024-01-02), ..., Date(2024-12-31)]
console.log(days.length);  // 366(闰年)

⭐ d3.timeDay 是 d3-time 模块的核心:range(start, stop) 返回两个时刻之间所有"日"的离散数组,offset(date, n) 加减 n 天。结构性(structural)就靠这种"时间尺度"操作实现。

加载中…

5.2 标准时间可视化(折线图)

x 轴 = 时间,y 轴 = 度量。最常见的时序图:法国人名 1900-2020 的使用频率。

你的原案例

原 6.2.1 节用折线图展示法国几十年人名(Marie / Jean / Camille)的使用频率变化。
原始页面附带"词云 + 鼠标高亮 + 时间段筛选",但代码只给了关键折线段。

嵌入陈为教材理论

  • 标准时序图本质:二维线图,x = 时间,y = 度量。优势是在线性时间域中的变化趋势一目了然。
  • 缺点:难以表达周期性——所以才有了 5.3 日历图和 5.4 螺旋图。
  • 交互:鼠标 hover 高亮、tip 显示、筛选时间段——是数据新闻标配。
  • 实战选择:数据点数 ≤ 50 用 linear;点数 50-500 用 monotoneX / curveCatmullRom;点数 > 500 才考虑面积 / 箱形。

⭐ 关键代码

⭐ 关键点 1:d3.timeParse + d3.timeFormat 时间格式化

// ⭐ 时间字符串 → Date 对象
const parseYear = d3.timeParse('%Y');        // '1965' → Date
// 时间 Date → 字符串
const formatYear = d3.timeFormat('%Y');     // Date → '1965'

// 典型 timeFormat tokens
//   %Y 4 位年    %y 2 位年
//   %m 月份 01-12   %B 月名 January
//   %d 日 01-31    %a 星期 Sun
//   %H 时 00-23    %M 分 00-59
//   %L 毫秒 000-999

⭐ d3-time 是 d3 的核心模块。d3.timeParse / timeFormat 处理任何时序数据必须先掌握——尤其 CSV 里年份是字符串"2024"而非 Date 对象。

⭐ 关键点 2:d3.scaleTime 时间轴

// 时间轴 = 数值轴的特化版(Date 在底层就是数字)
const xScale = d3.scaleTime()
  .domain([new Date(1900, 0, 1), new Date(2020, 11, 31)])
  .range([0, innerW]);
// d3.axisBottom 自动按"年/十年/世纪"切换 tick
svg.append('g')
  .attr('transform', `translate(${M.l},${M.t + innerH})`)
  .call(d3.axisBottom(xScale).ticks(10));

⭐ scaleTime 比 scaleLinear 多两个能力:1) 自动按时间跨度选 tick 间隔(10 年 / 12 月 / 30 天);2) 时区处理(实际项目记得设 utc())。

⭐ 关键点 3:hover 高亮 + tip

// 透明覆盖层捕获鼠标事件
const focus = svg.append('g').style('display', 'none');
focus.append('line')             // 垂直参考线
  .attr('class', 'hover-line')
  .attr('y1', 0).attr('y2', innerH);
focus.append('circle')           // 圆点
  .attr('r', 5).attr('fill', 'steelblue');
focus.append('text')             // tip
  .attr('class', 'tip');

svg.append('rect')               // 覆盖矩形
  .attr('width', innerW).attr('height', innerH)
  .attr('fill', 'none').attr('pointer-events', 'all')
  .on('mouseover', () => focus.style('display', null))
  .on('mouseout',  () => focus.style('display', 'none'))
  .on('mousemove', (event) => {
    const xm = d3.pointer(event)[0];
    const date = xScale.invert(xm - M.l);
    // bisect 找最近点
    const i = d3.bisector(d => d.year).left(series, date);
    const d = series[i];
    focus.attr('transform', `translate(${xScale(d.year)},0)`);
  });

⭐ 通用 hover 模板:透明矩形 + focus g(line + circle + text)。d3.bisector 找最近点比手写循环快且准。

加载中…

5.3 日历热图(van Wijk)

以"年=行 / 周=列"展示全年数据,发现周期性 + 周内趋势 + 节假日异常。

你的原案例

原 6.2.2 节用日历热图展示 2024 年北京每日最高温度。每月之间用红线分隔。

嵌入陈为教材理论

  • 日历热图 (Calendar Heatmap):van Wijk 2008 提出,x = 周(年内的第几周),y = 星期几,颜色 = 数值。
  • 适用场景:长时序(年)+ 周期性(星期 / 月)+ 异常检测(节假日、工作日 vs 周末)。
  • 设计要点:
    • 每周从周日(或周一)开始——影响"周末"在视觉上的位置
    • 月份之间用红线分隔——比 7×53 网格更易读
    • colormap 用顺序型(如 Viridis)——见 第 3 章 3.2 colormap

isMonthDivider 的巧妙之处

判断 7 天前与今天是否同月——是则同月分割线,否则需要画月线。这是日历可视化的核心算法。

⭐ 关键代码

⭐ 关键点 1:d3.timeDay.range + d3.timeFormat.isoDate

// ⭐ 把"年"拆成"日"列表
const days = d3.timeDay.range(
  new Date(2024, 0, 1),         // 2024-01-01
  new Date(2025, 0, 1)          // 2025-01-01
).map(d => ({
  date:  d,
  value: byKey.get(d3.timeFormat('%Y-%m-%d')(d)) ?? 0
}));
// 现在 days.length === 366(2024 闰年)

⭐ d3.timeDay.range(start, stop) 是"日内连续性 + 结构性"两个属性的合击——自动按日粒度切分整年,闰年 366 / 平年 365 自动正确。

⭐ 关键点 2:周日为起点布局

function weekColSunday(date) {
  // 周日为每列起点 (getDay()=0)
  return Math.floor((d3.timeDay.count(
    d3.timeYear.floor(date), date)) / 7);
}
function dayIndexSunday(date) {
  return date.getDay();    // 0=周日, 1=周一, ..., 6=周六
}

// 渲染每个日格
g.selectAll('rect.cell')
  .data(days).join('rect')
  .attr('class', 'cell')
  .attr('width',  cellSize)
  .attr('height', cellSize)
  .attr('x', d => weekColSunday(d.date) * cellSize)
  .attr('y', d => dayIndexSunday(d.date) * cellSize)
  .attr('rx', 2).attr('ry', 2)
  .attr('fill', d => color(d.value));

⭐ 周日 vs 周一布局是日历热图的设计分歧:周日为起点是英文日历传统(2024-01-01 是周一,所在列第 1 行往下是 周一→周六,最末行是周日)。中文场景常改成周一起点。

⭐ 关键点 3:isMonthDivider 月份分隔

// ⭐ 巧妙算法:判断 7 天前是否同月
function isMonthDivider(date) {
  const sevenDaysAgo = d3.timeDay.offset(date, -7);
  return date.getFullYear() !== sevenDaysAgo.getFullYear()
      || date.getMonth()    !== sevenDaysAgo.getMonth();
}
// 同月:不画分割线;跨月:画一条上边框

function isFirstDayOfMonth(date) {
  return date.getDay() !== 0         // 排除周日(被周列开头替代)
      && date.getMonth() > 0         // 排除一月(画上边框没意义)
      && date.getDate() === 1;
}

⭐ 不用循环"找每个月的第一天",改用"7 天前是否同月"——简洁又正确,O(n) 一遍扫描即可。这是日历热图作者 van Wijk 的精妙算法。

加载中…

5.4 周期时间可视化(阿基米德螺旋)

将时间沿圆周排列(螺旋 = 圆 + 持续展开)。发现周期性:每个回路 = 一个周期。

你的原案例

原 6.2.3 节用阿基米德螺旋(等速螺旋)布局日期,每个回路代表 1 年 / 1 月 / 1 周。每个日期对应螺旋上的一个扇环。

嵌入陈为教材理论

  • 螺旋 vs 折线:折线在线性时间域表现"趋势",但难以表现"周期";螺旋沿圆周重复,能突出周期性。
  • 阿基米德螺旋 (Archimedean Spiral):r = a + bθ,等速展开(每转一圈半径增加常量 b)。
  • 动态 Δθ 算法:原文档的精妙点——Δθ = boxSize / √(b² + r²),让每个扇环的弧长近似相等,保持视觉一致性。
  • 实战取舍:螺旋适合"年/季节/周"的循环,不适合"无周期"或"周期太长"。

关键路径生成

每个日期对应螺旋上的一个扇环——4 个关键点:起点外/终点外/终点内/起点内,闭合回起点。

⭐ 关键代码

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

// ⭐ 阿基米德螺旋公式:r = a + bθ
const boxSize = 32;                  // 每环宽度
const coilGap = boxSize * 1.2;       // 螺旋间距
const b = coilGap / (2 * Math.PI);   // 半径增长系数
const startRadius = 100;
let theta = (startRadius - 0) / b;   // 起始角度

function pos(t) {                    // t: 角度(弧度)
  const r = 0 + b * t;
  return {
    x: r * Math.cos(t),              // 极坐标转笛卡尔
    y: r * Math.sin(t),
    r
  };
}

⭐ 极坐标 = (r, θ),笛卡尔 = (x, y),θ 每增 2π,r 增 b——这就形成"每转一圈外扩一格"的螺旋。如果 b=0,就是普通圆周。

⭐ 关键点 2:把一年归一化为 2π

// ⭐ 一个完整年度恰好一个角度周期
const dayAngle = 2 * Math.PI / days.length;
const dayRadiusStep = (outerRadius - innerRadius) / days.length;

for (let dayIndex = 0; dayIndex < days.length; dayIndex++) {
  const theta = dayIndex * dayAngle;
  const radius = innerRadius + dayIndex * dayRadiusStep;
  const x = radius * Math.cos(theta);
  const y = radius * Math.sin(theta);
  // ...绘制第 dayIndex 天的扇环
}

// 366 天结束后累计角度 = 366 × dayAngle = 2π

⭐ 这里固定了“完整一年映射完整一圈”的语义:每天占据相同的角度槽位,首尾累计角度严格为 2π,半径则沿阿基米德螺旋连续增大。原案例中的动态 Δθ 是等弧长变体;若用它绘制年度周期,必须先把累计角度归一化,否则一年会跨越多个回路。

⭐ 关键点 3:扇环路径生成

// ⭐ 4 关键点:起点外 → 终点外 → 终点内 → 起点内
function segmentPath(t0, t1) {
  const coords = (t, sign) => {
    const r = pos(t).r + sign * coilGap / 2;  // sign=+1 外圈 / -1 内圈
    return [r * Math.cos(t), r * Math.sin(t)];
  };
  const [x0o, y0o] = coords(t0, +1);
  const [x1o, y1o] = coords(t1, +1);
  const [x1i, y1i] = coords(t1, -1);
  const [x0i, y0i] = coords(t0, -1);
  return `M ${x0o} ${y0o} L ${x1o} ${y1o} L ${x1i} ${y1i} L ${x0i} ${y0i} Z`;
}
// d 属性 = 'M ... L ... L ... L ... Z',4 个 L 描述一个 4 边形

⭐ SVG path 极简:M 移动、L 画线、Z 闭合。手算 4 顶点比用 d3.arc() 灵活(d3.arc 是从圆弧角度算的,对螺旋无法直接用)。

加载中…

5.5 Storyline 时间线(侏罗纪公园)

多角色叙事:每条线 = 一个角色 / 组织 / 进程,y 轴位置 = 聚散关系,时间 = 故事进展。

你的原案例

原 6.2.4 节用《侏罗纪公园》5 个角色(Alan / T-REX / Raptors / Malcolm / Dilophosaurus)做 Storyline 时间线。事件层用红色半透明圆圈标注关键冲突。

嵌入陈为教材理论

  • Storyline 起源:2008 年斯坦福大学 Ogawa & Ma 提出,原本用于电影/学术合作者关系网。
  • 核心:把每个角色/组织/进程抽象为"生命线",线靠近 = 角色相遇 / 冲突,线分开 = 角色分离。
  • 3 大元素:
    1. 时间尺度映射:电影时长 → SVG 宽度,x 轴严格按比例对齐
    2. Catmull-Rom 样条:curveCatmullRom.alpha(0.5) 张力系数 0.5,"自然流动"但不"过度弯曲"
    3. 事件层:半透明红圈 + 文字,标注关键转折点
  • 应用场景:电影分析、案件多人时间线、体育比赛互动、项目任务交接。

为什么 Catmull-Rom 不用 monotoneX?

monotoneX 保证"不过度弯曲"但会让线"过于直",失去叙事的"河流感"。Catmull-Rom 张力系数 0.5 是经验值——既能流动又不震荡。

⭐ 关键代码

⭐ 关键点 1:每个角色 = 一条 path

// ⭐ 时间尺度:0-127 分钟 → 屏幕宽度
const xScale = d3.scaleLinear()
  .domain([0, 127])
  .range([50, width - 50]);

// 每个角色是一组 [time, y] 关键点(手工调 y 实现聚散)
const characters = [
  { name: 'T-REX', color: '#d32f2f', points: [
    {time: 75, y: 280}, {time: 78, y: 130},   // 攻击现场
    {time: 88, y: 200}, {time:120, y: 290}
  ]},
  { name: 'Grant', color: '#1976d2', points: [
    {time:  0, y: 100}, {time: 78, y: 130},   // 与 T-REX 聚拢
    {time:120, y:  50}
  ]}
];

// 路径生成器
const line = d3.line()
  .curve(d3.curveCatmullRom.alpha(0.5))   // 张力 0.5
  .x(d => xScale(d.time))
  .y(d => d.y);

characters.forEach(c => {
  svg.append('path')
    .datum(c.points)
    .attr('d', line)
    .attr('stroke', c.color)
    .attr('stroke-width', 3)
    .attr('fill', 'none');
});

⭐ 用 y 值表达"关系"是关键:T-REX 和 Grant 在 t=78 都画到 y=130,视觉上两条线聚拢= "T-REX 攻击 Grant"。这不是算法生成,是手工叙事设计——但 d3 提供工具让你能这样手工做。

⭐ 关键点 2:事件层(叙事锚点)

// ⭐ 事件层:半透明红圈 + 文字标签
const events = [
  { time: 78, x: xScale(78), y: 130, name: 'T-REX 攻击',
    desc: 'T-REX 冲出围栏攻击 Alan & Kids 的车' },
  { time: 48, x: xScale(48), y: 320, name: 'Nedry 事件',
    desc: 'Dilophosaurus 攻击 Nedry' }
];

const eventLayer = svg.append('g').attr('class', 'event-layer');

eventLayer.selectAll('g.event')
  .data(events).enter().append('g')
  .attr('class', 'event')
  .style('cursor', 'pointer')
  .on('mouseenter', (e, d) => showTip(`${d.name}\n${d.desc}`))
  .each(function (d) {
    // 半透明背景圈
    d3.select(this).append('circle')
      .attr('cx', d.x).attr('cy', d.y).attr('r', 20)
      .attr('fill', 'rgba(255,0,0,0.15)');
    // 文字
    d3.select(this).append('text')
      .attr('x', d.x).attr('y', d.y - 9)
      .attr('text-anchor', 'middle')
      .attr('font-size', 10).attr('font-weight', 600)
      .attr('fill', '#cc0000')
      .text(d.name);
  });

⭐ 事件层与线层解耦:线层表达"关系流动",事件层表达"叙事锚点"。这种双层结构是 Storyline 的标准设计——可分别动画、可独立筛选。

⭐ 关键点 3:alpha(0.5) 张力系数

// d3.curveCatmullRom.alpha(t) - t ∈ [0, 1]
// t=0: 接近 linear(接近直线)
// t=0.5: 标准 Catmull-Rom(推荐)
// t=1.0: 接近 cardinal(高度曲线化)
d3.line().curve(d3.curveCatmullRom.alpha(0.5));

⭐ 选 0.5 是经验值:比 0 更"软"(看起来有故事感),比 1.0 更"稳"(不会过山车)。做时间叙事就用 0.5;做科学曲线拟合就用 0.2-0.3(更接近数据)。

加载中…