D3 饼图

d3
Created 9/29/2024
Updated 10/1/2024

D3 饼图

静态图

参考
  • 解读的官方样例为 Pie chart
  • 对代码进行注释解读的 Notebook 是这个
  • 复现可以查看该网页,完整代码可以查看 这里

官方样例的构建流程概述:

  • 读取数据
  • 构建比例尺
  • 创建一个饼图角度生成器 pie 对数据进行转换
  • 创建一个扇形生成器 arc 绘制饼图内的扇形形状
  • 为每个扇形添加标注信息(其中使用了另一个半径较小的扇形生成器)

其中需要注意的一点是在创建 svg 时,需要对 viewBox 在水平和垂直方向进行偏移,将视图区域向左移动 width/2 向上移动 height/2,让坐标点 (0, 0) 位于视图区域的中心,这样就可以让饼图的圆心位于视图中心,相关代码如下:

js
// 创建 svg
// 在容器 <div id="container"> 元素内创建一个 SVG 元素
// 返回一个选择集,只有 svg 一个元素
const svg = d3
  .select("#container")
  .append("svg")
  .attr("width", width)
  .attr("height", height)
  // 通过 viewBox 将视图区域向左移动 width/2 向上移动 height/2,让 (0, 0) 位于视图区域的中心
  // 这样就可以让饼图的圆心位于视图中心
  .attr("viewBox", [-width / 2, -height / 2, width, height])
  .attr("style", "max-width: 100%; height: auto; font: 10px sans-serif;");

另外在该示例中,有一个小技巧值得学习,在创建颜色比例尺(为饼图中的每个扇形设置不同颜色)时,为了可以缩小可视化图整体的色差(让饼图看起来更和谐美观),会基于原来的配色方案,在更窄的色谱范围里进行采样,得到一系列色差较少的颜色值,相关代码如下:

js
/**
 *
 * 构建比例尺
 *
 */
// 设置颜色比例尺
// 为不同扇形设置不同的配色
// 使用 d3.scaleOrdinal() 排序比例尺 Ordinal Scales 将离散型的定义域映射到离散型值域
// 具体参考官方文档 https://d3js.org/d3-scale/ordinal
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-scale#排序比例尺-ordinal-scales
const color = d3.scaleOrdinal()
    // 设置定义域范围
    // 各扇形的名称,即 18 种年龄段
    .domain(data.map(d => d.name))
    // 设置值域范围
    // 这里使用 d3.quantize(interpolator, n) 方法根据指定的 interpolator 插值函数,返回 n 个等间隔的均匀采样值(一个数组)
    // 该方法是由 d3-interpolate 模块提供的插值器(该模块还内置一些其他的插值器),具体介绍可以查看官方文档 https://d3js.org/d3-interpolate/value#quantize
    // 💡 关于插值器的介绍,可以查看这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-transition#插值器
    // 所使用的 interpolator 插值函数是 t => d3.interpolateSpectral(t * 0.8 + 0.1) 💡 参数 t 的取值范围是  [0, 1]
    // 其中 d3.interpolateSpectral() 是一种配色方案,它会根据传入的参数(范围在 [0,1] 之间)计算出相应的颜色值
    // 它可以在发散型的光谱(从暖色系过渡到冷色系)中选取配色,具体参考官方文档 https://d3js.org/d3-scale-chromatic/diverging#interpolateSpectral
    // 💡 这里是基于参数 t 进行调整,由于 t * 0.8 + 0.1 的范围是在 0.1 到 0.9 之间,所以在更窄的色谱范围中进行采样,得到一系列色差较少的颜色值
    // 所采样的数量是与 data.length 数据项的数量一样
    // 得到一系列的颜色值(一个数组),并对其进行反向 reverse 排序
    // 最终得到一系列从冷色系到暖色系的颜色值
    .range(d3.quantize(t => d3.interpolateSpectral(t * 0.8 + 0.1), data.length).reverse());

由于饼图各部分是围绕圆心分布的(而不是简单的沿横向或竖向分布的),所以在各个扇形添加文本标注时,还需要使用扇形生成器,调用其方法 arc.centroid() 获取相应扇形中点的坐标值,一般以此作为标注文本的位置,相关代码如下:

js
  /**
   *
   * 添加标注信息
   *
   */
  // 该变量表示每个扇形的标注文本相对于圆心的距离
  // 是大饼图的外半径的 0.8 倍
  // 相当于使用原始数据集,可以绘制出一个半径更小的饼图(用于定位标注文本),而它的各个扇形部分和大的饼图是一致的
  const labelRadius = arc.outerRadius()() * 0.8;

  // A separate arc generator for labels.
  // 创建另一个 arc 扇形生成器,在后面 👇 会借助它计算出各扇形的中点位置
  const arcLabel = d3.arc()
      // 该饼图的内半径和外半径是一样的,所以小饼图相当于一个圆(只有圆周一圈,而没有面积)
      .innerRadius(labelRadius)
      .outerRadius(labelRadius);

  // 为各扇形添加文本标注信息
  // 创建一个元素 <g> 作为容器
  // Create a new arc generator to place a label close to the edge.
  // The label shows the value if there is enough room.
  svg.append("g")
      .attr("text-anchor", "middle") // 设置文本对齐方式,居中对齐
    .selectAll() // 返回一个选择集,其中虚拟/占位元素是一系列的 <text> 文本元素,用于为各扇形的添加文本标注
    .data(arcs) // 绑定数据,每个文本元素 <text> 对应一个扇形数据
    .join("text") // 将元素绘制到页面上
      // 通过设置 CSS 的 transform 属性将文本元素「移动」到相应的扇形的中点
      // 各扇形的中点是使用另一个扇形生成器 arcLabel 的方法 arcLabel.centroid(d) 计算而得的
      .attr("transform", d => `translate(${arcLabel.centroid(d)})`)
      // ...

动效

饼图更新

如果饼图的数据需要动态更新时,可以考虑为它添加切换补间动画,利用物体恒存 object constancy 让用户更容易地留意和理解数据是如何变化的

参考

官方提供了一个示例 Arc tween,对如何实现弧形切换补间动画进行详细讲解

可以查看这个 Notebook 对以上示例中的代码和注释进行了翻译和进一步解读

复现可以查看该网页,完整代码可以查看 这里

另外官方还有一些关于扇形切换动画的精彩示例:

实现补间动画主要用到的 API 是 transition.attrTween,有一篇教程 Working with Transitions 对过渡 transition 有详细的讲解

变体

环形图

参考
  • 解读的官方样例为 Donut chart
  • 对代码进行注释解读的 Notebook 是这个
  • 复现可以查看该网页,完整代码可以查看 这里

绘制环形图的大部分代码和静态饼图都是相同的,主要区别是内半径的不同,相关代码如下

js
// 该变量用于计算环形内外半径
// 取宽度和高度两者之中较小值的一半
const radius = Math.min(width, height) / 2;

// ...

/**
 *
 * 绘制饼图内的扇形形状
 *
 */
// 使用 d3.arc() 创建一个 arc 扇形生成器
// 扇形生成器会基于给定的数据生成扇形形状
// 调用扇形生成器时返回的结果,会基于生成器是否设置了画布上下文 context 而不同。如果设置了画布上下文 context,则生成一系列在画布上绘制路径的方法,通过调用它们可以将路径绘制到画布上;如果没有设置画布上下文 context,则生成字符串,可以作为 `<path>` 元素的属性 `d` 的值
// 具体可以参考官方文档 https://d3js.org/d3-shape/arc
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-shape#扇形生成器-arcs
const arc = d3.arc()
    // 💡 设置内半径,由于这里所传递的参数不为 0,所以生成环状扇形(如果参数为 0 则生成完整扇形)
    .innerRadius(radius * 0.67)
    // 设置外半径
    .outerRadius(radius - 1);
// 💡 环形的宽度就是内外半径之差 (radius - 1) - (radius * 0.67) = 0.33 * radius - 1

环形图矩阵

参考
注意

从实用角度来看,使用环形图/扇形图矩阵并不利于读者进行数据比较,更推荐采用标准化的堆叠条形图

该示例在页面绘制多个环形图,由于每个环形图都使用单独的一个 <svg> 容器进行渲染,所以大部分代码与绘制单个环形图类似

推荐

💡 因为 <svg> 默认是 inline 元素(布局所采用的 CSS 样式是 display: inline-block),所以页面会自动将多个 <svg> 进行排版,最终效果是多个环形图不重叠地展示在页面上(如果只采用一个 <svg> 来渲染所有的环形图,则需要(繁琐的坐标计算)手动在它们定位到 svg 画布中)

如果需要在页面绘制多个图形时(常见于矩阵类型的可视化图形),除了常见的使用单个 <svg> 作为画布,也可以考虑采用该方案(即使用多个 svg 来渲染),充分利用 DOM 元素的 CSS 特性进行简单的排版

该可视化图形用于演示如何使用方法 selection.each 创建一个上下文/环境,可以同时访问选择集中各元素的父节点以及子元素的信息,相关代码如下:

js
// 绘制一系列环形图
const svg = wrapper.selectAll(".pie") // 返回一个选择集,其中虚拟/占位元素是一系列的 <svg class="pie"> 元素,用于分别绘制一个个环形图
    // 绑定数据 data(它基于各州的总人数降序排列,即人数较多的州先绘制)
    .data(data.sort((a, b) => b.sum - a.sum))
  .enter().append("svg") // 将元素绘制到页面上
    // 为每个 <svg> 添加 CSS class "pie" 类名(对应上面选择集的操作)
    .attr("class", "pie")
    // 使用方法 selection.each(func) 为选择集中的每个元素都调用一次函数 func 执行相应的操作
    // 在调用入参函数 func(d, i, nodes) 时,会依次传递三个参数:
    // * 当前所遍历的元素所绑定的数据 datum `d`
    // * 当前所遍历的元素在分组中的索引 index `i`
    // * 当前分组的所有节点 `nodes`
    // 具体参考 d3-selection 模块的官方文档 https://d3js.org/d3-selection/control-flow#selection_each
    // 这里调用方法 multiple() 在每个 <svg> 元素里绘制一个环形图
    // 💡 通过该方法可以创建一个上下文,可以同时访问到当前所遍历的元素,以及它的子元素
    // 💡 由于在回调函数 func 里 this 就指向当前所遍历的元素,可以通过 `d3.selection(this)` 创建(仅包含当前所遍历元素)的选择集,然后再通过方法 `selection.selectAll()` 获取子元素(其中 selection 表示通过 `d3.selection(this)` 所创建的选择集)
    .each(multiple)
  .select("g"); // 最后选择每个 <svg> 里的 <g> 容器(由于它经过了位置调整,便于后面为每个环形图添加文本注释)

// 绘制环形图的核心代码
// 传入的参数 `d` 是(包含一系列 svg 元素的)选择集当前遍历的元素所绑定的数据
// 它是一个对象 {state: string, sum: number, ages: array}
// 而函数内的 this 指向当前所遍历的元素,即相当于 nodes[i]
function multiple(d) {
  // 💡 通过 d3.selection(this) 创建的选择集,包含的元素视为父元素
  // 💡 以及 selection.selectAll() 创建的选择集,包含的元素视为子元素(或后代元素)
  // 💡 在该函数(环境/上下文)里可以同时访问父元素和子元素/后代元素

  // 返回的选择集所包含的元素是(在 <svg> 元素里所添加的)<g> 元素
  const svg = d3.select(this) // 💡 首先创建一个选择集,它包含当前所遍历的 <svg> 元素
      // 设置该 <svg> 元素的尺寸,它的宽和高都是环形图的直径
      .attr("width", r * 2)
      .attr("height", r * 2)
    // 创建一个 <g> 容器
    // 💡 这里会引起选择集变化,选中的元素是新增的(子元素)<g> 容器
    .append("g")
      // 通过 CSS transform 将容器 <g> 进行移动(向右移动半径长度 r,向下移动半径长度 r)
      // 让环形图的圆心绘制在 svg 的中心位置
      .attr("transform", `translate(${r},${r})`);

  // 在 <g> 容器里绘制一个环形图
  svg.selectAll(".arc") // 返回一个选择集,其中虚拟/占位元素是一系列的 <path class="arc"> 元素,用于分别环形图的各个扇形部分,💡 这里选中的是后代元素
      // 绑定数据
      // 调用饼图角度生成器 pie,对数据集 d.ages 进行转换处理
      // 计算出每个数据点(该州的各个年龄段)所对应的扇形的相关信息(主要是起始角和结束角)
      .data((d) => pie(d.ages))
    .enter().append("path") // 将元素绘制到页面上
      .attr("class", "arc") // 为各个 <path> 路径元素添加 CSS class "arc" 类名(对应上面选择集的操作)
      // 调用扇形生成器 arc
      // 同时还为扇形生成器设置内外半径,由于内半径不为 0 所以生成环状扇形(如果参数为 0 则生成完整扇形)
      // 由于扇形生成器并没有调用方法 area.context(canvasContext) 设置画布上下文
      // 所以调用扇形生成器 arc 返回的结果是字符串
      // 该值作为 `<path>` 元素的属性 `d` 的值
      .attr("d", arc.outerRadius(r).innerRadius(r * 0.6))
      // 设置颜色,不同扇形对应不同的颜色
      // 其中 d 是所绑定的数据(由饼图角度生成器所生成的),d.data 是原始数据点,所以 d.data.age 就是该扇形所对应的年龄段(名称)
      // 再通过颜色比例尺 color() 可以得到该扇形的相应颜色值
      .style("fill", (d) => color(d.data.age));
}

另外还有一个类似的样例,使用同样的数据绘制了环形图矩阵,但是不同在于这些环形图的面积是不同的,它们之间的面积大小比例与相应各州人口数量比例一致,这样就可以通过各个环形图的面积来直观地比较各个州的人口。

注意

使用面积这个图像的属性作为变量来展示数据时,需要特别注意面积与它的边常常是平方关系的(例如圆与半径,正方形与边长),所以边与数据是呈现幂函数关系的

使用面积大小来表示各州总人口数量的相关代码如下

js
/**
 *
 * 绘制环形图
 * 每个环形图都使用单独的一个 <svg> 容器进行渲染
 * 💡 由于 <svg> 默认是 inline 元素,所以页面会自动将多个 <svg> 进行排版,最终效果是多个环形图不重叠地展示在页面上
 * 如果只采用一个 <svg> 来渲染所有的环形图,则需要(繁琐的坐标计算)手动在它们定位到 svg 画布中
 *
 */
// 💡 构建一个比例尺,用于将各州的人口总数映射为环形图的半径
// ⚠️ 由于要实现环形的**面积**大小比例与相应各州人口数量比例一致(线性关系),而面积与半径 r 是呈平方关系
// 💡 所以各州的人口总数与环形图的半径呈幂函数关系
// 💡 使用 d3.scaleSqrt 构建一个幂比例尺(幂为 0.5)进行人口数量与半径之间的映射
// 💡 这里将比例尺的定义域范围设置为 [0, sumMax] 其中上限 sumMax 是各州人口总数中的最大值,值域范围是 [0, 220] 即最大的环形半径是 220 像素
// 💡 方法 d3.scaleSqrt() 是 d3.scalePow().exponent(0.5) 的简写形式
// 具体可以参考官方文档 https://d3js.org/d3-scale/pow#scaleSqrt
const radius = d3.scaleSqrt([0, d3.max(data, (d) => d.sum)], [0, 220]);

// 使用 d3.arc() 创建一个 arc 扇形生成器
// 扇形生成器会基于给定的数据生成扇形形状
// 调用扇形生成器时返回的结果,会基于生成器是否设置了画布上下文 context 而不同。如果设置了画布上下文 context,则生成一系列在画布上绘制路径的方法,通过调用它们可以将路径绘制到画布上;如果没有设置画布上下文 context,则生成字符串,可以作为 `<path>` 元素的属性 `d` 的值
// 具体可以参考官方文档 https://d3js.org/d3-shape/arc
// 或这一篇笔记 https://datavis-note.benbinbin.com/article/d3/core-concept/d3-concept-shape#扇形生成器-arcs
const arc = d3.arc()
  // 设置扇形的间隔半径(根据间隔角度结合间隔半径算出间隔弧长)
  .padRadius(50);

// 在页面创建一个 <div> 作为最外层容器,包裹一系列的 <svg> 元素
const wrapper = d3.select("#container")
                  .append("div");

// 绘制一系列环形图
const svg = wrapper.selectAll(".pie") // 返回一个选择集,其中虚拟/占位元素是一系列的 <svg class="pie"> 元素,用于分别绘制一个个环形图
    // 绑定数据 data(它基于各州的总人数降序排列,即人数较多的州先绘制)
    .data(data.sort((a, b) => b.sum - a.sum))
  .enter().append("svg") // 将元素绘制到页面上
    // 为每个 <svg> 添加 CSS class "pie" 类名(对应上面选择集的操作)
    .attr("class", "pie")
    // 使用方法 selection.each(func) 为选择集中的每个元素都调用一次函数 func 执行相应的操作
    // 在调用入参函数 func(d, i, nodes) 时,会依次传递三个参数:
    // * 当前所遍历的元素所绑定的数据 datum `d`
    // * 当前所遍历的元素在分组中的索引 index `i`
    // * 当前分组的所有节点 `nodes`
    // 具体参考 d3-selection 模块的官方文档 https://d3js.org/d3-selection/control-flow#selection_each
    // 这里调用方法 multiple() 在每个 <svg> 元素里绘制一个环形图
    // 💡 通过该方法可以创建一个上下文,可以同时访问到当前所遍历的元素,以及它的子元素
    // 💡 由于在回调函数 func 里 this 就指向当前所遍历的元素,可以通过 `d3.selection(this)` 创建(仅包含当前所遍历元素)的选择集,然后再通过方法 `selection.selectAll()` 获取子元素(其中 selection 表示通过 `d3.selection(this)` 所创建的选择集)
    .each(multiple)
  .select("g"); // 最后选择每个 <svg> 里的 <g> 容器(由于它经过了位置调整,便于后面为每个环形图添加文本注释)

// 绘制环形图的核心代码
// 传入的参数 `d` 是(包含一系列 svg 元素的)选择集当前遍历的元素所绑定的数据
// 它是一个对象 {state: string, sum: number, ages: array}
// 而函数内的 this 指向当前所遍历的元素,即相当于 nodes[i]
function multiple(d) {
  // 💡 通过 d3.selection(this) 创建的选择集,包含的元素视为父元素
  // 💡 以及 selection.selectAll() 创建的选择集,包含的元素视为子元素(或后代元素)
  // 💡 在该函数(环境/上下文)里可以同时访问父元素和子元素/后代元素

  // 💡 基于该州的人口总数,使用幂比例尺比例尺 radius 进行映射,计算出对应环形图的半径大小
  const r = radius(d.sum);

  // 返回的选择集所包含的元素是(在 <svg> 元素里所添加的)<g> 元素
  const svg = d3.select(this) // 💡 首先创建一个选择集,它包含当前所遍历的 <svg> 元素
      // 设置该 <svg> 元素的尺寸,它的宽和高都是环形图的直径
      .attr("width", r * 2)
      .attr("height", r * 2)
    // 创建一个 <g> 容器
    // 💡 这里会引起选择集变化,选中的元素是新增的(子元素)<g> 容器
    .append("g")
      // 通过 CSS transform 将容器 <g> 进行移动(向右移动半径长度 r,向下移动半径长度 r)
      // 让环形图的圆心绘制在 svg 的中心位置
      .attr("transform", `translate(${r},${r})`);

  // 在 <g> 容器里绘制一个环形图
  svg.selectAll(".arc") // 返回一个选择集,其中虚拟/占位元素是一系列的 <path class="arc"> 元素,用于分别环形图的各个扇形部分,💡 这里选中的是后代元素
      // 绑定数据
      // 调用饼图角度生成器 pie,对数据集 d.ages 进行转换处理
      // 计算出每个数据点(该州的各个年龄段)所对应的扇形的相关信息(主要是起始角和结束角)
      .data((d) => pie(d.ages))
    .enter().append("path") // 将元素绘制到页面上
      .attr("class", "arc") // 为各个 <path> 路径元素添加 CSS class "arc" 类名(对应上面选择集的操作)
      // 调用扇形生成器 arc
      // 同时还为扇形生成器设置内外半径,由于内半径不为 0 所以生成环状扇形(如果参数为 0 则生成完整扇形)
      // 由于扇形生成器并没有调用方法 area.context(canvasContext) 设置画布上下文
      // 所以调用扇形生成器 arc 返回的结果是字符串
      // 该值作为 `<path>` 元素的属性 `d` 的值
      .attr("d", arc.outerRadius(r).innerRadius(r * 0.6))
      // 设置颜色,不同扇形对应不同的颜色
      // 其中 d 是所绑定的数据(由饼图角度生成器所生成的),d.data 是原始数据点,所以 d.data.age 就是该扇形所对应的年龄段(名称)
      // 再通过颜色比例尺 color() 可以得到该扇形的相应颜色值
      .style("fill", (d) => color(d.data.age));
}

Copyright © 2024 Ben

Theme BlogiNote

Icons from Icônes