Introduction to D3.js

学习D3.js,这是一个用于在网络上生成动态、交互式数据可视化的JavaScript库。D3.js可能会让人望而生畏,因此这是一个有趣且易于上手的SVG和D3入门课程!在本课程中,你将获得数据绑定的实践经验,并学习它如何与“进入-更新-退出”模式配合使用,使用比例尺构建完整图表,还将学习在不同的D3布局之间制作动画,以实现真正的交互式可视化效果——让你的数据焕发生机!

0-introduction

  • 本期课程的两大主要目标
    • 深入理解 SVG (可缩放矢量图形)
      • 能够在浏览器中绘制自定义图形。
      • 能够轻松阅读和理解 SVG 语法。
    • 掌握 D3.js 的核心基础
      • D3 是事实上的 Web 数据可视化 JavaScript 库。
      • 功能非常强大和全面,几乎涵盖了所有数据可视化的需求。
      • 由于其复杂性,可能会让人感到不知所措。
      • 本课程不求面面俱到,而是专注于讲解库中最核心、最基本的部分。
      • 学完后,你将能举一反三,将核心原则应用于库的其他部分。
  • 课程材料
    • 本次课程使用一个 Observable notebook 进行教学。
    • 地址:observablehq.com/@sxywu,在 "workshop introduction to D3" 合集中。
    • 建议跟着 notebook 一起操作,内含大量练习和动手环节。
    • 可以直接在提供的 notebook 中操作,或者用 GitHub 账户登录后 fork(复制)一份进行修改。
  • 讲师介绍:Shirley Wu
    • 独立的数据可视化创作者,即自由职业者。
    • 与各类客户合作,帮助他们用数据讲述故事。
    • 工作流程覆盖:数据分析、设计、原型制作和最终的编码实现。
    • 拥有软件工程的专业背景。
    • 自 2012 年以来一直使用 D3,并搭配过不同框架(Backbone, React, 现在是 Vue)。
    • 自 2014 年起开始教授 D3 和数据可视化课程。
  • 课程核心项目:电影之花 (Film Flowers)
    • 项目简介:将过去 25 年中最卖座的夏季大片重新想象成花朵。
    • 数据到视觉的映射规则
      • 花瓣形状 -> 电影分级 (Parental Guidance ratings)。
      • 颜色 -> 电影类型 (Genres)。
      • 花瓣数量 -> IMDb 投票数
      • 花朵大小 -> IMDb 评分 (满分 10 分)。
    • 示例亮点
      • 《黑暗骑士崛起》非常漂亮。
      • 喜欢《哈利波特》和《盗梦空间》。
      • 最喜欢的作品是 1997 年的《蝙蝠侠与罗宾》,因为它小巧又可爱。
  • 项目数据
    • 课程将使用一个为夏季大片清洗过的数据集。
    • 与原项目唯一的区别:本次课程只根据电影的第一个类型进行着色。

1-d3-js-ecosystem-resources

  • 制作“电影之花”的七个步骤
    1. 绘制一片花瓣:学习 SVG 坐标系和 SVG 路径 (path),特别是 d 属性。
    2. 选择花瓣并绑定数据:学习 D3 的选择集 (selections)、数据绑定 (data binding) 以及如何设置属性和 CSS 样式。
    3. 为每部电影创建一片花瓣:学习 D3 的 enter/append 模式和 SVG 的 transform (特别是 translate 命令)。
    4. 根据数据渲染花瓣:使用 D3 的比例尺 (scales) 将数据(如评分、票数)映射到视觉属性(如大小、颜色)。会用到 d3.min, d3.max, d3.extent 等函数。
    5. 将花瓣组合成花朵:学习 D3 的嵌套选择集 (nesting selections),并使用 SVG transformrotate 命令来排列花瓣。
    6. 处理数据更新:学习 D3 的 update/exit 模式,用于处理动态变化的数据集。同时学习 D3 的过渡 (transitions) 来实现从一个数据状态到另一个状态的平滑动画。
    7. 定位花朵:简要了解 D3 的布局生成器(如形状、层级结构),并使用 D3 的力导向布局 (force layout) 根据电影类型来定位花朵。
  • 课程目标
    • 通过这七个步骤,对 D3 库有一个宏观的了解。
    • 最终能自如地查阅 D3 社区中的大量示例,并读懂其代码。
  • D3 生态系统与资源
    • D3 API Reference (官方文档)
      • 列出了 D3 的所有模块,内容非常详尽,有时会让人感到不知所措。
    • D3 示例 (历史)
      • **blocks.org (bl.ocks.org)**:D3 在 2011 年刚推出时,大量的示例都托管在此网站上。
      • 通常包含一个代码预览和 index.html 文件,是过去学习 D3 的主要方式。
    • D3 示例 (现在)
      • Observable (observablehq.com):当前 D3 示例的主要托管平台。
      • 自称为“为数据和可视化打造的魔法笔记本”。
      • 可以理解为网页版的 Jupyter Notebook,但使用 JavaScript,并为可视化量身定做。
      • 核心特点:单元格 (cell) 是“响应式” (reactively) 执行的,基于依赖链,而非从上到下的顺序。这意味着 import 或工具函数可能写在 notebook 的底部。
    • 社区资源
      • D3 Slack: 一个可以提问 D3 相关问题的 Slack 频道。
      • Data Visualization Society (数据可视化协会): 一个更广泛的社区,不只针对 D3,但其中有大量精通 D3 的专家,也是寻求帮助的好地方。

2-svg-vs-html5-canvas

  • 课程目标:在屏幕上绘制一片花瓣。
  • 浏览器绘图的两种主要技术:SVG vs. HTML5 Canvas
    • 本课程将主要使用 SVG。
  • SVG (Scalable Vector Graphics - 可缩放矢量图形)
    • 本质: 一种 XML 语法,与 HTML 非常相似。
    • 工作方式: 每个图形(如矩形、圆形、路径)都是一个 DOM 元素。可以像操作 HTML 元素一样,为其设置属性、样式和绑定事件监听器。
    • 优点:
      • 上手简单,易于交互,因为每个形状都是独立的 DOM 元素。
      • 与 D3 结合使用非常自然,对于熟悉 Web 开发的人来说过渡平滑。
    • 缺点:
      • 性能问题: 当元素数量非常多时,性能会下降。
      • 经验法则:
        • 超过 2000-3000 个 SVG 元素时,渲染可能会出现问题。
        • 当需要同时为超过 1000 个元素添加动画时,绝对不推荐使用 SVG。
      • 性能瓶颈原因: 浏览器需要跟踪和管理成千上万个独立的 DOM 元素,更新和动画它们的计算开销很大。
  • HTML5 Canvas
    • 本质: 一个单一的 <canvas> DOM 元素,通过 JavaScript API 在其上进行“绘制”。
    • 工作方式: 使用 canvas.drawRect() 等 JS 命令来绘图。一旦绘制完成,图形就变成了画布上的像素信息,无法再作为独立对象被访问。
    • 优点:
      • 性能极高: 由于只将图形存储为像素数据,可以轻松渲染成千上万甚至数十万个数据点(尽管从信息传达角度不推荐这样做)。
    • 缺点:
      • 交互困难: 绘制出的图形不是 DOM 元素,没有原生的事件支持(如点击、悬停)。实现交互需要自己构建事件系统,捕获鼠标位置并判断其是否在某个形状上。
      • 上手难度相对较高。
  • 一个生动的类比
    • SVG 就像 Adobe Illustrator:基于矢量的,每个对象都是独立的,可以随时编辑。
    • Canvas 就像 Adobe Photoshop:基于像素的,一旦绘制,就融合成了一张位图。
  • 重要澄清
    • D3 不仅仅用于 SVG:一个常见的误解是 D3 只能和 SVG 一起使用。
    • D3 是技术无关的 (agnostic):D3 可以操作任何浏览器支持的技术,包括 SVGHTML 元素,甚至也支持 Canvas
  • 课堂问答
    • : 如果我从 Illustrator 导出一个 SVG 文件,可以用 D3 操作它吗?
    • : 完全可以。你可以将导出的 .svg 文件中的路径字符串 (<path d="...">) 复制过来,然后用 D3 的选择器选中它并进行操作。这是一个很好的方法,特别是对于绘制复杂图形。

3-svg-viewport-coordinates

  • SVG 基础:视口 (Viewport) 与坐标系
    • 理解 SVG 的第一步是了解它的视口和坐标系统,这与普通的 HTML 元素有所不同。
  • SVG 视口 (Viewport)
    • 概念: 可以将 SVG 元素想象成一扇“窗户”,透过它能看到一个充满图形的世界。
    • 定义: 必须为 SVG 元素指定 widthheight 属性,这定义了“窗户”的大小。只有在窗户范围内的图形才是可见的。
    <svg width="100" height="100">
    <!-- SVG 图形元素放在这里 -->
    </svg>
    
  • SVG 坐标系
    • 原点 (0,0): 位于 SVG 视口的左上角。
    • X 轴: 从左到右,数值增加。
    • Y 轴: 从上到下,数值增加。
      • 这是一个关键点,与数学中常见的坐标系不同,需要适应这种思维转变。
    • 深度学习: 推荐 Sara Soueidan 的博客系列,它对 SVG 坐标系有非常详尽的讲解。
  • 常用的 SVG 元素
    • 虽然 SVG 有很多图形元素,但以下几个是讲师最常用的:
      • <rect> (矩形)
      • <circle> (圆形)
      • <path> (路径)
      • <text> (文本)
  • 元素属性
    • 必要属性 (尺寸): 如果不设置,图形将不可见。
      • rect: width, height
      • circle: r (半径)
      • path: d (定义路径的字符串)
    • 可选属性 (定位): 如果不设置,默认定位在原点 (0,0)。
      • rect: x, y (左上角坐标)
      • circle: cx, cy (圆心坐标)
      • text: x, y
    • 文本对齐:
      • text-anchor: 类似 CSS 的 text-align
      • 值:start (左对齐), middle (居中对齐), end (右对齐)。

4-svg-path-commands

  • 深入 SVG 路径 (<path>)

    • path 元素是 SVG 中最强大的元素之一。
    • 通过理解其 d (define) 属性的语法,我们可以绘制几乎任何能想象到的形状。
  • 示例:绘制樱花花瓣

    <svg width="100" height="100">
      <path d="..." fill="none" strokeWidth="2"></path>
    </svg>
    
  • 通过 <path> 元素绘制一个樱花花瓣。

    `d` 属性的值是一串看起来像乱码的字符串,但它实际上是由一系列命令组成的。
    
    
  • 核心 path 命令

    • 虽然命令很多,但以下三个是最常用且最强大的:
      1. M (moveto - 移动到)
      2. L (lineto - 画直线到)
      3. C (curveto - 画曲线到)
  • 命令详解

    • M x,y (moveto)
      • 作用: 就像把画笔从纸上“提起”,然后“移动”到一个新的坐标 (x,y) 并“落下”。这通常是路径的起点。
    • L x,y (lineto)
      • 作用: 从当前画笔的位置,画一条直线到新的坐标 (x,y)
      • 需要与 moveto 或其他命令连用,确保画笔已在纸上。
    • C x1,y1 x2,y2 x,y (curveto - 三次贝塞尔曲线)
      • 作用: 从当前位置画一条平滑的曲线到终点 (x,y)
      • 构成:
        • 终点 (红色): (x,y) 是曲线的结束位置。
        • 控制点 1 (蓝色): (x1,y1) 是起点的“手柄”,决定了曲线开始时的方向和弧度。
        • 控制点 2 (紫色): (x2,y2) 是终点的“手柄”,决定了曲线结束时的方向和弧度。
      • 理解: 想象从起点到终点有一条直线,然后用两个控制点像“磁铁”一样去“拉扯”这条线,使其弯曲成想要的形状。
  • 分解花瓣路径

    • 将复杂的花瓣路径字符串分解为多个命令的组合:
    1. M 0,0:将画笔移动到原点。
    2. C 50,40 50,70 20,100:画出第一条曲线,形成花瓣的一侧。
    3. L 0,85:画一条短直线,连接到花瓣的另一侧。
    4. L -20,100:画另一条短直线。
    5. C ... 0,0:画出第二条曲线,闭合路径,形成完整的花瓣。

5-draw-an-svg-shape-by-hand-practice

  • 练习目标: 熟悉 SVG path 命令,亲手创建一个笑脸。
  • 准备工作
    • 使用一个 100x100 像素的坐标系。
    • 先画草图,再确定坐标点。
  • 绘制过程 (合作完成)
    1. 绘制眼睛 (两条短竖线)
      • 第一只眼睛:
        • M 25,25: 移动画笔到左眼起点。
        • L 25,35: 向下画一条 10 像素的直线。
      • 第二只眼睛:
        • M 75,25: 提起画笔,移动到右眼起点。
        • L 75,35: 向下画一条 10 像素的直线。
      • 路径字符串: M 25,25 L 25,35 M 75,25 L 75,35
  1. 绘制嘴巴 (一条曲线)
  • 目标: 画一个不对称的微笑。
  • 起点: M 15,75 (提起画笔,移动到嘴巴左边嘴角)。
  • 曲线命令 C:
  • 控制点 1: 20,100 (David 提议)。
  • 控制点 2: 85,90
  • 终点: 85,75
  • 完整嘴巴路径: C 20,100 85,90 85,75
  • 最终路径字符串
    • M 25,25 L 25,35 M 75,25 L 75,35 M 15,75 C 20,100 85,90 85,75
  • 探索与发现
    • 通过修改 curveto 命令中的控制点坐标,可以创造出各种不同的曲线形状。
      • 将控制点 Y 坐标设为 0,笑脸会倾斜。
      • 将控制点 Y 坐标设为很小的负数,笑脸会变成一个波浪线。
    • 这充分展示了 pathcurveto 命令的强大能力。通过组合这些命令,可以创造任何想要的形状。
    • 如果从 Illustrator 等工具导出 SVG,其路径字符串也是由这些命令组成的,现在我们有能力去解读它了。
  • 重要修正
    • 之前在练习中删除了 transform 属性,但它其实是故意设置的。
    • 目标是让图形的逻辑中心位于 (0,0) 点,这对于后续的旋转操作至关重要。
    • 因此,重新调整了笑脸的坐标,使其以 Y 轴为中心对称分布(例如,眼睛的 x 坐标为 2525)。

6-svg-shape-exercise

  • 个人练习:创建你自己的花瓣
    • 任务: 设计并绘制一个属于你自己的花瓣 SVG 路径。
    • 提示:
      1. 先用纸笔画草图:像之前的练习一样,先在坐标系上画出想要的形状。
      2. 标记关键点: 在草图上标出所有起点、终点和控制点的 (x,y) 坐标。这个过程能帮助你清晰地构思路径命令。
    • 重要注意事项:
      • 将花瓣的中心/根部放在 (0,0)
      • 这是因为后续我们会以 (0,0) 点为轴心来旋转花瓣,从而组成一朵完整的花。这一点对于后续步骤至关重要。
  • 目标:
    • 将你创建的花瓣路径字符串粘贴到 notebook 指定的 petalPath 变量中。
    • 会征集一个同学的作品,这个花瓣将贯穿我们后续的所有练习,并最终出现在我们创造的“电影之花”可视化作品中。

7-svg-shape-solution

  • 练习回顾
    • 感谢大家提交的精彩花瓣设计。
  • 选定的花瓣设计
    • 为了继续课程,我们将采用学员 Vijay 提交的设计。
    • 这个设计非常酷,看起来像火焰,效果很棒。
  • 路径代码解析
    • Vijay 的路径非常复杂,使用了多个曲线 (C) 和直线 (L) 命令。
    • 大致过程:
      1. (0, 85) 附近开始。
      2. 画一条线到左侧。
      3. 用一个 C 命令画出左上方的小曲线。
      4. 再画一条线下来。
      5. 用另一个 C 命令画出左下方类似“S”形的缓和曲线。
      6. 通过镜像的方式,在右侧重复了相同的绘制过程,形成了一个对称的形状。
    • 测试表明,这个路径可以完美地填充颜色 (fill)。
  • 如何使用多个自定义花瓣
    • 如果你设计了多个花瓣,可以进入名为 workshop-utility-functions 的 notebook。
    • 找到名为 petalPaths 的单元格,将你所有的花瓣路径字符串粘贴进去(最多支持 4 个)。
    • 这样,你的多个设计将在后续的可视化中被使用。
  • 常见的 SVG 调试技巧 (Bugs)
    • Bug 1: 图形不显示 (位置问题)
      • 原因: 没有明确设置 SVG 的 widthheight。SVG 默认尺寸有限(如300x150),而你的图形可能被画在了这个可见区域(视口)之外。
    • Bug 2: 图形不显示 (属性缺失)
      • 原因: 没有为图形元素设置必要的尺寸属性。
        • <rect>: 缺少 widthheight
        • <circle>: 缺少 r (半径)。
        • <path>: 缺少 d (路径定义)。
    • Bug 3: 只有线条的路径不显示
      • 原因: SVG 图形默认样式是 fill: black (黑色填充) 和 stroke: none (无描边)。
      • 如果你的 <path> 是一条没有闭合的开放路径(例如一条单纯的线),它没有可填充的区域,所以看不见。
      • 解决:
        • 明确设置 stroke 颜色和 stroke-width
        • 或者将路径闭合,使其可以被填充。

8-api-overview

D3 API 概述

  • 目标: 选中我们刚刚创建的花瓣,并将电影数据绑定到它上面。
  • D3 的 API 非常全面,但也可能让人不知所措。为了更好地理解,我们可以按照数据可视化编码流程的步骤来对其模块进行分类。

D3 模块分类

  1. 数据准备 (Data Preparation)
    • 作用: 处理原始数据,为后续的布局计算做准备。
    • 常用模块:
      • d3-array: 用于计算数组的最大值、最小值等。
      • d3-collection (或类似的数组方法): 用于对数据(如 JSON 对象)进行分组。
  2. 布局计算 (Layout Calculation)
    • 作用: D3 帮助我们计算复杂可视化布局所需的所有位置和属性。
    • 示例: 绘制一个树状图。D3 会为我们计算:
      • 连接节点之间的连线的路径 (d属性)
      • 每个节点的位置 (x, y坐标)
    • 我们提供数据,D3 输出用于在 DOM 中绘图所需的所有参数。
  3. DOM 操作 (DOM Manipulation)
    • 作用: 这是 D3 的核心基础。它根据数据来创建、更新或移除 DOM 元素。
  4. 收尾工作 (Finishing Touches)
    • 作用: 添加图表的最后润色部分。
    • 常用模块/功能:
      • 坐标轴 (d3-axis): 方便地为图表生成坐标轴。
      • 动画 (d3-transition): 实现平滑的过渡效果。
      • 交互 (d3-drag, d3-brush): 实现元素的拖拽、区域选择(刷选)等交互功能。

本次工作坊的重点

本次工作坊将涵盖以下几个部分,虽然看起来只是 D3 的一小部分,但它们是理解整个库的关键:

  1. DOM 操作 (DOM Manipulation): 最核心的部分。掌握了这部分,就能理解 D3 中绝大多数示例代码的运作方式。
  2. D3 比例尺 (D3 Scales): 将数据映射到可视化属性。
  3. 过渡 (Transitions): 用于实现动画。
  4. 力导向布局 (Force Layout): 用于定位我们的花朵。

核心理念: 掌握了 D3 的 DOM 操作,你就能读懂 D3 的示例代码,并知道如何将其应用到自己的项目中,而不仅仅是盲目地复制粘贴。

9-selections

D3 选择集 (Selections)

选择集是 D3 操作 DOM 的基础。我们主要关注 selectselectAll

d3.select(selector)

  • 作用: 在文档中查找并返回第一个与指定 CSS 选择器匹配的元素。
  • 示例: d3.select('svg') 会选中页面中的第一个 SVG 元素。

d3.selectAll(selector)

  • 作用: 在文档中查找并返回所有与指定 CSS 选择器匹配的元素。
  • 示例: svg.selectAll('rect') 会选中该 SVG 内部所有的矩形元素。

如何理解 D3 选择集(调试技巧)

  • 当你在浏览器控制台打印一个 D3 选择集对象时,会看到它内部有一个 _groups 属性。
  • _groups 是一个数组,其中包含了 D3 选中的真实 DOM 元素
  • 这是一个非常重要的调试工具,可以让你检查是否选中了预期的元素。在 Chrome 调试器中点击 _groups 里的元素,可以直接在“Elements”面板中定位到该 DOM 节点。

方法链 (Method Chaining)

  • D3 的选择集对象本身挂载了大量可用的方法(如 .attr(), .style(), .data() 等)。
  • 这就是为什么我们可以进行链式调用,例如 d3.select('svg').selectAll('rect').attr(...)。每一次调用都会返回一个选择集对象,可以继续进行下一步操作。

选择器的作用域 (Scoping)

  • d3.select()d3.selectAll(): 从整个文档开始搜索。
  • selection.select()selection.selectAll(): 只在 selection 这个父选择集所包含的元素的后代中进行搜索。这是一个非常重要的区别,可以帮助我们更精确地定位元素。

10-data-binding

D3 数据绑定

数据绑定是将数据与 DOM 元素关联起来的过程,这是 D3 实现“数据驱动”的核心机制。

关键概念: D3 会在被绑定的 DOM 元素上创建一个特殊的内部属性 __data__,并将相应的数据存储在这里。

.datum() vs .data()

.datum(value)

  • 作用: 将整个数据 value 绑定到选择集中的每一个元素上。
  • 场景:
    • 选择单个元素: d3.select('rect').datum([45, 67, ...])
      • 结果:这一个矩形元素绑定了整个数组。
    • 选择多个元素: d3.selectAll('rect').datum([45, 67, ...])
      • 结果:每一个矩形元素都绑定了同一个、完整的数组。

.data(array)

  • 作用: 将 array 数组中的元素与选择集中的元素进行一对一的绑定。
  • 场景:
    • 选择多个元素: d3.selectAll('rect').data([45, 67, 23, 89, 50])
      • 结果:
        • 第一个矩形绑定数据 45
        • 第二个矩形绑定数据 67
        • ...以此类推。
    • 这是数据可视化中最常用的绑定方式。

调试数据绑定

你可以通过以下方式检查元素上绑定的数据:

  1. 打印选择集: console.log(selection),然后在 _groups 中找到元素,查看其 __data__ 属性。
  2. 在 Elements 面板中检查: 选中一个元素,然后在控制台输入 console.dir($0) ($0 是对当前选中元素的引用),展开对象的属性列表,找到 __data__

11-attr-style

使用绑定数据操作 DOM

一旦数据被绑定到元素上,我们就可以使用 .attr().style() 方法来根据这些数据修改元素的属性和样式。

.attr(name, value).style(name, value)

  • 作用:
    • .attr(): 设置 DOM 元素的属性(如 width, height, x, y, fill 等)。
    • .style(): 设置 DOM 元素的 CSS 样式(如 fill-opacity, stroke-dasharray 等)。
  • value 的两种形式:
    1. 固定值: selection.attr('width', 50)
      • 所有被选中的元素 width 属性都被设置为 50。
    2. 函数: selection.attr('height', function(d, i) { ... })
      • 这是最强大的用法。D3 会遍历选择集中的每一个元素,并为每个元素执行这个函数。
      • 函数参数:
        • d: 当前元素上绑定的数据 (data)。
        • i: 当前元素在选择集中的索引 (index)。
      • 函数的返回值将被用作该元素的属性值。

示例解析

selection
  .attr("x", function (d, i) {
    return i * 50; // 根据索引计算 x 坐标
  })
  .attr("height", function (d) {
    return d; // 使用绑定的数据作为高度
  });
  • x 属性:
    • 第一个矩形 (i=0),x0 * 50 = 0
    • 第二个矩形 (i=1),x1 * 50 = 50
    • ...
  • height 属性:
    • 第一个矩形,height 为其绑定的数据 45
    • 第二个矩形,height 为其绑定的数据 67
    • ...

通过这种方式,我们让数据直接驱动了每个矩形的视觉表现。

动手练习

建议花几分钟时间,在 notebook 的单元格中尝试修改这些值或函数,以获得对 D3 选择集、数据绑定和属性设置更直观的理解。

  • 尝试修改传入的数据数组。
  • 尝试在函数中对数据进行数学运算(如 return d * 2)。
  • 查阅 SVG Presentation Attributes 文档,尝试设置不同的属性,如 fill (填充色), stroke (描边色), stroke-width (描边宽度) 等。

12-attr-style-practice

动手实践与探索

1. 动态数据

  • 我们可以轻松地替换绑定的数据。即使代码保持不变,只需传入一个新的数据数组,图表就会自动更新以反映新数据。
  • 观察: 如果新数据数组的长度小于 DOM 元素的数量,只有前面几个元素(有数据绑定的)会被渲染出来。

2. 修正条形图方向

  • 问题: 由于 SVG 的坐标系 Y 轴向下延伸(原点 (0,0) 在左上角),我们的条形图是“头朝下”的。

  • 解决方案: 我们需要同时设置 y 坐标和 height

    • height: 仍然由数据 d 决定。
    • y: 应该是 容器总高度 - 条形本身高度
  • 代码实现: 这样,较高的条形图,其 y 坐标值较小(更靠上),从而实现了“从下往上长”的正确效果。

    .attr('y', function(d) {
    return 100 - d; // 假设容器高度为 100
    })
    .attr('height', function(d) {
    return d;
    })
    
        ```
    

3. 玩转样式

  • 我们可以使用 .style().attr() 来设置更多的视觉样式。

  • 示例: 创建虚线描边。

    .attr('stroke-dasharray', '5 5') // 5像素实线,5像素空白
    
    
  • 这展示了如何结合使用 .attr().style() 以及数据绑定来创建丰富的视觉效果。

13-attr-style-exercise

练习:为花瓣上色

目标: 利用我们在上节学到的知识,为现有的花瓣路径元素绑定电影数据,并根据数据进行上色和样式设置。

任务要求

  1. 选择元素:

    • 在给定的单元格中,首先选中所有的 <path> 元素。
  2. 绑定数据:

    • movies 数据数组绑定到这些路径上。
  3. 设置填充色 (fill):

    • 根据每部电影的类型 (genre) 来设置花瓣的填充颜色。

    • 数据结构注意: 每部电影的 genre 属性是一个数组(可能包含多个类型)。为了简化,我们只使用数组中的第一个类型

    • 颜色查找: 使用提供的 colors 对象作为颜色映射表。如果电影的类型不在这个对象中,则使用 colors.other 作为默认颜色。

    • 代码逻辑:

      .attr('fill', function(d) {
          const firstGenre = d.genres[0];
          return colors[firstGenre] || colors.other;
      })
      
      
  4. 自由探索 (可选):

    • 尝试设置其他属性,如 stroke (描边色)、stroke-width (描边宽度)、fill-opacity (填充不透明度) 等,来美化你的花瓣。

14-attr-style-solution

练习解答:为花瓣上色

步骤分解

  1. 选择 SVG 和 路径

    • 出于好习惯,我们先选择 SVG 容器,然后在其内部选择所有的 <path> 元素。
    const svg = d3.select(svgNode); // svgNode 是 observable 传入的 DOM 节点
    const paths = svg.selectAll("path");
    
  2. 绑定电影数据

    • 使用 .data() 方法将 movies 数组绑定到路径选择集上。
    paths.data(movies);
    
    • 调试观察: 此时打印选择集,会看到一个有趣的现象:_groups 显示了已有的 5 个路径,但 _enter 中包含了 130 个“待进入”的占位符。这是因为我们的数据(135 部电影)比 DOM 元素(5 个路径)多。我们将在下一个 notebook 中详细讲解如何处理这种情况。
  3. 设置填充色 (fill) 和描边色 (stroke)

    • 我们使用一个函数来动态决定颜色。函数接收绑定的数据 d (即单个电影对象)。
    • 我们从 d.genres 数组中取出第一个类型,然后在 colors 对象中查找对应的颜色。如果找不到,则使用 colors.other
    .attr('fill', function(d) {
        const genre = d.genres[0];
        return colors[genre] || colors.other;
    })
    .attr('stroke', function(d) {
        const genre = d.genres[0];
        return colors[genre] || colors.other;
    });
    
    
  4. 美化样式

    • 为了让描边可见(因为当前填充色和描边色相同),我们可以降低填充的不透明度,并增加描边宽度。
    .attr('fill-opacity', 0.5)
    .attr('stroke-width', 2);
    
    

通过以上步骤,我们成功地使用 D3 的选择集、数据绑定和属性设置,根据电影数据为花瓣赋予了独特的颜色。

15-creating-dom-elements-from-data

从数据创建 DOM 元素

到目前为止,我们都是将数据绑定到已存在的 DOM 元素上。但在实际应用中,我们不可能预先在 HTML 中手动创建成百上千个元素。D3 的核心能力之一就是根据数据动态地创建所需元素。

.enter().append() 模式

这是 D3 最重要、最核心的概念之一。

过程分解

假设我们有一个空的 SVG 容器和一个包含 5 个数字的数组 [45, 67, 23, 89, 50]

  1. d3.selectAll('rect')
    • 此时 SVG 中没有 <rect> 元素,所以这会返回一个空的选择集
  2. .data([45, 67, ...])
    • 我们将数据数组绑定到这个空选择集上。
    • D3 会进行比较:DOM 中有 0 个矩形,而数据中有 5 个条目。
    • 结论: 需要创建 5 个新的矩形元素来与数据匹配。
    • D3 会将这 5 个“待创建”的元素信息放入一个名为 _enter 的特殊选择集中。这些还不是真实的 DOM 元素,只是占位符 (placeholders),但它们已经绑定了相应的数据。
  3. .enter()
    • 这个方法的作用就是获取上面提到的 _enter 选择集。
    • 它返回一个包含所有“待创建”元素占位符的选择集。
  4. .append('rect')
    • 这个方法会遍历 .enter() 返回的选择集。
    • 对于每一个占位符,它会在 DOM 中真实地插入一个 <rect> 元素,并将占位符上绑定的数据传递给这个新创建的元素。

完整的链式调用:

svg.selectAll("rect").data(barData).enter().append("rect"); // 在此之后,我们就可以像之前一样继续链接 .attr() 和 .style()

D3 的核心理念:数据驱动文档 (Data-Driven Documents)

  • 这就是 D3 名字的由来。我们让数据驱动文档(DOM)的结构。
  • 如果你今天只能记住一件事,就请记住这个: 在 D3 中,我们让数据来决定 DOM 中应该存在什么。

注意事项

  1. 数据必须是数组:

    • 传递给 .data() 方法用于创建元素的数据,必须是一个数组。D3 需要遍历数组来确定应该创建多少个元素。
  2. 选择器与附加元素的一致性:

    • selectAll() 中的选择器应该与你计划 append() 的元素相匹配,以便后续的更新操作能够正确地选中它们。

    • 示例: 如果你使用类名作为选择器 selectAll('.bar'),那么在 append 之后,必须为新元素添加这个类名:

      .selectAll('.bar')
      .data(data)
      .enter()
      .append('rect')
      .attr('class', 'bar'); // 确保新元素也带有 'bar' 类
      
      
    • 遵循这个规则能避免很多潜在的 bug。

16-creating-dom-elements-from-data-exercise

练习:为每部电影创建一片花瓣

目标: 使用 .enter().append() 模式,为数据集中每一部电影创建一个花瓣,并根据电影数据设置其样式。

任务要求

  1. 创建花瓣:

    • 在空的 SVG 容器中,为 movies 数组中的每一部电影创建一个 <path> 元素。
  2. 设置位置 (transform):

    • 使用 transform="translate(x, y)" 属性来定位每一个花瓣,将它们排列成一个网格。

    • 为了简化计算,可以直接使用提供的 calculateGridPosition(i) 函数。这个函数接收元素的索引 i,并返回一个包含 [x, y] 坐标的数组。

    • 代码逻辑:

      .attr('transform', function(d, i) {
          const position = calculateGridPosition(i);
          return `translate(${position[0]}, ${position[1]})`;
          // 或者更简洁的: return `translate(${calculateGridPosition(i)})`;
      })
      
      
  3. 设置花瓣形状 (d):

    • 根据每部电影的家长指导评级 (rated) 来决定花瓣的形状。
    • 使用提供的 pathObject 作为查找表,将评级(如 "PG-13", "R" 等)映射到具体的路径字符串。
  4. 设置颜色 (fill):

    • 与上一个练习相同,根据每部电影的第一个类型 (genres[0]) 来设置填充色。
    • 使用 colorObject 作为颜色查找表。

最终效果: 你将看到一个由 135 片花瓣组成的网格,每片花瓣的形状和颜色都由其对应的电影数据决定。

17-creating-dom-elements-from-data-solution

练习解答与补充说明

补充说明

  • 数据数组内容: .data() 绑定的数组,其内部元素可以是任何类型(对象、字符串、数字)。只要外层是数组,D3 就能处理。
  • 响应式布局:
    • 要实现响应式,通常需要在 JavaScript 中监听窗口的 resize 事件。
    • 在事件回调函数中,重新获取容器的宽度,然后重新计算所有元素的位置(例如,重新计算网格中每行可以放多少个花瓣),最后重新设置它们的 transform 属性。
    • 本次练习中的 calculateGridPosition 函数内部就包含了类似逻辑:它根据容器总宽度和单个花瓣宽度来决定每行排列几个。

解答步骤

  1. 选择、绑定数据、创建元素

    • 这是核心的 enter().append() 流程。
    d3.select(svg)
      .selectAll("path") // 选中一个空集
      .data(movies) // 绑定135部电影数据
      .enter() // 获取需要创建的135个元素的占位符
      .append("path"); // 为每个占位符创建一个真实的 <path> 元素
    
  2. 设置花瓣形状 (d 属性)

    • 使用电影的 rated 属性(家长指导评级)在 pathObject 中查找对应的路径字符串。
    .attr('d', function(d) {
        return pathObject[d.rated];
    })
    
    
  3. 设置位置 (transform 属性)

    • 使用 translate 命令来定位每个花瓣。位置通过 calculateGridPosition(i) 函数计算。
    • 注意,模板字符串可以直接接收数组,并将其转换为以逗号分隔的字符串。
    .attr('transform', function(d, i) {
        return `translate(${calculateGridPosition(i)})`;
    })
    
    
  4. 设置颜色和样式

    • 这部分与之前的练习完全相同:根据电影类型设置 fillstroke,并添加 fill-opacitystroke-width 进行美化。
    .attr('fill', function(d) {
        const genre = d.genres[0];
        return colorObject[genre] || colorObject.other;
    })
    // ... 其他样式设置
    
    

通过这一系列操作,我们成功地从无到有,为数据集中的每一条数据都创建了一个对应的、样式丰富的可视化元素。

Observable Notebook 的 Forking

  • 如果你想保存并分享你的修改,可以在 Observable Notebook 右上角的菜单(三个点)中选择 "Fork"。
  • 这需要你登录(可以用 GitHub 账户)。
  • 注意:如果 Fork 后你的自定义花瓣丢失了,可能需要手动进入 workshop-utility-functions 這個 notebook,在 petalPaths 单元格中重新粘贴你的花瓣路径字符串。

18-translate-position

深入理解 SVG transformtranslate

transform 是 SVG 中一个非常强大的属性,而 translate 是其最常用的命令之一。

transform="translate(x, y)" 的真正含义

  • 它并不是简单地“移动”这个元素。
  • 更准确地说,它是移动了该元素自己的坐标系

视觉化理解

  • 想象每个 SVG 元素都有一个自己独立的、透明的坐标纸。默认情况下,这张纸的左上角 (0,0) 与其父容器的 (0,0) 对齐。
  • 当你对一个矩形应用 transform="translate(50, 80)" 时,你实际上是将这张透明坐标纸向右移动了 50px,向下移动了 80px。
  • 现在,当你在该矩形上设置 x="10" y="10" 时,这个 (10,10) 是相对于它自己那张已经移动过的坐标纸来计算的。
  • 结果就是,这个矩形最终被渲染在了父容器的 (50+10, 80+10)(60, 90) 的位置。

为什么 translate 非常有用?

  • 简化路径定义:
    • 在我们的花瓣练习中,我们所有的花瓣路径 (d 属性) 都是围绕 (0,0) 点设计的。
    • 如果没有 translate,我们绘制第 100 个花瓣时,就必须手动计算出它在全局坐标系下的所有路径点坐标(例如,M 300,200 L 320,250...),这将是一场噩梦。
  • 解耦设计与布局:
    • translate 让我们能够将形状的设计(花瓣长什么样,围绕 0,0 绘制)和元素的布局(花瓣应该放在哪里)分离开来。
    • 我们只需要设计一个标准化的、以 (0,0) 为中心的形状,然后就可以通过 translate 把它像“图章”一样盖在画布的任何位置。

这个概念对于组合复杂场景(如将花瓣旋转成花朵)至关重要。

19-data-types-visual-channels

  • 目标:根据电影评分(IMDB 评分)来调整每个花瓣的大小。
  • 问题:目前我们只映射了分类属性(如类型、分级)。当我们尝试直接将 IMDB 评分(0-10 的数值)乘以花瓣的原始高度(100 像素)来进行缩放时,会导致花瓣尺寸过大(例如高达 1000 像素),视觉效果不符合预期。
  • 核心概念:D3 比例尺 (D3 Scales)
    • 使用 D3 比例尺来将原始数据(raw data)转换为可渲染到 DOM 的视觉通道(visual channels)。
  • 常见数据类型
    • 可量化/连续型 (Continuous)
      • 定量 (Quantitative):数值可以连续。例如:IMDB 评分(可以是 7.23)、IMDB 投票数。
      • 时间 (Temporal):例如电影的发行日期。
    • 分类/离散型 (Discrete)
      • 名义 (Nominal):类别之间没有内在顺序。例如:电影类型(喜剧、动作等)。
      • 有序 (Ordinal):类别之间有隐含的顺序。例如:电影分级(G < PG < PG-13 < R),T 恤尺寸(小、中、大)。
      • 空间 (Spatial):例如城市或国家,通常被视为分类数据。

20-scales

  • 常见视觉通道 (Visual Channels)
    • 连续型 (Continuous)
      • 位置 (Position):x, y 坐标。
      • 尺寸/维度 (Size/Dimensions):矩形的宽度/高度,圆的半径,或元素的缩放比例。
      • 颜色 (Color)
        • 顺序 (Sequential):从浅到深的渐变。
        • 发散 (Diverging):从一个中心值向两边发散。
    • 离散型 (Discrete)
      • 分类颜色 (Categorical Colors):一组互不相关的颜色。
      • 形状 (Shapes):例如为不同类别使用不同形状的花瓣。
      • 符号 (Symbols):点、方块、字母等。
  • D3 比例尺的心智模型 (Mental Model)
    • 我们可以根据输入(数据类型)和输出(视觉通道类型)来选择合适的 D3 比例尺。
    • 连续输入 → 连续输出
      • 常用比例尺: scaleLinear, scaleLog, scaleSqrt, scaleTime
      • 用途: 将连续数据(如评分)映射到连续的视觉通道(如位置、尺寸)。
    • 连续输入 → 离散输出
      • 常用比例尺: scaleQuantize
      • 用途: 将一个连续的数据范围(如 IMDB 投票数)分割成几个离散的“桶”,并映射到离散的输出(如花瓣数量,只能是 5、6、7 等整数)。
    • 离散输入 → 离散输出
      • 常用比例尺: scaleOrdinal
      • 用途: 将离散的数据(如电影类型)映射到离散的视觉通道(如一组分类颜色)。
    • 离散输入 → 连续输出
      • 常用比例尺: scaleBand
      • 用途: 为一系列离散的元素(如条形图中的每个条形)计算其在连续轴上的位置(如 x 坐标)。

21-using-d3-js-scales

  • D3 比例尺的实际用法
    • .domain(): 定义输入域,即原始数据的范围。通常是一个包含最小值和最大值的数组,如 [min, max]
    • .range(): 定义输出域,即视觉通道的范围。例如,像素位置 [0, 500] 或尺寸 [10, 100]
    • 使用: 定义好 .domain.range 后,比例尺本身就成了一个函数,可以将一个原始数据值传入,得到一个转换后的视觉值。
  • 辅助函数
    • d3.min(data, accessor): 计算最小值。
    • d3.max(data, accessor): 计算最大值。
    • d3.extent(data, accessor): 非常有用,直接返回一个 [min, max] 数组,可以无缝对接到 .domain() 中。
  • 实践案例:修正条形图
    • 问题: 之前的条形图尺寸是硬编码的,并且是上下颠倒的。
    • 解决方案: 使用 D3 比例尺实现动态化和方向修正。
    • X 轴比例尺 (scaleBand):
      • 类型: 离散输入 → 连续输出。
      • domain: 条形的索引数组 [0, 1, 2, ..., n-1]
      • range: 图表容器的宽度 [0, width]
      • 作用: 自动为每个离散的条形计算出它在 x 轴上的连续位置。
    • Y 轴比例尺 (scaleLinear):
      • 类型: 连续输入 → 连续输出。
      • domain: 数据的范围,如 [0, d3.max(data)]
      • range: 图表容器的高度,但顺序颠倒 [height, 0]
      • 关键点: 将 range 设置为 [height, 0] 是修正条形图方向的关键。它将数据中的最小值 0 映射到容器的底部 (height),将最大值 max 映射到容器的顶部 (0),从而使条形图从下向上生长。

22-scales-exercise

  • 练习目标
    • 将原始的 movies 数据数组,通过 D3 比例尺转换成一个用于渲染的 flowers 对象数组。
    • 这个新数组中的每个对象将包含渲染花朵所需的所有视觉通道属性。
  • 需要实现的映射关系
    1. 分级 (PG rating)花瓣类型 (petal type/path)
    2. IMDB 投票数 (number of IMDb votes)花瓣数量 (number of petals)
    3. IMDB 评分 (IMDb rating out of 10)花瓣大小 (size of petal)
    4. 电影类型 (movie genre)花瓣颜色 (color of petals)
  • 编程最佳实践
    • 分离数据: 保持原始数据数组(movies)和派生出的视觉数据数组(flowers)分离。
    • 原因: 这是一个好习惯,可以避免对原始数据的意外修改(mutate),尤其当同一份原始数据需要用于生成多种不同的可视化图表时。
  • 任务
    • 为上述四种映射关系选择正确的 D3 比例尺。
    • 为每个比例尺设置合适的 domainrange
    • 使用这些比例尺处理 movies 数组,生成 flowers 数组,每个对象包含 color, path, scale, numPetals 等属性。

23-scales-solution

  • 练习解答:创建各种比例尺
    1. 颜色比例尺 (colorScale)
      • 类型: scaleOrdinal (离散 → 离散)。
      • domain: 使用 topGenres 数组(如 ['Action', 'Comedy', ...])。
      • range: 使用 petalColors 数组(颜色十六进制码数组)。
      • .unknown(): 使用 .unknown(otherColor) 为不在 domain 中的类型(即“其他”类型)指定一个默认颜色,这比之前的对象查找逻辑更简洁。
    2. 路径比例尺 (pathScale)
      • 类型: scaleOrdinal (离散 → 离散)。
      • 技巧: 不指定 domain。只提供一个 rangepetalPaths 数组)。D3 会自动为它遇到的每一个新的、唯一的电影分级(如'PG', 'R'等)从 range 中分配一个尚未使用的花瓣路径。这非常方便,无需手动列出所有分级。
    3. 尺寸比例尺 (sizeScale)
      • 类型: scaleLinear (连续 → 连续)。
      • domain: 使用 d3.extent(movies, d => d.rating) 动态获取电影评分的最小值和最大值。
      • range: 设置一个较小的缩放范围,例如 [0.2, 0.75],以确保花瓣大小适中。
    4. 花瓣数量比例尺 (numPetalScale)
      • 类型: scaleQuantize (连续 → 离散)。
      • domain: 使用 d3.extent(movies, d => d.votes) 动态获取 IMDB 投票数的范围。
      • range: 一个代表花瓣数量的整数数组,例如 [5, 6, 7, 8, 9, 10]。比例尺会将连续的投票数范围分割成几个区间,每个区间映射到数组中的一个整数。
  • 结果
    • 成功创建了包含 path, color, scalenumPetals 属性的对象数组。
    • 渲染出的花瓣拥有了正确的形状、颜色和大小。numPetals 的值已计算备用,将在后续步骤中用于生成完整的花朵。

24-translate-scale

  • 核心概念:transform 的顺序至关重要
    • scale() 操作会影响整个坐标系,包括后续 translate() 的效果。
  • 两种顺序的对比
    • 1. 先 translatescale (推荐)
      • 过程: 首先,将坐标系的原点(0,0)移动到新的位置;然后,围绕这个新的原点进行缩放。
      • 结果: 行为符合直觉。元素被放置在指定位置,并以其自身中心为基准进行缩放。
    • 2. 先 scaletranslate (易出错)
      • 过程: 首先,对整个坐标系进行缩放。例如,放大 2 倍后,坐标系中的一个单位现在代表 2 个像素;然后,在这个已被缩放的坐标系中进行平移。此时 translate(100, 100) 实际上会将对象移动 200x200 像素。
      • 结果: 最终位置通常不符合预期,难以调试。
  • 最佳实践
    • 永远坚持先平移(translate),后缩放(scale)。
    • 这个顺序可以避免因坐标系缩放导致的意外位移,使代码逻辑更清晰、更可预测。

25-what-are-group-elements

目标:将花瓣组合成花朵

  • 我们已经成功为每部电影创建了一片具有正确颜色、大小和形状的花瓣。
  • 下一步是为每部电影创建一朵完整的花。

引入 <g> 元素

  • 为了将属于同一朵花的多个花瓣组合在一起,我们需要使用 SVG 的 <g> 元素。
  • <g> (Group):
    • 它是一个容器元素,本身不渲染任何东西。
    • 作用是将子元素(如多个 <path><text>)组合成一个逻辑单元。
    • 优点: 我们可以对整个 <g> 元素应用变换(如 translate, scale),其内部的所有子元素都会一起移动和缩放,就像在 Adobe Illustrator 中对一个组进行操作一样。

新的 DOM 结构

对于每一朵花,我们的 DOM 结构将如下所示:

<g transform="translate(x, y)"> <!-- 移动整个花朵到网格中的正确位置 -->

    <!-- 花瓣 -->
    <path ... /> <!-- 第1片花瓣 -->
    <path ... /> <!-- 第2片花瓣 -->
    ...          <!-- 花瓣数量由电影的IMDB投票数决定 -->

    <!-- 标题 -->
    <text>电影标题</text>

</g>

使用 D3 创建 <g> 元素

  1. 创建父级分组

    • 我们使用 data().enter().append('g') 的模式,为 flowers 数组中的每一项数据创建一个 <g> 元素。
    • 每个 <g> 元素都会绑定上对应的电影数据。
    • 我们将这个包含 135 个 <g> 元素的选择集存入一个变量,如 g
    const g = svg.selectAll("g").data(flowers).enter().append("g");
    
  2. 创建子元素 (标题)

    • 我们可以对 g 选择集调用 .append('text') 来为每一个组添加一个 <text> 子元素。
    • 重要概念:数据继承 (Data Inheritance)
      • 当你在一个已经绑定了数据的父元素下创建子元素时,这个子元素会自动继承父元素的数据
      • 正因如此,我们可以在创建 <text> 元素时,直接从其绑定的数据 d 中获取 d.title
    g.append("text").text((d) => d.title);
    

这个过程为我们搭建好了每一朵花的基本框架,接下来我们需要往里面填充正确数量的花瓣。

26-passing-a-function-to-data

挑战:动态创建不同数量的子元素

  • 每朵花的花瓣数量是不同的,取决于电影数据。
  • 我们需要一种方法,为每个父级 <g> 元素动态地创建不同数量的 <path> 子元素。

解决方案:嵌套选择 (Nested Selections) 与函数式数据绑定

  • D3 的 .data() 方法非常强大,它不仅能接受一个数组,还能接受一个函数

工作原理

  1. g.selectAll('path')
    • 我们在父级 <g> 选择集上,对每一个 <g> 元素内部执行 selectAll('path')。由于此时 <g> 内部是空的,这会返回一个空选择集。
  2. .data(function(d) { ... })
    • 这是最关键的一步。D3 会遍历父级选择集中的每一个 <g> 元素。
    • 对于每一个 <g>,它会执行这个函数,并将该 <g> 元素上绑定的数据 d (即单个电影的数据) 作为参数传入。
    • 这个函数的返回值必须是一个数组。这个数组的长度,决定了将要为这个特定的 <g> 创建多少个 <path> 子元素。数组中的每一项,都会被绑定到新创建的 <path> 元素上。

示例解析

g.selectAll("path")
  .data(function (parentData) {
    // parentData 是单个电影的数据,比如 { title: 'Minions', numPetals: 5, ... }

    // 我们需要返回一个数组,长度为 parentData.numPetals
    // 数组的每一项是一个对象,包含了该花瓣独特的旋转角度
    return _.times(parentData.numPetals, function (i) {
      return {
        // ...复制父级数据
        rotation: i * (360 / parentData.numPetals),
      };
    });
  })
  .enter()
  .append("path");
  • 对于“小黄人”,parentData.numPetals 为 5,所以 .data() 函数会返回一个长度为 5 的数组。D3 随之会创建 5 个 <path> 元素。
  • 对于“盗梦空间”,parentData.numPetals 为 9,函数返回一个长度为 9 的数组,D3 就创建 9 个 <path> 元素。
  • 每个新创建的 <path> 元素上绑定的数据是 { ..., rotation: ... },既包含了父级的信息,也包含了自己独特的旋转角度。

结果

  • 通过这种“嵌套选择”和“函数式数据绑定”的模式,我们成功地为每朵花创建了正确数量、且数据各异(带有旋转角度)的花瓣。
  • 最终得到的 D3 选择集是一个二维结构:一个包含 135 个数组的数组,每个子数组里是对应花朵的所有花瓣。

27-group-elements-practice

实践:整合代码,绘制完整的花朵

目标: 将前面学到的概念整合起来,在一个单元格中完成从创建分组到绘制花瓣和标题的完整流程。

步骤分解

  1. 创建父级分组 (<g>)

    • 使用 d3.select(svg).selectAll('g').data(flowers).enter().append('g') 创建 135 个分组。
    • 对这个分组选择集 g,立即设置 transform 属性,使用 d.translate 数据来定位每一朵花在网格中的位置。
    const g = d3
      .select(svg)
      .selectAll("g")
      .data(flowers)
      .enter()
      .append("g")
      .attr("transform", (d) => `translate(${d.translate})`);
    
  2. 创建花瓣 (<path>)

    • 渲染顺序: 为了让标题显示在花瓣之上,我们应该先 append 花瓣,再 append 标题。
    • 使用嵌套选择和函数式数据绑定来创建花瓣。
    g.selectAll("path")
      .data((parentData) =>
        _.times(parentData.numPetals, (i) => ({
          ...parentData, // 继承父级数据
          rotate: i * (360 / parentData.numPetals), // 添加自己的旋转角度
        }))
      )
      .enter()
      .append("path");
    // ... 接下来设置花瓣的属性
    
  3. 设置花瓣属性

    • 形状 (d):
      .attr('d', d => d.path)
      
    • 变换 (transform):
      .attr('transform', d => `rotate(${d.rotate}) scale(${d.scale})`)
      
      • 这里我们同时应用了旋转缩放。旋转角度来自刚刚在数据中计算的 d.rotate,缩放比例来自 d.scale
    • 颜色和样式: .attr('fill', ...).attr('stroke', ...).attr('fill-opacity', ...) 等。
  4. 调试与庆祝

    • 通过以上步骤,屏幕上应该已经出现了由许多花瓣组成的花朵网格。
    • 成功: 看到由自己设计的花瓣路径组成的美丽花朵,是值得庆祝的时刻!

28-using-data-to-draw-text

绘制标题 (<text>)

目标: 为每一朵花添加电影标题。

步骤

  1. 追加文本元素

    • 我们回到父级分组选择集 g
    • 由于每朵花只有一个标题,我们直接使用 .append('text'),不需要复杂的 data() 绑定。
    • 文本内容直接从继承的数据 d 中获取 d.title
    g.append("text").text((d) => d.title);
    
  2. 样式和定位

    • 文本截断: 很多电影标题很长。为了美观,可以使用 Lodash 的 _.truncate() 方法将标题截断到一定长度。
    • 居中对齐:
      • 水平居中: .attr('text-anchor', 'middle')。这会让文本的中心点对齐其 x 坐标(默认为 0)。
      • 垂直居中: .attr('dy', '0.35em')。这是一个经验值,可以将文本在垂直方向上大致居中于其 y 坐标。
    • 字体样式: .style('font-size', '0.7em').style('font-style', 'italic') 等。

SVG 文本换行的挑战

  • SVG 的 <text> 元素本身不支持自动换行,实现起来比较麻烦。
  • 替代方案 (实践技巧):
    • 当需要显示较长或需要换行的文本时,一个更简单的方法是使用 HTML 的 <div> 元素。
    • 可以在 SVG 画布的上方覆盖一个绝对定位的 <div>,然后在这个 <div> 中放置标签。<div> 支持所有标准的 CSS 文本样式,包括自动换行,处理起来要容易得多。

Transform scale 的应用位置

  • 在练习中,scale 变换是应用在花瓣路径 (<path>) 上的,而不是父级分组 (<g>) 上。
  • 原因: 这是一个设计决策。
    • 如果 scale 应用在 <g> 上,那么不仅花瓣会被缩放,标题文本也会一起被缩放
    • scale 只应用在 <path> 上,可以保持所有标题的字体大小一致,只改变花瓣的大小,这样通常可读性更好。

29-rotate

深入理解 SVG transformrotate

rotatetransform 属性的另一个重要命令,同样,它也是在操作元素的坐标系。

transform="rotate(degrees x y)"

  • degrees: 旋转的角度,单位是度 (degrees),不是弧度 (radians)。这是一个常见的混淆点,因为 JavaScript 的 Math 函数(如 sin, cos)使用的是弧度。
  • x, y (可选): 指定旋转的中心点
    • 如果省略 xy,旋转默认围绕坐标系的原点 (0,0) 进行。
    • 如果提供了 xy,旋转会围绕指定的 (x,y) 点进行。

经验法则

  • 直接在 rotate 中指定旋转中心 (x,y) 有时会让人困惑。
  • 一个更直观、更可靠的方法是利用 translate 来控制旋转中心。
  • 推荐工作流:
    1. 设计一个围绕 (0,0) 点的形状。
    2. translate: 将整个元素的坐标系移动到你希望的旋转中心位置。
    3. rotate: 在这个新的位置上,围绕其 (0,0) 原点进行旋转。

顺序的重要性

  • translatescale 的组合一样,rotatetranslate 的顺序也会极大地影响最终结果。
  • translate 在前,rotate 在后: transform="translate(tx, ty) rotate(deg)"
    • 先平移,再围绕新的原点旋转。这是我们想要的、可预测的行为。
  • rotate 在前,translate 在后: transform="rotate(deg) translate(tx, ty)"
    • 先将坐标系旋转 deg 度,然后沿着这个已经旋转了的坐标轴进行平移。这会导致元素移动到一个完全意想不到的位置。

最终结论:

始终坚持先 translate,然后再进行 rotate 或 scale。这个简单的规则可以帮你避免绝大多数由 transform 顺序引起的复杂问题。

30-enter-update-exit-pattern

目标:实现数据更新与过滤

  • 我们已经创建了静态的花朵网格。
  • 下一步是增加交互性,允许用户根据类型 (genre) 或评级 (parental guidance) 来过滤花朵。

引入 Enter-Update-Exit 模式

  • 当数据发生变化时,我们不希望完全重绘整个可视化。
  • D3 的 Enter-Update-Exit 模式提供了一种高效的方式来处理数据更新,它精确地操作 DOM,只进行必要的添加、修改和删除。

核心:增强的 .data() 方法

  • .data() 方法是这个模式的核心。当我们再次调用它并传入新数据时,它会进行一次精密的比较。

关键:Key 函数

  • 为了让 D3 知道如何正确地匹配新数据已存在的 DOM 元素,我们需要提供一个 Key 函数
  • Key 函数的作用是为每个数据点和每个 DOM 元素提供一个唯一的标识符 (ID)。
    • 对于数据: 函数会返回每个数据项的唯一 ID (例如 d.movie_id)。
    • 对于 DOM 元素: D3 会查看该元素之前绑定的数据,并使用相同的 Key 函数获取其 ID。
  • 通过比较这些 Key,D3 就知道哪个元素对应哪个新数据。
    • 如果省略 Key 函数,D3 会默认使用数据在数组中的索引作为 Key。这在简单情况下可行,但对于有唯一 ID 的数据,明确提供 Key 函数是最佳实践。

D3 的计算结果

在调用 .data(newData, keyFunction) 之后,D3 会将元素分为三个组:

  1. Update Selection (更新集):
    • 定义: 已存在的 DOM 元素,其 Key 在新数据中也能找到。
    • 处理: 这些元素需要保留,但其属性可能需要根据新数据进行更新。
    • .data() 返回的选择集,其 _groups 属性就代表了更新集。
  2. Exit Selection (退出集):
    • 定义: 已存在的 DOM 元素,其 Key 在新数据找不到
    • 处理: 这些元素是多余的,需要从 DOM 中移除。
    • 可以通过 .exit() 方法获取这个选择集。
  3. Enter Selection (进入集):
    • 定义: 新数据中的条目,其 Key 在已存在的 DOM 元素中找不到
    • 处理: 需要为这些新数据创建新的 DOM 元素。
    • 可以通过 .enter() 方法获取这个选择集。

重要特性: D3 在计算 Update 集时,还会自动重新排序已存在的 DOM 元素,以匹配它们在新数据数组中的顺序。

31-join-vs-enter-update

更新 DOM 的两种方式

D3 提供了两种语法模式来实现 Enter-Update-Exit,两者最终效果完全相同。

1. "旧"方式 (经典 .enter() / .exit() / .merge() 模式)

这种方式将三个阶段明确地分开处理。

  1. 处理 Exit 集:

    • 获取退出集并将其从 DOM 中移除。
    const rects = svg.selectAll("rect").data(newData, keyFn);
    rects.exit().remove();
    
  2. 处理 Enter 集:

    • 获取进入集,创建新元素,并设置只在创建时需要设置一次的静态属性(如固定的填充色)。
    const enterSelection = rects.enter().append("rect").attr("fill", "pink");
    
  3. 处理 Update + Enter 集:

    • 使用 .merge() 方法将更新集 (rects) 和进入集 (enterSelection) 合并成一个选择集。
    • 这个合并后的选择集代表了屏幕上所有最终应该存在的元素。
    • 对这个合并集设置需要根据数据动态更新的属性。
    enterSelection.merge(rects)
        .attr('x', (d, i) => ...)
        .attr('height', d => ...);
    
    
  • 优点: 逻辑清晰,可以对每个阶段进行精细控制。
  • 缺点: 代码比较冗长。

2. "新"方式 (现代 .join() 模式)

D3 v5 引入的 .join() 方法极大地简化了这个过程。

  • 基本用法:

    svg.selectAll('rect')
      .data(newData, keyFn)
      .join('rect') // 这一行完成了 enter, append, exit, remove 和 merge 的所有工作!
      .attr('fill', 'pink')
      .attr('x', (d, i) => ...)
      .attr('height', d => ...);
    
    
    • .join('rect') 会自动处理:
      • 为进入集 append('rect')
      • 为退出集 .remove()
      • 返回一个已经合并好的进入+更新选择集。
    • 之后我们就可以直接链式调用,设置所有属性。
  • 高级用法 (带函数参数):

    • .join() 也可以接受三个函数作为参数,分别对应 enter, update, exit 三个阶段,提供了更精细的控制,同时保持了代码的简洁性。
    .join(
        enter => enter.append('rect').attr('class', 'new'), // 处理 enter
        update => update.attr('class', 'updated'),      // 处理 update
        exit => exit.remove()                           // 处理 exit
     )
    
    
  • 优点: 代码极其简洁、易读。

  • 推荐: 在新项目中,推荐使用 .join() 模式。

32-enter-update-exercise

练习:更新条形图

目标: 分别使用“旧方式”和“新方式”来实现一个可以动态更新数据的条形图。

任务要求

  1. 使用旧方式 (.enter/.exit/.merge):
    • 在一个单元格中,编写代码。
    • 当点击“New data”按钮时,图表应该根据新生成的数据数组进行更新。
    • 正确处理 exit()enter()merge() 流程。
  2. 使用新方式 (.join):
    • 在另一个单元格中,使用 .join() 方法实现相同的功能。
    • 对比两种方式的代码量和可读性。

预期效果: 每次点击按钮,条形图都会平滑(尽管此时还没有过渡效果)地更新,正确地增加、减少或修改条形,以匹配新的数据。

33-enter-update-solution

练习解答与反思

解答步骤

  1. 旧方式实现:
    • 严格按照 rects.exit().remove() -> rects.enter().append(...) -> enter.merge(rects) 的顺序编写。
    • 顺序问题: 在这个经典模式中,执行顺序很重要。通常的最佳实践是:exit,再 enter,最后 mergeupdate。这在处理复杂的嵌套选择时尤为关键,可以避免一些难以察觉的 bug。
  2. 新方式实现:
    • 代码大幅简化,核心就是 .data(...).join('rect') 这一行。
    • 之后直接链式设置所有属性,无需区分是静态属性还是动态属性。

为什么这么麻烦?

  • 在练习中,我们看到每次更新,条形图都是瞬间变化的。
  • 这种瞬间变化的效果,和我们每次都清空 SVG 然后从头重绘看起来并没有区别。
  • 既然如此,为什么还要费这么大劲去学习和使用 Enter-Update-Exit 模式呢?

答案:与过渡 (Transitions) 的结合

  • Enter-Update-Exit 模式的真正威力在于它与 D3 过渡 (Transitions) 的结合。
  • 这个模式为 D3 提供了精确的信息:
    • 哪些元素是新出现的(可以做一个“淡入”或“从下往上长出”的动画)。
    • 哪些元素是要离开的(可以做一个“淡出”或“缩小消失”的动画)。
    • 哪些元素是要更新的(可以平滑地从旧的位置/大小/颜色过渡到新的状态)。

如果没有这个模式,D3 就无法知道新旧元素之间的对应关系,也就无法创建出有意义的、对象持续性 (object constancy) 的动画。我们将在下一部分学习如何加入过渡效果。

34-transitions

什么是 D3 过渡 (Transitions)?

  • D3 过渡是 D3 提供的动画系统,它允许我们将元素的属性和样式从一个状态(State A)平滑地动画到另一个状态(State B)

为什么过渡很重要?

  • 对象恒定性 (Object Constancy): 当数据更新时,动画帮助我们的眼睛追踪元素的变化。用户可以看到一个条形图从一个高度平滑地增长到另一个高度,或者一个点从一个位置移动到另一个位置。这比瞬间的、跳跃式的变化更容易理解,让用户能感知到元素是同一个对象,只是状态改变了。
  • 揭示数据变化: 通过动画,我们可以清晰地看到数据是如何更新的。例如,当两个条形图因为数据排序变化而交换位置时,过渡动画会直观地展示这个“交换”的过程。

D3 过"河"拆"桥"

  • 一个重要的概念:D3 本身不记录数据的历史状态。当你传入新数据时,D3 关注的是新旧状态之间的差异,并告诉你需要做什么(enter, update, exit)来达到新状态。一旦计算完成,它就“忘记”了旧数据。
  • 然而,过渡动画在视觉上“记住”了旧状态,因为它需要知道动画的起始点(旧属性值)和终点(新属性值)。

D3 过渡模块

D3 的过渡系统 (d3-transition) 功能强大,支持:

  • 时间控制: duration() (动画时长), delay() (延迟)。
  • 缓动 (Easing): ease(),控制动画的速度曲线,如先快后慢、弹跳效果等。
  • 链式动画: 在一个动画结束后触发另一个。虽然可以实现,但对于复杂的、多步骤的动画序列,D3 的语法会变得有些繁琐。

何时使用其他动画库?

  • GreenSock (GSAP):
    • 对于需要非常复杂的、精确定时控制的多阶段动画,推荐使用 GreenSock。
    • GSAP 是一个专业的动画平台,与 SVG 和 D3 配合得非常好,尤其适合高级动画场景。

如何使用 D3 过渡

  1. 定义一个过渡 (推荐方式):

    • 创建一个可复用的过渡对象。这能确保所有使用该对象的动画同步进行。
    const t = d3.transition().duration(1000); // 1000毫秒 = 1秒
    
  2. 应用过渡:

    • 在 D3 选择集上调用 .transition() 方法,并可以传入之前定义的过渡对象 t
    selection.transition(t).attr("opacity", 0); // 动画到 opacity: 0
    
  • 工作原理:
    • D3 会将 .transition() 之后设置的属性和样式作为动画的目标状态 (State B)
    • 它会查找元素在调用 .transition() 之前的属性值作为动画的起始状态 (State A)
    • 如果找不到起始状态,它会使用 SVG 元素的默认值。

35-animate-with-transitions-exercise

练习:为条形图更新添加动画

目标: 将我们之前做的条形图更新,从瞬间变化升级为带有动画的平滑过渡。

动画要求

  1. 进入 (Entering) 的条形:
    • 从容器底部(y 在底部,height 为 0)“生长”出来,动画到其最终的高度和 y 位置。
  2. 更新 (Updating) 的条形:
    • 如果条形在数据数组中的位置发生变化,它应该平滑地从旧的 x 位置滑动到新的 x 位置。
  3. 退出 (Exiting) 的条形:
    • 在被移除之前,应该先动画“缩回”到容器底部(yheight 动画到初始状态),然后消失。

代码结构

  • 所有的更新和动画逻辑将被封装在一个名为 updateBars() 的函数中。
  • 每次点击“New data”按钮时,这个函数都会被调用。

提示

  • 使用“新方式” (.join()) 来实现。

  • 由于你需要对 enterexit 阶段进行特定的动画处理,你需要使用 .join()函数参数形式:

    .join(
        enter => { /* ... 在这里处理 enter 动画 ... */ },
        update => { /* ... 如果需要,处理 update ... */ },
        exit => { /* ... 在这里处理 exit 动画 ... */ }
    )
    
    

最终效果: 你将看到一个充满活力的条形图,每次更新时,条形图都会以优雅的动画来展示数据的增、删、改和位置变化。


36-animate-with-transitions-solution

练习解答:实现条形图动画

逻辑分解

  1. 定义过渡:

    • 首先定义一个全局的过渡对象 t,以便所有动画同步。
    const t = d3.transition().duration(750);
    
  2. 使用 .join() 的函数形式:

    svg.selectAll('rect')
       .data(data, d => d)
       .join(
           enter => ..., // 处理进入
           update => update, // 更新集直接返回,不做特殊处理
           exit => ...   // 处理退出
       );
    
    
  3. 处理进入 (Enter):

    • 动画起点 (State A): 在新创建的 rect 上,立即设置动画的起始状态。
      • y: svgHeight (在底部)
      • height: 0
      • x: (d, i) => i * rectWidth (立即设置到正确的 x 位置,避免从左侧滑入的奇怪效果)
      • 其他静态属性如 fill, stroke 等。
    • 返回 enter 选择集: return enter;
    (enter) =>
      enter
        .append("rect")
        .attr("x", (d, i) => i * rectWidth)
        .attr("y", svgHeight)
        .attr("height", 0);
    // ... 其他静态属性
    
  4. 处理退出 (Exit):

    • 动画终点 (State B): 在 exit 选择集上,应用过渡,并设置动画的目标状态。
      • y: svgHeight
      • height: 0
    • 链式调用 .remove(): D3 会确保在过渡动画结束后才移除元素。
    (exit) => exit.transition(t).attr("y", svgHeight).attr("height", 0).remove();
    
  5. 处理进入+更新 (Enter + Update):

    • .join() 返回的合并选择集上,应用过渡,并设置所有元素的最终状态。
    • 对于进入的元素: 它们会从上面设置的 State A 动画到这里的 State B。
    • 对于更新的元素: 它们会从它们当前的状态动画到这里的 State B。
    // 在 join(...) 之后链式调用
    .attr('width', rectWidth) // 静态宽度,不需要动画,放在 transition 前
    .transition(t)
    .attr('x', (d, i) => i * rectWidth) // 动画到新的 x 位置
    .attr('y', d => svgHeight - d)       // 动画到新的 y 位置
    .attr('height', d => d);             // 动画到新的高度
    
    

关键点

  • 分离起始状态和目标状态: 动画的起始属性.transition() 调用之前设置,目标属性之后设置。
  • join() 的函数参数: 让我们能够精确地控制不同阶段(enter, exit)的特定动画行为。
  • .remove() 的时机: 链在 .transition() 之后,保证了 "先动画,后移除"。

通过这几行额外的代码,我们为数据可视化赋予了生命,使其不仅能展示数据,还能生动地讲述数据变化的故事。


37-filtering-updating-data-exercise

最终练习:过滤和更新花朵数据

目标: 实现一个交互式的花朵网格,用户可以通过点击复选框来过滤电影的类型家长指导评级,花朵网格会以平滑的动画响应这些变化。

练习分为两部分

  1. Part 1: 实现过滤与更新 (无动画)
    • 使用 .join() 方法更新花朵(<g> 元素)。
    • 当用户点击筛选器时,filteredFlowers 数据会改变,你的代码应该能正确地增加或移除相应的花朵。
    • 确保 enter 阶段能正确创建出完整的花朵(包含所有花瓣和标题)。
  2. Part 2: 添加动画
    • 在 Part 1 的基础上,加入 D3 过渡效果。
    • 进入 (Entering): 新出现的花朵应该淡入 (fade in)。
    • 更新 (Updating): 位置发生变化的花朵应该平滑地移动到新位置。
    • 退出 (Exiting): 被过滤掉的花朵应该淡出 (fade out),然后再被移除。

Observable Notebook 提供的环境

  • 响应式数据流: Observable 已经为我们处理好了数据的响应式更新。当你点击复选框时:
    1. 一个 filter 函数会运行,生成一个新的 filteredMovies 数组。
    2. filteredMovies 会被传入我们之前写的 createFlowers 函数,生成新的 filteredFlowers 数组。
    3. 你的练习单元格依赖于 filteredFlowers,所以当它变化时,你的代码会自动重新运行。
  • 这极大地简化了开发,我们只需关注 D3 的更新逻辑,而不用操心事件监听和数据流管理。

D3 与 React/Vue

  • React: Shirley 有另一个关于 D3 和 React 的课程。两者可以结合,但有时会因两者都想控制 DOM 而产生冲突。
  • Vue: 讲师个人更喜欢用 Vue 搭配 D3。Vue 的响应式系统与 Observable 类似,并且其内置的动画支持与 D3 配合得很好,这对于数据可视化非常重要。

现在,让我们开始挑战这个综合性的练习,将之前所学全部融会贯通!


38-filtering-updating-data-solution-filter-update

解答 Part 1:实现过滤与更新

核心逻辑

使用 .join() 的函数参数形式来处理父级 <g> 元素(代表每朵花)的进入、更新和退出。

svg
  .selectAll("g")
  .data(filteredFlowers, (d) => d.title) // 使用 title 作为 key
  .join(
    (enter) => {
      /* 在这里处理进入的组 */
    },
    (update) => update, // 更新的组暂时不做特殊处理
    (exit) => exit // 退出的组让 .join 自动移除
  )
  .attr("transform", (d) => `translate(${d.translate})`); // 对合并后的组设置最终位置

enter 阶段构建完整的花朵

  • join 的第一个函数参数接收进入集 (enter)。我们需要在这个阶段构建出每一朵新花朵的完整结构。
  • 注意: 必须在函数末尾 return enter.append('g');,这样 .join() 才能将新创建的组与更新集合并。
enter => {
    const g = enter.append('g'); // 1. 创建父级 <g>

    // 2. 在 <g> 内部创建花瓣 <path>
    g.selectAll('path')
      .data(/* ... 嵌套选择的老朋友 ... */)
      .join('path') // 子元素也可以用 .join()
      .attr(...); // 设置花瓣所有属性

    // 3. 在 <g> 内部创建标题 <text>
    g.append('text')
      .text(...)
      .attr(...); // 设置标题所有属性

    return g; // 4. 返回创建好的 <g> 选择集
}

为什么可以在 enter 阶段设置所有属性?

  • 在这个特定例子中,花朵的内部结构(花瓣数量、颜色、形状)一旦创建就不会再改变。唯一改变的是整朵花是否存在以及它在网格中的位置
  • 因此,我们可以在 enter 阶段一次性地构建出完整的、静态的花朵内部。
  • 而唯一需要动态更新的属性——transform,是在 .join() 返回的合并集上设置的,这样能确保更新的花朵也能移动到新位置。

调试技巧

  • return 的重要性: 忘记在 enter 函数中 return 是一个常见错误。这会导致 .join() 无法合并,后续的链式调用(如设置 transform)会失败。
  • Key 函数: 使用一个唯一的 key(这里是 d.title)是正确更新的关键。

完成这一步后,你的花朵网格应该能正确地响应筛选器,花朵会瞬间出现或消失。


39-filtering-updating-data-solution-animate

解答 Part 2:添加动画

目标: 将 Part 1 的瞬间更新变成平滑的淡入、移动和淡出动画。

1. 定义过渡

在代码顶部定义一个可复用的过渡对象 t

const t = d3.transition().duration(750);

2. 处理进入 (Enter)

  • 动画起点 (State A):

    • enter 函数中,当创建新的 <g> 元素后,立即将其 opacity 设置为 0
    • 为了防止花朵从左上角 (0,0) 飞入,我们还需要立即设置其 transform 属性到其最终位置。
    const g = enter
      .append("g")
      .attr("opacity", 0)
      .attr("transform", (d) => `translate(${d.translate})`);
    
  • 动画终点 (State B):

    • .join() 之后的链式调用中,对合并后的选择集应用过渡,并将 opacity 动画到 1
    // 在 join(...) 之后
    .transition(t)
    .attr('opacity', 1)
    .attr('transform', d => `translate(${d.translate})`); // 同时也处理移动
    
    

3. 处理退出 (Exit)

  • join 的第三个函数参数中处理 exit
  • 动画过程:
    • exit 选择集应用过渡。
    • opacity 动画到 0
    • 链式调用 .remove(),确保花朵在淡出动画结束后才被移除。
(exit) => exit.transition(t).attr("opacity", 0).remove();

最终整合代码结构

const t = d3.transition().duration(750);

svg
  .selectAll("g")
  .data(filteredFlowers, (d) => d.title)
  .join(
    // === ENTER ===
    (enter) => {
      const g = enter
        .append("g")
        // 动画起点
        .attr("opacity", 0)
        .attr("transform", (d) => `translate(${d.translate})`);

      // ... 在 g 内部构建花瓣和文本 ...

      return g;
    },

    // === UPDATE ===
    // 直接返回即可,移动和淡入动画在 join 之后统一处理
    (update) => update,

    // === EXIT ===
    (exit) => exit.transition(t).attr("opacity", 0).remove()
  )
  // === 对 ENTER + UPDATE 集合统一处理动画 ===
  .transition(t)
  // 动画终点
  .attr("opacity", 1)
  .attr("transform", (d) => `translate(${d.translate})`);

现在,当你点击筛选器时,花朵会以优雅的方式淡入、淡出和移动,提供了极佳的用户体验,完美地展示了 D3 数据驱动动画的强大能力。恭喜你,完成了这个复杂的综合练习!

40-shape

超越简单的 XY 定位

  • 我们已经学习了如何使用 D3 Scales 将原始数据映射到 XY 坐标。
  • 但有时,我们需要绘制更复杂的图形,如折线图、饼图、面积图等。手动计算这些图形的 SVG <path> 字符串会非常繁琐和困难。

d3-shape 模块

  • d3-shape 是 D3 提供的一个强大的模块,专门用于生成复杂形状的路径字符串 (<path>d属性)
  • 工作原理: 你提供给它一个数据数组,它会返回一个可以直接用于 <path> 元素的路径字符串。
  • 主要功能:
    • d3.line(): 从一系列点数据生成折线。
    • d3.area(): 生成面积图。
    • d3.arc(): 生成弧形,是构建饼图和环形图的基础。
    • d3.symbol(): 生成各种预设的符号(如十字、圆形、方形等),常用于散点图。
    • 以及更多其他形状...

示例:绘制折线图

  1. 原始数据: 一个包含日期和价格的对象数组。

    const data = [
      { date: new Date("2022-01-01"), value: 100 },
      { date: new Date("2022-01-02"), value: 105 },
      // ... more data points
    ];
    
  2. 创建 line generator:

    • 配置一个 d3.line() 生成器,告诉它如何从每个数据点中获取 x 和 y 值。
    const lineGenerator = d3
      .line()
      .x((d) => xScale(d.date)) // 使用 x 比例尺
      .y((d) => yScale(d.value)); // 使用 y 比例尺
    
  3. 生成路径字符串:

    • 将数据数组传入生成器。
    const pathString = lineGenerator(data);
    // pathString 会是类似 "M10,100L20,95..." 的字符串
    
  4. 渲染:

    • 将生成的字符串应用到 <path> 元素上。
    svg
      .append("path")
      .attr("d", pathString)
      .attr("stroke", "blue")
      .attr("fill", "none");
    

D3 示例资源

  • D3 的官方文档通常会链接到 Observable Notebooks 中的示例。
  • D3 的官方 Observable 账号 (observablehq.com/@d3) 包含了数百个按模块分类的示例,是学习和探索 D3 功能的宝贵资源。现在你已经熟悉了 Observable,可以轻松地深入研究这些示例的代码。

41-hierarchy

d3-hierarchy 模块

  • d3-hierarchy 专门用于处理和布局层级结构数据(或树状数据)。
  • 应用场景:
    • 树状图 (Tree diagrams)
    • 矩形树图 (Treemaps)
    • 打包图/圆堆图 (Circle packs)
    • ...以及其他表示层级关系的图表。

工作流程

  1. 原始数据:

    • 通常是一个嵌套的对象,每个对象有 children 数组属性,形成树状结构。
    const hierarchicalData = {
      name: "root",
      children: [
        { name: "A", children: [...] },
        { name: "B" }
      ]
    };
    
    
  2. 创建层级布局 (Hierarchy Layout):

    • 使用 d3.hierarchy() 将原始数据转换成 D3 可以理解的层级结构。
    • 然后应用一个具体的布局算法,如 d3.tree()d3.pack()
    const root = d3.hierarchy(hierarchicalData);
    const treeLayout = d3.tree().size([width, height]);
    treeLayout(root); // 这会修改 root 对象,为其所有后代节点添加 x, y 坐标
    
  3. 返回结果:

    • 布局函数会直接修改你传入的层级对象(非原始数据),为每个节点添加布局所需的属性(如 x, y, r (半径), width, height 等)。
    • 原始数据保持不变。
  4. 渲染:

    • 你可以遍历处理后的层级数据 (root.descendants()) 来绘制每个节点。
    • 可以使用 d3-shaped3.link() 来方便地绘制节点之间的连接线。

d3-hierarchy 极大地简化了复杂层级可视化的计算过程,你只需关注数据的准备和最终的渲染。


42-force-layout

d3-force 模块

  • d3-force 是一个用于实现力导向图 (Force-Directed Graph) 的物理模拟引擎。
  • 它可能是 D3 最强大、最有趣的布局模块之一,常用于展示网络关系。

与其他布局模块的区别

  1. 直接修改数据: 与 d3-shaped3-hierarchy 不同,d3-force直接修改传入的原始节点数据数组,为其添加 x, y, vx, vy 等属性。
  2. 迭代计算: 它不是一次性计算出最终位置,而是通过**数千次迭代(ticks)**来逐步模拟力的作用,直到系统达到一个相对稳定的平衡状态。

####核心概念

  • 节点 (Nodes): 代表图中的实体,通常是一个对象数组。
  • 链接 (Links): 代表节点之间的关系,通常是一个包含 sourcetarget 的对象数组,指向节点。
  • 力 (Forces): 定义了节点之间如何相互作用。你可以组合多种力来实现复杂的效果。

常用力 (Forces)

  • forceLink: 链接力。使被链接的节点相互吸引,保持在一定距离内。
  • forceManyBody (Charge): 多体力/电荷力。
    • 负值 (默认): 节点相互排斥,像同性电荷一样,避免重叠。
    • 正值: 节点相互吸引。
  • forceCenter: 向心力。将所有节点整体吸引到画布的中心。
  • forceCollide: 碰撞力。为每个节点设置一个半径,防止它们重叠。

工作流程

  1. 创建模拟 (Simulation):

    const simulation = d3.forceSimulation(nodes); // 传入节点数组
    
  2. 添加力:

    simulation
      .force(
        "link",
        d3.forceLink(links).id((d) => d.id)
      ) // 链接力
      .force("charge", d3.forceManyBody().strength(-30)) // 排斥力
      .force("center", d3.forceCenter(width / 2, height / 2)); // 向心力
    
  3. 监听 tick 事件:

    • 模拟引擎在内部不断进行计算。在每一次迭代(tick)后,节点的位置都会被更新。
    • 我们需要监听 tick 事件,并在回调函数中更新 DOM 元素(如 SVG 圆形和线条)的位置。
    simulation.on("tick", () => {
        // 在这里更新节点和链接的位置
        nodeElements.attr("cx", d => d.x).attr("cy", d => d.y);
        linkElements.attr("x1", d => d.source.x) ...;
    });
    
    
  • 这个过程会产生一个动态的、看起来非常“有机”的布局动画,直到所有力达到平衡。

43-force-examples

D3 力导向布局的创意应用

力导向布局不仅限于传统的节点-链接图,它的灵活性使其可以被创造性地应用于各种场景,产生生动、自然的效果。

示例 1: 《汉密尔顿》音乐剧可视化

  • 背景漂浮的粒子: 使用力导向布局让粒子在背景中随机、有机地移动。
  • 按歌曲分组: 使用 forceXforceY 这两种力,可以为不同的节点组指定不同的焦点(x, y 坐标)。这里,同一首歌的歌词(节点)被吸引到同一个焦点,从而形成分组。

示例 2: 疫情传播模拟

  • 模拟移动: 使用力导向布局模拟人们从家移动到中心区域的过程。力的作用使得移动路径不是僵硬的直线,而是带有碰撞和避让的、更自然的效果。
  • 自定义避障: 通过自定义力或巧妙地应用 forceCollide,可以实现粒子(人)在移动过程中避开建筑物(其他房子)的效果。

示例 3: 《卫报》无家可归者报道

  • 放射状布局: Nadieh Bremer 使用了 forceRadial,将节点按层次分布在不同的同心圆上。
  • 动态迁移: 讲师使用力导向布局制作了节点从一个群组迁移到另一个群组的动画。
    • 力的迭代计算特性(ticks)使得动画过程看起来非常平滑和有机,一帧帧地微调位置,效果远胜于简单的线性插值动画。

关键力量

在这些创意应用中,最常用的两种力是:

  1. 定位到焦点: forceX, forceY, forceRadial - 将节点吸引到特定的 x 坐标、y 坐标或圆形路径上。
  2. 避免碰撞: forceCollide - 防止元素重叠。

结论: 当你需要创造出看起来更“有机”、“自然”或者带有物理模拟感觉的可视化或动画时,d3-force 是一个非常强大的工具。


44-force-practice-graph-calculation

练习目标:使用力导向布局排列电影花朵

最终目标: 根据电影共有的类型 (genre) 来对花朵进行布局。如果两部电影有相同的类型,它们之间应该有引力。同时,我们希望这个布局能与之前的筛选器配合使用。

性能考量

  • 力导向布局计算量大: 它需要进行数千次迭代计算,非常消耗 CPU。
  • SVG vs. Canvas:
    • 当需要渲染大量(成百上千)的元素,并且有复杂动画时,SVG 的性能可能会成为瓶颈,导致动画卡顿(janky)。
    • 在这种情况下,HTML5 Canvas 是更好的选择,因为它在处理大量图形时性能更高。
    • 本练习中,我们仍然使用 SVG,但在旧设备上可能会体验到卡顿。

为了使用 d3-force,我们需要将电影数据转换成节点 (nodes)链接 (links) 数组。

  1. 节点 (Nodes):
    • 我们的图中有两种类型的节点:
      • 电影花朵节点: 每个 flower 对象都是一个节点。
      • 类型节点 (Genre Nodes): 每个电影类型(如 "Action", "Comedy")本身也是一个节点。
  2. 链接 (Links):
    • 当一部电影属于某个类型时,就在该电影花朵节点和对应的类型节点之间创建一条链接。
    • link 对象结构: { source: genreNode, target: flowerNode }

createGraph 函数逻辑

这个辅助函数完成了数据转换工作:

  1. 遍历所有过滤后的电影 (filteredFlowers)。
  2. 将每个 flower 对象推入 nodes 数组。
  3. 遍历当前电影的所有 genres
  4. 对于每个 genre
    • 检查该 genre 是否已经作为一个节点存在。如果不存在,则创建一个新的 genre 节点。
    • 创建一个新的 link,连接当前的 genre 节点和 flower 节点。
  5. 返回一个包含 nodeslinksgenres 数组的 graph 对象。

这个 graph 对象就是我们接下来在力导向布局中要使用的数据源。


45-force-practice-using-d3-force-position

练习实现:使用 D3 Force 定位花朵

这是一个综合练习,我们将结合之前的组件创建和新的力导向布局。

1. 创建 DOM 元素

在应用力布局之前,我们首先需要创建所有的 SVG 元素。

  • 链接 (Links):
    • 使用 graph.links 数据创建 <line> 元素。
    • 给它们一个类名(如 class="link")以便选择。
    • 此时只设置静态样式(如 stroke, opacity),不设置位置。
  • 花朵 (Flowers):
    • 复用之前的代码,使用 graph.nodes 数据创建花朵 (<g> 元素及其子元素)。
    • 关键: 此时不要设置 transform 属性,让它们默认堆叠在 (0,0)
  • 类型标签 (Genres):
    • 使用 graph.genres 数据创建 <text> 元素作为类型标签。
    • 同样,此时不设置位置。

2. 配置力导向模拟 (Force Simulation)

这是练习的核心部分。

const simulation = d3.forceSimulation();
  • 指定节点 (Nodes):
    • 模拟的节点应该是所有参与布局的元素,即花朵节点类型节点的并集。
    • simulation.nodes(_.union(graph.nodes, graph.genres))
  • 添加力 (Forces):
    • 链接力 forceLink:
      • d3.forceLink(graph.links)
      • 这会使被链接的电影花朵和类型标签相互吸引。
    • 碰撞力 forceCollide:
      • d3.forceCollide().radius(d => ...)
      • 为每个节点设置一个碰撞半径,防止它们重叠。半径可以根据花朵的 d.scale 动态计算。
      • 这里选择 forceCollide 而不是 forceManyBody 是因为它能更直接地防止重叠。
    • 向心力 forceCenter:
      • d3.forceCenter(width / 2, height / 2)
      • 将整个布局吸引到画布中心。

3. 在 tick 事件中更新位置

  • 这是将模拟计算结果应用到视图的关键一步。
simulation.on("tick", () => {
  // 更新花朵的位置
  flower.attr("transform", (d) => `translate(${d.x}, ${d.y})`);

  // 更新类型标签的位置
  genres.attr("transform", (d) => `translate(${d.x}, ${d.y})`);

  // 更新链接线条的端点位置
  link
    .attr("x1", (d) => d.source.x)
    .attr("y1", (d) => d.source.y)
    .attr("x2", (d) => d.target.x)
    .attr("y2", (d) => d.target.y);
});
  • 在每个 tickd3-force 都会更新节点数据对象上的 xy 属性。我们的 tick 回调函数读取这些新值,并更新对应 SVG 元素的位置属性。

完成以上步骤后,运行代码,你将看到花朵和类型标签在力的作用下动态地寻找自己的位置,最终形成一个按类型聚合的、有机的网络图。当应用筛选器时,数据会更新,力导向模拟会重新开始,动态地调整到新的布局。

46-bar-chart-visualization-html

目标:将 Observable Notebook 代码迁移到 index.html

本节的目标是将我们在 Observable 中原型化的代码,特别是简单条形图电影花朵,转换成一个独立的 index.html 文件。

迁移条形图

步骤分解

  1. HTML 结构 (index.html):
    • 创建基本的 HTML 骨架。
    • 引入库: 使用 <script> 标签引入 D3 和 Lodash 的 CDN 链接。
    • 创建容器: 直接在 HTML 中创建必要的元素,如 <svg> 容器和 <button>
      <div id="chart-container">
        <button id="update-btn">New Data</button>
        <svg id="bar-chart" width="500" height="100"></svg>
      </div>
      <script src=".../d3.v7.min.js"></script>
      <script src=".../lodash.min.js"></script>
      <script src="./script.js"></script>
      
  2. JavaScript 代码 (script.js or <script> tag):
    • 将 Observable 中的 JavaScript 代码复制过来。
    • 进行必要的修改:
      • 移除 Observable 特有语法: 删除 html\\...\ md\...\\ 这样的标记模板字面量。
      • 修改选择器 (Selector):
        • 在 Observable 中,svg 这个变量直接引用了 DOM 节点。
        • index.html 中,我们需要使用选择器字符串来让 D3 找到对应的元素。
        • 旧 (Observable): d3.select(svg)
        • 新 (HTML): d3.select("#bar-chart")
      • 绑定事件: 确保事件监听器(如 .on("click", ...))正确地绑定到了 HTML 中的按钮上。

两种创建 DOM 的方式

  1. 在 HTML 中预先定义 (推荐):

    • 直接在 index.html 文件中写好 <svg><button> 标签。
    • 优点: 结构清晰,关注点分离 (HTML 负责结构,JS 负责行为)。这是从 Observable 迁移过来最直接、最简单的方式。
  2. 使用 D3 动态创建:

    • 在 HTML 中只定义一个根容器,如 <div id="app"></div>
    • 在 JavaScript 中使用 D3 的 .append() 方法来创建 <svg><button> 并将它们添加到根容器中。
    const container = d3.select("#app");
    const svg = container.append("svg").attr(...);
    const button = container.append("button").text("New Data");
    
    
    • 优点: 更接近于在 React/Vue 等框架中的工作方式,所有 UI 都由 JavaScript 控制。
    • 注意: 当将 D3 选择集 (如 svg) 传递给需要 DOM 节点的地方时,需要使用 .node() 方法来获取底层的 DOM 元素,例如 d3.transition(t).selection(svg.node())

结论: 将 Observable 代码迁移到标准 Web 环境非常直接,主要工作是调整 DOM 的创建和选择方式。


47-flower-visualization-html-refactoring-the-code

迁移电影花朵可视化 (带力导向布局)

这个迁移过程比条形图要复杂一些,因为它依赖于外部数据和多个辅助函数。

步骤分解

  1. 加载数据:

    • index.html 中,我们不能像 Observable 那样直接访问预加载的数据。
    • 需要使用 D3 的数据加载功能(如 d3.json()d3.csv())来异步获取数据。
    • 将所有依赖数据的代码都包裹在 .then() 回调中
    d3.json("movies.json").then((movies) => {
      // 所有处理和渲染电影数据的代码都在这里
    });
    
  2. 复制和整理辅助函数与常量:

    • 从 Observable Notebooks 中找到并复制所有必要的辅助函数和常量:
      • calculateData(): 将电影原始数据转换为花朵可视化数据。
      • createGraph(): 将花朵数据转换为力导向图所需的 nodeslinks
      • topGenres, petalColors, petalPaths: 定义好的常量数组。
    • 将这些代码粘贴到你的 JavaScript 文件中,放在 d3.json() 的外部,使其成为全局可用的函数和变量。
  3. 重构和连接:

    • d3.json(...).then(movies => { ... }) 回调函数内部,按顺序调用这些函数:
      1. 使用 calculateData(movies) 生成 flowers 数据。
      2. 使用 createGraph(movies, flowers) 生成 graph 数据。
      3. 将这个 graph 数据用于创建 DOM 元素和配置力导向模拟。
    • 调试: 这个过程可能会因为缺少某个变量或函数而出错。使用浏览器的开发者工具控制台查看错误信息,逐步追踪并补全缺失的部分。例如,可能会发现 calculateData 内部依赖了 topGenres,就需要把 topGenres 也复制过来。

移除不必要的部分

  • 过滤器: 由于我们暂时不实现复杂的交互式过滤功能,可以移除与过滤器相关的代码。
  • 网格布局: calculateGridPositions 函数也不再需要,因为我们现在使用力导向布局来定位花朵。

经过这一系列的复制、粘贴和重构,我们最终将一个复杂的多部分可视化项目成功地从 Observable 原型环境迁移到了一个独立的网页中。这展示了 Observable 作为一个快速原型开发工具的强大之处,以及将原型转化为最终产品的清晰路径。


48-flower-visualization-html-adding-the-width-height

完成迁移:设置尺寸并处理响应式

1. 定义和应用画布尺寸

  • 问题: 在上一步中,我们的代码因为缺少 widthheight 变量而报错。力导向布局的 forceCenter 和 SVG 本身的尺寸都需要这些值。

  • 解决方案:

    1. 在 JavaScript 的开头定义 widthheight 变量。为了让它充满整个窗口,我们可以使用 window.innerWidthwindow.innerHeight

      const width = window.innerWidth;
      const height = window.innerHeight;
      
    2. 使用 D3 选择 SVG 元素,并设置其 widthheight 属性。

      d3.select("svg").attr("width", width).attr("height", height);
      
    3. 更新 forceCenter 的参数,使其居中于新的画布尺寸。

      .force("center", d3.forceCenter(width / 2, height / 2))
      
      

完成这些修改后,SVG 画布将占据整个浏览器窗口,力导向布局也会正确地将所有元素吸引到窗口中心,整个可视化就成功地在 index.html 中运行起来了。

2. 实现响应式 (Responsive Design)

如何让可视化在浏览器窗口大小改变时也能自适应?

  • 思路:

    1. 监听事件: 监听 windowresize 事件。
    2. 在事件回调中更新:
      • 重新获取窗口的 widthheight
      • 更新 SVG 元素的 widthheight 属性。
      • 找到已经存在的力导向模拟对象 (simulation)。
      • 更新模拟中对尺寸敏感的力,主要是 forceCenter
        simulation.force("center", d3.forceCenter(newWidth / 2, newHeight / 2));
        
      • 重启模拟: 调用 simulation.alpha(1).restart() 来“唤醒”模拟,使其根据新的中心点重新调整布局。
  • 代码结构:

    function handleResize() {
      // ... 获取新尺寸,更新SVG ...
      // ... 更新力,重启模拟 ...
    }
    
    window.addEventListener("resize", handleResize);
    
    • 这个事件监听器最好放在数据加载的回调函数内部,以确保 simulation 对象已经存在。

这是一个通用的实现响应式 D3 可视化的基本模式,具体细节会根据图表类型有所不同,但核心思想是监听 resize -> 更新尺寸 -> 更新布局/比例尺 -> 重绘/重启模拟


49-wrapping-up

总结与展望

Q&A: 获取开放数据

  • 兴趣驱动: 与其在数据集中寻找灵感,不如从你真正好奇或 passionate 的主题出发,然后主动去寻找或收集相关数据。这会让你更有动力完成项目。
    • 数据来源可以是:Google 搜索、API、手动录入(如《汉密尔顿》歌词项目)。
  • 新闻机构的数据集:
    • 许多优秀的数据新闻机构会公开他们报道中使用的数据。
    • FiveThirtyEight (fivethirtyeight/data): 拥有大量高质量、清洗过的数据集。
    • The Pudding: 知名的视觉叙事网站,也会在 GitHub 上分享数据。
    • ProPublica, New York Times 等。
  • 政府数据:
    • data.gov 曾是重要来源,虽然有所变动,但其存档或类似资源可能存在。
    • 美国人口普查局 (US Census) 等官方机构网站是可靠的数据源。
  • 从报道中挖掘: 阅读数据驱动的新闻报道,查看他们的“方法论 (methodology)”部分,通常会说明数据来源。

Q&A: D3 Force 与 Canvas

  • 性能: 对于大量的元素和动画,Canvas 性能优于 SVG。
  • D3 支持: d3-force 对 Canvas 有很好的支持。许多官方示例就是用 Canvas 实现的。
  • 工作流程:
    1. d3-force 的配置和使用方式完全相同,因为它只负责计算 x, y 坐标。
    2. 不同之处在于渲染: 你需要在 simulation.on("tick", ...) 的回调中,使用 Canvas 2D API (如 context.moveTo, context.arc, context.stroke) 来绘制节点和链接。
    3. 关键点: 在每一帧(每个 tick)绘制之前,必须调用 context.clearRect(0, 0, width, height) 来清空画布,否则会留下拖影。

后续步骤

  • Workshop 最终代码: 讲师会更新代码仓库,提供 workshop 中所有练习在 index.html 环境下的最终实现版本。
  • 其他 Frontend Masters 课程:
    • 《Building Custom Data Visualizations》: 重点不在于编码,而在于数据可视化的设计思维。如何分析数据、构思视觉编码、进行原型设计。这是比编码更重要的一环。
    • 《Data Visualization for React Developers》: 专为 React 开发者设计的 D3 入门,重点讲解如何在 React 环境中使用 D3,两者如何协同工作。

最后,感谢大家参与本次 workshop,希望你们在这段旅程中收获满满!