D3 散点图
提示
本文的大部分代码需要依赖 D3.js 库,在写本文时 D3.js 版本为 v7
静态图
参考
- 解读的官方样例为 Scatterplot
- 对代码进行注释解读的 Notebook 是这个
- 复现可以查看该网页,完整代码可以查看 这里
官方样例的构建流程概述:
- 数据导入与转换
- 构建比例尺和坐标轴(对象)
- 创建容器并设置宽高和
viewBox
属性 - 绘制横纵坐标轴
- 绘制数据点的标注信息
- 绘制数据点
在官方样例中,将绘制静态散点图的核心代码封装为一个通用的函数 function Scatterplot(data, {})
具体的解读可以查看这个 Notebook
但是该函数实际上通用性并不足,因为它会使用一些 Observable 平台专有的功能/方法,所以我基于它进行简化(虽然降低了代码的通用性),然后在一般的前端 js 环境复现,完整代码可以查看 这里
响应性
响应性是指图表随着页面大小的改变,会自动进行大小的缩放,以适应页面的尺寸。
对于上述所复现的散点图,我将 svg 元素的宽高固定为 width = 640, height = 400
,所以当页面发生改变时,散点图并不会响应页面大小变化,在这一节会探讨如何实现该功能。
实现图表的随页面大小响应式变化功能我想到两种方案:
- 通过设置 svg 元素的 CSS 样式来实现响应性
- 当页面大小改变时,基于页面的宽高计算出散点图的具体元素的尺寸,并对散点图进行重绘
方案一
通过监听 resize
事件(页面调整大小操作),基于页面的大小来设置 <svg>
元素的宽高,即通过整体缩放来实现响应性
说明
实际上是基于容器的 #container
的大小来设置 <svg>
元素的宽高
而容器的 #container
的宽高由 CSS 进行约束,所以它是自动随页面大小而变化的
#container {
width: 100%;
height: 70vh;
}
所以从某种角度而言,也是根据页面的大小来设置 <svg>
元素的宽高
缺陷
该方案是对整个 SVG 进行缩放,在页面较小时,图表里的元素的可视性可能较差
方案二
同样监听 resize
事件(页面调整大小操作),但是并不是单纯地对 SVG 整体进行缩放,而是基于页面的尺寸对图表进行重绘来实现响应性。这样即使将页面调整为较小尺寸时,依然可以保持坐标轴和数据点等元素的可视性。
说明
该方案通过重绘实现图表的响应性,同时保证图中元素的可视性,但是可能很耗费性能,特别是在页面缩放较频繁或数据量较大时
为了改善该方案的性能损耗,所以针对图表的不同部分采用不同的绘制方案
- 对于坐标轴等构成较为简单且变化较少的「固定」的元素可以完全重绘
- 而对于数据点等数量变化较大的元素,一般是改变其属性以移动这些元素到正确的新位置,而不是完全重绘它们
此外还可以为变化的元素添加过渡动效,这样可以让图表的重绘显得更「顺滑」
值得留意的一点是,在该方案中对坐标轴先整个移除,再进行重新生成坐标轴对象。
说明
其实,坐标轴对象支持重新设置比例尺,只需要使用方法 axis.scale(scale)
即可重新配置坐标轴的比例尺,然后再重新调用该坐标轴对象绘制坐标轴。
xAxis.scale(newXScale)
xAxisGridContainer.call(xAxis)
这个方法并不会在图中绘制出重叠的新旧两个坐标轴,而是基于原有的坐标轴进行调整,效率还会比上面的方案更高。因为在调用坐标轴对象绘制坐标轴时,首先会删除不必要的刻度线和刻度值,再在页面绘制新增的刻度线和刻度值(也是采用 D3 的数据绑定的 enter-update-exit 模式,具体可以参考源代码)。
但是该方法并不能直接适用于该实例,因为在缩放页面时,除了要更新坐标轴,还需要更新网格线。要在调用坐标轴对象更新完坐标轴后,再 remove 原有的网格线,并基于新的坐标轴绘制新的网格线。
交互
散点图常见交互的实现
Tooltip
参考
散点图可以看到数据的离散和集中程度,但是要在图中查看具体某个数据点的确切的值往往比较难,这时候可以添加一个交互,当鼠标悬停到特定的数据点时,弹出 tooltip 提示框来显示该数据点的具体信息。
最简单的方法是在 SVG 元素(一般数据点采用 <circle>
元素来绘制)内添加 <title>
元素,这就类似为 <img>
元素添加 alt
属性。这样当鼠标悬停在数据点上,就会显示一个浏览器默认的提示框。
以下是实现 tooltip 的核心代码
/**
* 为数据点添加 tooltip 提示框
*/
// 通过在 <circle> 内添加 <title> 元素实现鼠标悬浮时显示提示框
pointsContainer.selectAll('circle')
.append('title') // 在 <circle> 元素内添加一个 <title> 元素
.text((d) => { // 入参数据继承自父级 <circle> 所绑定的数据
return `mpg: ${X[d]}, hp: ${Y[d]}` // 设置 tooltip 内容
})
// 同时也在标注元素 <text> 内添加 <title> 元素,也可以实现鼠标悬浮时显示提示框
labelsContainer.selectAll("text")
.append('title')
.text((d) => {
return `mpg: ${X[d]}, hp: ${Y[d]}`
})
注意
需要注意的是 <title>
元素适用范围,它只能在含有 fill
属性(属性值不能为 none
)的图形元素内起作用,才能够显示 tooltip
所以除了添加 <title>
元素,还需要在散点图的点 <circle>
元素添加 fill
属性
const pointsContainer = svg
.append("g")
.attr("fill", "steelblue") // 为数据点设置填充色
.attr("stroke", "steelblue")
.attr("stroke-width", 1.5);
辅助线
提示框 Tooltip 弹出时可能会遮盖图表的一部分,而且在图表中移动鼠标时会造成弹出框的频繁显示和消失,这对于观众是一种干扰。
为了显示具体点的信息,另一种交互方式是为散点图添加十字辅助线,这样就可以在鼠标移动到具体点时,更准确地(基于辅助线)读取当前(坐标轴)的值。
以下是实现辅助线的核心代码
/**
* 构建辅助线,并在坐标轴显示相应的值
*/
// 辅助线容器
const guidesContainer = svg
.append("g")
.style("opacity", 0) // 默认不显示
.attr("stroke", "black") // 设置辅助线的颜色
.attr("stroke-dasharray", 5); // 将辅助线设置为虚线
const guidesParallel = guidesContainer.append("line"); // 水平辅助线
const guidesVertical = guidesContainer.append("line"); // 垂直辅助线
// 同时在坐标轴上相应的位置显示(当前鼠标所在的位置)所对应的值
const xValue = svg
.append("text")
.attr("transform", `translate(0, ${height - 14})`) // 在横坐标显示的值定位到容器的底部
.attr("font-size", 14) // 设置字体大小
.style("opacity", 0); // 默认不显示
const yValue = svg.append("text").attr("font-size", 14).style("opacity", 0);
// 监听鼠标在散点图上的移动相关事件
svg
.on("mouseover", function () {
// 当鼠标进入散点图时,将辅助线及坐标轴上相应的值显示出来
// 设置不同的透明度进行视觉上的优化
guidesContainer.style("opacity", 0.3);
xValue.style("opacity", 0.5);
yValue.style("opacity", 0.5);
})
.on("mouseout", function () {
// 当鼠标离开散点图时,隐藏辅助线及坐标轴上相应的值
guidesContainer.style("opacity", 0);
xValue.style("opacity", 0);
yValue.style("opacity", 0);
})
.on("mousemove", function (event) {
// 当鼠标在散点图上移动时,获取鼠标的位置(相对于 svg 元素)
const [xPos, yPos] = d3.pointer(event);
// 设置横坐标轴的值
xValue
.attr("x", xPos - 14) // 该值在(横坐标轴容器里)横向位置移动(和鼠标的位置一样)xPos - 14
.attr("fill", "black") // 设置字体颜色
// 调用比例尺的 invert() 方法,通过位置(range 值域)反向求出对应的(domain 定义域)值
// 由于反向求出的值可能具有多位小数,根据原始数据的精度,保留一位小数
.text(d3.format(".1f")(xScale.invert(xPos)));
// 设置纵坐标的值
yValue
.attr("y", yPos) // 该值在(纵坐标轴容器里)纵向位置移动(和鼠标的位置一样)yPos
.attr("fill", "black")
.text(d3.format(".0f")(yScale.invert(yPos)));
// 设置水平辅助线的位置
guidesParallel
// x1 和 y1 属性设置线段的起点,x2 和 y2 属性设置线段的终点
.attr("x1", xScale(xDomain[0]) - insetLeft)
.attr("y1", yPos)
.attr("x2", xScale(xDomain[1]) + insetRight)
.attr("y2", yPos);
// 设置垂直辅助线的位置
guidesVertical
.attr("x1", xPos)
.attr("y1", yScale(yDomain[0] - insetTop))
.attr("x2", xPos)
.attr("y2", yScale(yDomain[1] + insetRight));
});
由于在上一步为散点图设置了响应性,所以在页面进行大小调整时,辅助线(以及在坐标轴显示的相应值)需要使用新的比例尺进行计算绘制,以下是核心代码
function debounce(delay = 500) {
// ...
// 重新设置计时器,倒计时重新计算
timer = setTimeout(function () {
// ...
if (w !== width || h !== height) {
//...
// 先取消在散点图上设置的鼠标移动监听器
svg.on("mousemove", null);
// 使用新的比例尺计算辅助线和坐标轴显示相应的值
xValue.attr("transform", `translate(0, ${h - 14})`); // 修正横坐标上显示值的位置
// 重新设置在散点图上设置的鼠标移动监听器
svg.on("mousemove", function (event) {
const [xPos, yPos] = d3.pointer(event);
xValue
.attr("x", xPos - 14)
.attr("fill", "black")
.text(d3.format(".1f")(newXScale.invert(xPos)));
yValue
.attr("y", yPos)
.attr("fill", "black")
.text(d3.format(".0f")(newYScale.invert(yPos)));
guidesParallel
.attr("x1", newXScale(xDomain[0]) - insetLeft)
.attr("y1", yPos)
.attr("x2", newXScale(xDomain[1]) + insetRight)
.attr("y2", yPos);
guidesVertical
.attr("x1", xPos)
.attr("y1", newYScale(yDomain[0] - insetTop))
.attr("x2", xPos)
.attr("y2", newYScale(yDomain[1] + insetRight));
});
}
timer = null;
}, delay);
}
高亮显示
但是通过十字辅助线来读取数据点的具体数值也有不足的,由于数据点是一个值,但是映射到页面时则是一个占据一定面积的小圆形,那么该图形元素实际对应多个数据点的,所以通过辅助线交互无法做到精准的读取。
下图是鼠标在小圆形内移动时,坐标轴上相应的数值会变化
针对这种情况,可以在这些小圆形上设置鼠标悬停事件监听器,当鼠标进入到特定数据点时,在坐标轴显示固定的(相应的)值,即使鼠标在小圆形内移动。
还可以添加一些 「高亮」样式 让具体数据的读取显得更容易,例如当鼠标悬浮到特定数据点时,改变该数据点的样式。
以下是实现高亮显示数据点相应值的核心代码
/**
*
* 当鼠标悬停在数据点时,高亮数据点并在坐标轴中显示相应的值
*
*/
// 使用 <foreignObject> 元素来包含普通的 HTML 元素,便于使用 CSS 设置丰富的样式
// 设置横坐标轴上的数据点的值的高亮显示
const xForeign = svg
.append('foreignObject')
.attr('width', 50) // 设置该容器的大小尺寸
.attr('height', 30)
// 在横坐标显示的值定位到容器的底部,并结合容器的大小进行位置调整,以正好覆盖住辅助线的值
.attr("transform", `translate(0, ${height - marginBottom / 2})`)
.style('opacity', 0) // 默认不显示
// 设置 <foreignObject> 元素内的 HTML 元素的样式
const xDom = xForeign.append('xhtml:div')
.style('width', '50px')
.style('height', '30px')
.classed('activeDotValue', true) // 添加一个类名 activeDotValue,这样就可以使用 CSS 来设置样式外观
// 设置纵坐标轴上的数据点的值的高亮显示
const yForeign = svg
.append('foreignObject')
.attr('width', 50)
.attr('height', 30)
.style('opacity', 0)
const yDom = yForeign.append('xhtml:div')
.style('width', '50px')
.style('height', '30px')
.classed('activeDotValue', true)
// 监听鼠标在数据点上的移动相关事件
pointsContainer.selectAll("circle")
.on('mouseover', function (event, d) {
// 当鼠标移入数据点时,为该元素添加类名 activeDot
// 使用 CSS 为具有该类名的元素设置填充色为红色
this.classList.add('activeDot');
xDom.text(X[d]); // 设置在横坐标上数据点的相应数据
yDom.text(Y[d]); // 设置在纵坐标上数据点的相应数据
// 调整元素在横坐标上的位置,并显示出来
xForeign
// 调用比例尺计算出该数据点的(domain 定义域)值计算出对应的(range 值域)位置
// 进行一些位置调整以正好覆盖住辅助线的值
.attr('x', xScale(X[d]) - 25)
.style('opacity', 1)
// 调整元素在纵坐标上的位置,并显示出来
yForeign.style('opacity', 1)
.attr('y', yScale(Y[d]) - 15)
})
.on('mouseleave', function (event, d) {
// 当鼠标移出数据点时,为该元素移除类名 activeDot
this.classList.remove('activeDot')
// 并隐藏元素
xForeign.style('opacity', 0)
yForeign.style('opacity', 0)
})
通过 CSS 设置高亮样式
.activeDot {
fill: #f87171;
stroke: #f87171;
}
.activeDotValue {
background-color: #f87171;
color: white;
display: flex;
justify-content: center;
align-items: center;
border-radius: 0.375rem;
}
由于在上一步为散点图设置了响应性,所以在页面进行大小调整时,在坐标轴高亮显示的相应值的位置也需要使用新的比例尺进行计算绘制,以下是核心代码
function debounce(delay = 500) {
// ...
timer = setTimeout(function () {
// ...
// 当页面的宽度或高度改变时
if (w !== width || h !== height) {
// ...
/**
*
* 重新设置数据点在坐标轴中高亮显示的值的位置
*
*/
// 先取消在数据点上设置的鼠标移动监听器
pointsContainer.selectAll("circle")
.on('mouseover', null)
// 使用新的比例尺计算高亮显示的值的位置
xForeign
.attr("transform", `translate(0, ${h - marginBottom / 2})`)
pointsContainer.selectAll("circle")
.on('mouseover', function (event, d) {
this.classList.add('activeDot');
xDom.text(X[d]);
yDom.text(Y[d]);
xForeign
.attr('x', newXScale(X[d]) - 25)
.style('opacity', 1)
yForeign.style('opacity', 1)
.attr('y', newYScale(Y[d]) - 15)
})
}
// 执行完核心代码后,清空计时器 timer
timer = null;
}, delay);
}
刷选
参考
- 解读的官方样例为 Brushable Scatterplot
- 另外还参考了一个类似的例子 Brushable Scatterplot Matrix
- 对代码进行注释解读的 Notebook 是这个
- 复现可以查看该网页,完整代码可以查看 这里
说明
该复现示例中没有设置响应性及之前说到的一些交互,后续可以考虑将它们与刷选操作相结合
以下是实现刷选功能核心代码
// ...
// 创建一个刷选器
const brush = d3.brush()
// 监听刷选的全过程(刷选在不同过程会分发三个不同类型的事件),触发回调函数 brushed
.on("start brush end", brushed);
// ...
// 刷选发生时所触发的回调函数
// 从入参的刷选事件对象中解构出 selection 选区属性
function brushed({selection}) {
// ...
if (selection) {
// 如果用户创建了选区
// 从选区解构出(矩形选区四个角落)各个坐标值
const [[x0, y0], [x1, y1]] = selection;
value = dot
.style("stroke", "gray") // 先将所有的数据点设置为灰色
// 筛选出所有数据点中满足条件的元素(构成新的选择集),即在 [[x0, y0], [x1, y1]] 范围内的数据点
.filter(d => x0 <= x(d.x) && x(d.x) < x1 && y0 <= y(d.y) && y(d.y) < y1)
.style("stroke", "steelblue") // 将选区范围内的数据点设置为蓝色
.data(); // 返回选择集中的元素所绑定的数据所构成的一个数组
} else {
// 如果用户取消了选区
dot.style("stroke", "steelblue"); // 将所有的数据点恢复为蓝色
}
// ...
}
区域缩放
需要在散点图中引入缩放交互的一般原因是数据点太多(或者数据间的跨度太大),导致散点图上的数据点太密集无法看清,为了更细致精确地观察局部的数据需要对散点图进行缩放(同时也需要支持平移)。
提示
与平移缩放交互相关的模块是 d3-zoom ,通过监听相关的操作(如鼠标滚轮的滚动)并执行相应的回调函数,回调函数中可以访问缩放变换对象,该对象有一些实用的方法可以计算出平移缩放变换后的 CSS transform 的参数值,将它们应用到元素上就可以平移缩放的效果。
与 d3-zoom 模块相关的官方样例:
参考
- 解读的官方样例为 Scatterplot Tour
- 对代码进行注释解读的 Notebook 是这个
- 复现可以查看该网页,完整代码可以查看 这里
前面解读的官方样例使用的是编程式缩放,复现时改用交互式缩放,使例子变得更常用
说明
该复现示例中没有设置响应性及之前说到的一些交互,后续可以考虑将它们与刷选操作相结合
针对散点图的缩放和一般的「放大」交互是不同了。一般对于图形的放大操作,都是通过增大 CSS 的 scale 参数值来实现,这样 DOM 元素整体都会变大,这种方式称为 Geometric Zoom 图像化缩放。如果是以这种方式来放大散点图,其实并没有意义,因为原本聚集在一起的数据点在放大后还是一样无法分离开来(因为比例尺并没有变换)。
所以较为合理的做法是在缩放交互时改变坐标轴(实际是改变比例尺的映射关系,通过新的比例尺再重绘坐标轴),再基于新的坐标轴重绘数据点。在这个过程中可以让数据点的大小不变,而只改变它们的位置,这种方法称为 Semantic zoom 语义化缩放。
考虑到 Semantic zoom 语义化缩放需要重绘页面(包括数据点和坐标轴),如果数据量比较大时会影响性能,因此以上例子是结合 Geometric zoom 图像化缩放和 Semantic zoom 语义化缩放来优化性能。其中坐标轴通过 Semantic zoom 语义化缩放实现,而数据点通过 Geometric zoom 图像化缩放(并配合一些校正操作)实现。
参考
关于两种缩放策略的解释和对比,可以看《D3 Zoom - The Missing Manual》这一篇文章。
动效
路径展开
参考
- 解读的官方样例为 Scatterplot, Connected
- 对代码进行注释解读的 Notebook 是这个
- 复现可以查看该网页,完整代码可以查看 这里
位置变动
参考
- World Health Chart
- The best stats you've ever seen Hans Rosling 教授在 TED2006 上的演讲,使用多种可视化展示技术,来讲述发展变化的世界
- 解读的官方样例为 The Wealth & Health of Nations
- 对代码进行注释解读的 Notebook 是这个
- 复现可以查看该网页,完整代码可以查看 这里
其中一个值得注意学习的细节是为了让过渡动画更流畅使用了插值器。
例如当原始数据中是按年为单位,时间间隔跨度较大,可以通过线性插值器基于两个相邻的(原始)数据点,计算/估算出其中各月份的值。