D3.js 实现数据可视化

D3 是一个数据驱动的可视化 js 库,有很高的灵活性,相对应,使用难度和学习成本也更高。

本篇笔记包含 D3 操作元素、SVG、比例尺、坐标轴、事件等基本概念,以及柱状图、散点图、热度图、地理路径、树形图的绘制方式。

是 freeCodeCamp 课程 “数据可视化” 中的笔记,学习了基础用法,并结合认证项目进行实践,加深理解与应用。

操作 HTML 元素

选择和添加元素

  • 选中元素: select('body')
  • 选择所有匹配元素: select('li')
  • 附加元素: append('h1')
  • 获取或设置文本: text() 和 text('Learning D3')
d3.select('body')
  .append('h1')
  .text('Learning D3')

使用 D3 中的数据

  • 先选择对应的元素,比如想基于数据创建 h2 ,就先选中 h2
  • 给元素设置数据集:data(dataset) ; 其中 data() 方法会解析数据集,任何链接在后面的方法都会为数据集中的每个对象运行一次
  • 把数据集和选择元素比较,用于补齐缺少的元素: enter() ;如果有 9 个数据,即可通过 append 去创建 9 个元素。
  • 基于数据去设置文本: text((d) => d)
const dataset = [12, 31, 22, 17, 25, 18, 29, 14, 9];
// 在这行下面添加代码
d3.selectAll('h2')
  .data(dataset)
  .enter()
  .append('h2')
  .text((d) => `${d} USD`)

设置属性和样式

  • 设置内联 CSS 样式: `style('font-family', 'verdana') 
  • 基于数据去设置内联 CSS 样式: `style('color', d => d < 20 ? 'red' : 'green') 
  • 设置 HTML 属性,包括 class: attr('class', 'bar'); 也支持基于数据通过回调函数去设置
  • class 可以用于 :hover 改变样式等操作,写在 css 的样式优先于 attr 中的样式
d3.select('body').selectAll('div')
  .data(dataset)
  .enter()
  .append('div')
  .attr('class', 'bar')
  .style('margin', '2px')
  .style('height', (d) => `${d * 10}px`)

D3 中的 SVG

SVG 是 Scalable Vector Graphics 的缩写,缩放不会被像素化,用于制作常见的几何图形。

宽高单位

通过以上操作 HTML 元素的方法来操作 svg,width 和 height 支持 attr 和或 style 定义,需注意 width 和 height 没有单位,它定义在默认缩放下的宽度,而无论怎么缩放,元素的宽高比永远是 5:1 。

const w = 500;
const h = 100;

const svg = d3.select("body")
  .append('svg')
  .attr('width', w)
  .attr('height', h)
// 或
const svg = d3.select("body")
  .append('svg')
  .style('width', w)
  .style('height', h)

rect 形状

  • rect  用于创建方形
  • rect 这类元素必须添加在 svg 元素内
  • 通过 x 和 y 定义在 svg 中的位置,y 坐标是从 SVG 画布的顶端开始测量的,而不是从底端。可设置 y 值为容器高度减去柱子高度,使其翻转过来,正向显示
  • 用 fill 属性着色
svg.selectAll("rect")
   .data(dataset)
   .enter()
   .append("rect")
   .attr("x", (d, i) => i * 30)
   .attr("y", (d, i) => h - 3 * d)
   .attr("width", 25)
   .attr("height", (d, i) => 3 * d)
   .attr("fill", "navy");

text 元素

  • 需要 x 和 y 属性来指定在 svg 上的位置
  • text 元素通过 text() 方法填充文本
svg.selectAll("text")
   .data(dataset)
   .enter()
   .append("text")
   .text((d) => d)
   .attr("x", (d, i) => i * 30)
   .attr("y", (d, i) => h - (3 * d) - 3);

title 元素

  • 用于悬停提示文本,类似 html 的 text 属性
  • title 元素通过 text() 方法填充文本
.append('title')
.text(d => d)

circle 形状

  • 用于创建圆形
  • 三个主要的属性。 cx 和 cy 属性是坐标,定义图形的中心位置。 r 属性定义半径大小。
  • cy 坐标跟 y 一样是从顶部,可以使用容器高度减去坐标位置,使其正向显示

比例尺基本用法

https://d3js.org/d3-scale

比例尺可帮助布局数据,用于缩放,将大量数据放到较小的显示区域。

线性比例尺 scaleLinear

https://d3js.org/d3-scale/linear

对于线性缩放(通常使用于定量数据),使用 D3 的 scaleLinear() 方法创建比例尺,返回一个 scale 函数。使用 scale 函数,调用函数时传入参数 50 ,控制缩放比例。

const scale = d3.scaleLinear(); // 在这里创建比例尺
const output = scale(50); // 调用 scale,传入一个参数

默认情况下,比例尺使用一对一关系(identity relationship), 即输入值直接映射为输出值。

定义域与值域

比例尺可以使用更灵活的配置:

  • 定义域:比例尺的输入信息,通过 domain() 设置,假设有一个数据集值的范围为 50 到 480
  • 值域:输出信息,通过 range() 方法设置,沿着 SVG 上的 x 比例尺映射这些点,假设在 10 单位和 500 单位之间
scale.domain([50, 480]);
scale.range([10, 500]);

通过 padding 在散点图和 SVG 边缘之间添加空隙: 想象 x 比例尺是一条从 0 到 500 (SVG 宽的值)的水平直线。 在 range() 方法中包含 padding 使散点图沿着这条直线从 30 (而不是 0)开始,在 470 (而不是 500)结束。

const w = 500;
const padding = 30;
scale.range([padding, w - padding]);

查找数据集的最大最小值

设置域的时候,想找到数据集中的最小值和最大值,可使用 min() 和 max() 来返回这些值。

一维数组中找:

const exampleData = [34, 234, 73, 90, 6, 52];
d3.min(exampleData)
d3.max(exampleData)

找到二维数组的最大值和最小值的例子:

const locationData = [[1, 7],[6, 3],[8, 3]];
const minX = d3.min(locationData, (d) => d[0]);

min() 和 max() 方法在设置比例尺时十分有用,如使用 max() 方法创建 x 和 y 比例尺。为 y 坐标设置 range 时,一般大的值是第一个参数,小的值是第二个参数,使其可保持从下往上的顺序。

const w = 500;
const h = 500;

const padding = 30;

// 创建 x 和 y 比例尺
const xScale = d3.scaleLinear()
  .domain([0, d3.max(dataset, (d) => d[0])])
  .range([padding, w - padding]);

const yScale = d3.scaleLinear()
  .domain([0, d3.max(dataset, (d) => d[1])])
  .range([h - padding, padding]);

为了不要顶住比例尺的起始端或末端,设置 domain 时可适当加调整一些数值:

.domain([d3.min(dataset, (d) => d[1]) - 20, d3.max(dataset, (d) => d[1])] + 20)

比例尺应用与设置

比例尺就像操作函数一样,将 x 和 y 的原数据值变为适应 SVG 并可在 SVG 上正确渲染的值。

比例尺函数为 circle 图形设置坐标属性值:

.attr('cx', d => xScale(d[0]))
.attr('cy', d => yScale(d[1]))
.attr('r', 5)

比例尺函数为 rect 图形设置坐标值:

.attr('x', (d) => xScale(d[0]))
.attr('y', (d) => yScale(d[1]))

rect 图形设置 height 高度时就把总高度减去 y 比例尺位置:

.attr('height', (d) => h - padding - yScale(d[1]))

其他比例尺

https://d3js.org/d3-scale

支持多种比例尺,可到文档查看,按需选用

时间比例尺 scaleTime

https://d3js.org/d3-scale/time

通过 scaleTime 创建时间比例尺,类似于线性缩放比例尺,domain 设置为时间。

const xScale = d3
  .scaleTime()
  .domain([d3.min(dataset, (d) => new Date(d[0])), d3.max(dataset, (d) => new Date(d[0]))])
  .range([padding, w - padding]);

刻度比例尺 scaleBand

https://d3js.org/d3-scale/band

指定均分的刻度比例尺,比如创建 12 个月的刻度:

const yScale = d3
  .scaleBand()
  .domain(Array.from({ length: 12 }, (_, i) => i))
  .range([h - padding, padding]);

如所有年份需要作为刻度:

const xScale = d3
  .scaleBand()
  .domain(dataset.map((d) => d.year))
  .range([padding, w - padding]);

通过 bandwidth 可以获取一个刻度对应的长度:

.attr('width', (d) => xScale.bandwidth(d.year))
.attr('height', (d) => yScale.bandwidth(d.month))

坐标轴

https://d3js.org/d3-axis

可将比例尺转换为可阅读的坐标轴,适用于大多比例尺。

将比例尺渲染为坐标轴

  • 分别通过 axisLeft() 和 axisBottom() 方法渲染 x 和 y 坐标轴。
  • 可以使用 g 元素(英文中组 group的缩写)。 比例尺只是一条直线,是一个简单的图形,所以可以用 g 。
  • 使用 transform 属性将比例尺放置在 SVG 的正确位置上,否则会沿着 SVG 的边缘渲染,不可见
  •  transform 属性使用 translate ,根据给定的值移动整组
  • x 比例尺作为参数被传递给 call() 方法
const xAxis = d3.axisBottom(xScale);
const yAxis = d3.axisLeft(yScale);;

svg.append("g")
  .attr("transform", "translate(0," + (h - padding) + ")")
  .call(xAxis);

svg.append("g")
  .attr("transform", "translate(" + (padding) + ", 0)")
  .call(yAxis);

坐标轴格式化

可设置 tickFormat 传入函数,使坐标轴按特定形式格式化。例如,使用逗号分割千位:

axis.tickFormat(d3.format(",.0f"));

d3.format 指定格式化函数 https://d3js.org/d3-format

const f = d3.format(".1f");
for (let i = 0; i < 10; ++i) {
  console.log(f(0.1 * i));
}

也可配合 d3.timeFormat 时间格式化 https://d3js.org/d3-time-format

axis.tickFormat(d3.timeFormat("%b %d"));

d3.format 和 d3.timeFormat 创建的函数也可以被直接调用,所以 tickFormat 也可以直接写函数。

function formatMinutes(seconds) {
  const m = Math.floor(seconds / 60);
  const s = seconds % 60;
  return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}
axis.tickFormat(formatMinutes);

坐标点过滤

如使用刻度比例尺时,所有年份需要作为刻度,这样刻度太多了,根本看不清。此时可以使用 tickValues 来只选取 10 的倍数作为刻度。

刻度比例尺的 domain 可以拿到所有值。

const xAxis = d3.axisBottom(xScale).tickValues(xScale.domain().filter((y) => y % 10 === 0));

事件与交互

事件监听

https://d3js.org/d3-selection/events

选择的元素可通过 on 监听事件,第一个返回元素是鼠标事件,第二个是当前触发事件元素的数据。

交互效果

如渲染 rect 时,监听 mouseover 和 mouseout 让提示渲染及隐藏:

svg  
  .selectAll('rect')  
  .data(data)  
  .enter()  
  .append('rect')
  // ... 此处省略 x y width height fill 等属性设置
  .on('mouseover', (e, d) => {
       d3.select('#tooltip')
        .style('display', 'block')
        .style('left', xScale(new Date(d[0])) + 'px')
        .style('bottom', padding + 20 + 'px')
        .attr('data-date', formatDate(new Date(d[0])))
        .text(formatDate(new Date(d[0])) + '\n' + d[1]);
    })
    .on('mouseout', () => {
       d3.select('#tooltip').style('display', 'none');
    });

基础图形绘图思路

先定好 svg 的宽高、padding等要素,选中 svg 对象并设置。

柱状图

1、拿到数据,定义 x 和 y 比例尺,渲染创建 x 和 y 坐标轴
2、绘制矩形柱子: svg 对象选中 rect 对象,应用数据,追加 rect 元素
3、rect 元素根据 x 和 y 比例尺渲染 x 和 y 的位置,渲染固定的宽度,渲染高度为总高度减去 y 比例尺位置

散点图

1、拿到数据,定义 x 和 y 比例尺,渲染创建 x 和 y 坐标轴
2、绘制圆形散点: svg 对象选中 circle 对象,应用数据,追加 circle 元素
3、circle 元素根据 x 和 y 比例尺渲染 cx 和 cy 位置,渲染固定的半径

热度图

1、定义 x y 两个维度的刻度比例尺,渲染创建 x 和 y 坐标轴
2、定义 value 不同范围的不同颜色
3、将 rect 元素放到 x y 轴对应的位置,宽高通过刻度比例尺获取每个刻度的距离,将填充颜色设置为对应颜色

绘制地理路径

地理路径生成器,geoPath,接收一个给定的 GeoJSON 几何体或要素对象,并生成 SVG 路径数据字符串。

https://d3js.org/d3-geo

geoPath 创建地理路径生成器

geoPath,使用默认设置创建新的地理路径生成器。

const path = d3.geoPath(projection); // for SVG

参数 projection ,默认为 null 。可指定与投影一起使用,通常是 D3 内置的 地理投影

TopoJSON 与 GeoJSON

GeoJSON 是一种对各种地理数据结构进行编码的格式,基于JavaScript对象表示法(JSON)的地理空间信息数据交换格式。许多地图可视化库和框架(如Leaflet和D3.js)都支持直接使用GeoJSON作为地图数据输入

GeoJSON支持多种几何类型,包括点(Point)、线(LineString)、多边形(Polygon)、多点(MultiPoint)、多线(MultiLineString)、多多边形(MultiPolygon)和几何集合(GeometryCollection)。

TopoJSON 库由 D3 作者编写,是一种对地理拓扑进行编码的方式,是对 GeoJSON 的扩展。

npm i topojson-client

可以使用 TopoJSON 库在 TopoJSON 和 GeoJSON 间转化。

import * as topojson from 'topojson-client'

topojson.feature(topology, object) 用来返回 GeoJSON 的 Feature 结构对象数组,topology 参数是一个 TopoJSON 对象,object 参数是 topology 参数中想要返回的对象。

const countiesFeature = topojson.feature(counties, counties.objects.counties).features

topojson.mesh(topology[, object[, filter]]) 用来返回相连的几何体的边,是多线(MultiLineString)的形式。可想象它返回一张“网(mesh)”,共用边只保留一份,方便对整个网进行操作,如上色等。

const countiesMesh = topojson.mesh(counties, counties.objects.states, (a, b) => a !== b);

渲染 path

要显示多个要素,请将它们组合成一个要素集合,文档示例为:

svg.append("path")
    .datum({type: "FeatureCollection", features: features})
    .attr("d", d3.geoPath());

以上 topojson.mesh 方法返回的 MultiLineString 正是 GeoJSON 要素集合,通过 datum 传入渲染:

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

或者使用多个路径元素,文档示例为:

svg.selectAll()
  .data(features)
  .join("path")
    .attr("d", d3.geoPath());

以上 topojson.feature 返回的 features 正适合这种方式来渲染:

svg.selectAll().data(countiesFeature).join('path').attr('d', path).attr('class', 'county');

树形图

树形图通过递归地将区域划分为矩形,根据每个节点的相关值进行划分。

https://d3js.org/d3-hierarchy/treemap

创建树形图布局

首先是配置 d3.treemap 创建新的树状图布局:

// 创建树形图布局  
const treemap = d3.treemap();  
treemap.size([w, h]);  
treemap.paddingInner(1);

生成根节点

然后便是调用 d3.hierarchy 从树形结构的原生 js 对象,生成 treemap 使用的分层结构根节点 root。支持排序、求和等操作。

// 对数据排序和求和  
const root = d3.hierarchy(data);  
root.sum((d) => d.value);  
root.sort((a, b) => b.height - a.height || b.value - a.value);

计算树形图布局

然后就是把根节点 root 传到树状图布局,进行计算。

// 计算树形图布局  
treemap(root);

以上计算后,会在 root 及其后代上分配以下属性:

  • node.x0 - 矩形的左边缘
  • node.y0 - 矩形的上边缘
  • node.x1 - 矩形的右边缘
  • node.y1 - 矩形的底边

渲染叶子节点

通过 root.leaves() 获取所有叶子节点。可以对它的所有叶子节点进行渲染,渲染的宽高和位置通过以上属性计算。

// 获取所有叶子节点  
const leaves = root.leaves();  
// 渲染叶子节点
svg  
  .selectAll('rect')  
  .data(leaves)  
  .enter()  
  .append('rect')  
  .attr('x', (d) => d.x0)  
  .attr('y', (d) => d.y0)  
  .attr('width', (d) => d.x1 - d.x0)  
  .attr('height', (d) => d.y1 - d.y0)  
  .attr('fill', 'red')