function Scatterplot(data, {
x = ([x]) => x,
y = ([, y]) => y,
r = 3,
title,
marginTop = 20,
marginRight = 30,
marginBottom = 30,
marginLeft = 40,
inset = r * 2,
insetTop = inset,
insetRight = inset,
insetBottom = inset,
insetLeft = inset,
width = 640,
height = 400,
xType = d3.scaleLinear,
xDomain,
xRange = [marginLeft + insetLeft, width - marginRight - insetRight],
yType = d3.scaleLinear,
yDomain,
// 由于 svg 的坐标体系中向下和向右是正方向,和我们日常使用的不一致
// 所以这里的值域范围需要采用从下往上与定义域进行映射
yRange = [height - marginBottom - insetBottom, marginTop + insetTop],
xLabel, // 为横坐标轴添加额外文本(一般是刻度值的单位等信息)
yLabel,
xFormat, // 格式化数字的说明符 specifier 用于格式化横坐标轴的刻度值
yFormat,
fill = "none", // 数据点的填充颜色
stroke = "currentColor", // 数据点的描边颜色
strokeWidth = 1.5, // 数据点的描边宽度
halo = "#fff", // 数据点的标注信息的文字描边颜色
haloWidth = 3 // 数据点的标注信息的文字描边宽度
} = {}) {
/**
*
* 对原始数据 data 进行转换
*
*/
// 主要使用 d3-array 模块的 API:d3.map()
// 具体可以参考 https://github.com/d3/d3-array#map
// 从原始数据 data(一般是一个数组,也可以是其他类型,只要是可迭代对象即可)中读取出用于绘制散点图的横坐标所需的数据
// 参数 x 是映射函数 mapper,它会被可迭代对象 data 的每一个元素依次调用,而映射函数会返回一个值,作为各个元素的相应「替代」值,和 JavaScript 的数组原生方法 arr.map() 类似。
// 参数 x 的默认值是 ([x]) => x
// 这里假设原始数据是一个嵌套数组,例如 [[1,2], [11, 3], ...] 其元素是一个二维数组 [a, b] 其中第一个值就是用作散点图的横坐标,所以默认的 mapper 函数 ([x]) => x 就是通过解构读取第一个值
const X = d3.map(data, x);
// 从原始数据 data 中读取出用于绘制散点图的纵坐标所需的数据
const Y = d3.map(data, y);
// 从原始数据 data 中读取出各数据点的标注信息
const T = title == null ? null : d3.map(data, title);
// 这里还做了一步数据清洗
// 使用 JavaScript 数组的原生方法 arr.filter() 筛掉横坐标或纵坐标值任意一个为空的数据点
// 返回一个数组,其元素是一系列数字,对应于原数据集的元素的索引位置
const I = d3.range(X.length).filter(i => !isNaN(X[i]) && !isNaN(Y[i]));
/**
*
* 构建比例尺和坐标轴
* 主要使用 d3-scale 和 d3-axis 模块的 API
*/
// 计算数据集的范围,作为坐标轴的定义域
// 主要使用 d3-array 模块的 API:d3.extent()
// 具体可以参考 https://github.com/d3/d3-array#extent
// 参数 X 和 Y 是一个可迭代对象,该方法获取可迭代对象的范围,返回一个由最小值和最大值构成的数组 [min, max]
if (xDomain === undefined) xDomain = d3.extent(X);
if (yDomain === undefined) yDomain = d3.extent(Y);
// xType 和 yType 是横轴和纵轴所对应的数据映射为可视元素的属性时所使用的比例尺,默认值都是 d3.scaleLinear
// 它是线性比例尺 linear scale(连续型比例尺 Continuous Scales 的一种),值域中的值 y 与定义域中的值 x 是通过表达式 y=mx+b 联系起来的,这种映射方式可以在视觉元素的变量中保留数据的原始差异比例
// 具体可以参考 https://github.com/d3/d3-scale#linear-scales
// 使用方法 d3.scaleLinear(domain, range) 构建一个线性比例尺,其中参数 xDomain 是定义域,一般是原始数据的范围;而 xRange 是值域,一般是可视元素的某个属性的范围,例如页面的宽度
const xScale = xType(xDomain, xRange); // 横轴所使用的比例尺
const yScale = yType(yDomain, yRange); // 纵轴所使用的比例尺
// 基于比例尺绘制坐标轴
// 具体可以参考 https://github.com/d3/d3-axis
// 使用方法 d3.axisBottom(scale) 生成一个朝下的坐标轴(对象),即其刻度在水平轴线的下方
// 而 d3.axisLeft(scale) 就生成一个朝左的坐标轴,即其刻度在竖直轴线的左方
// 调用坐标轴对象方法 axis.ticks() 设置坐标轴刻度的间隔(一般是设置刻度的数量 count),以及刻度值的格式
// 其中刻度值的格式使用了 d3-format 模块,该模块提供了很多处理数字格式的 API
// xFormat 和 yFormat 就是用于格式化数字的说明符 specifier
// 具体可以参考 https://github.com/d3/d3-format#locale_format
const xAxis = d3.axisBottom(xScale).ticks(width / 80, xFormat);
const yAxis = d3.axisLeft(yScale).ticks(height / 50, yFormat);
// 构建出来的坐标轴对象 xAxis 和 yAxis 也是一个方法,它接受一个 SVG 元素 context,一般是一个 <g> 元素,如 xAxis(context) 和 yAxis(context) 将坐标轴在其内部渲染出来。构建出来的坐标轴是有一系列 SVG 元素构成
// * 轴线由 <path> 路径元素构成,它带有类名 domain
// * 刻度是和刻度值分别由元素 <line> 和 <text> 构成。每一刻度和相应的刻度值都包裹在一个 <g> 元素中,它带有类名 tick
// 💡 但是一般使用 selection.call(axis) 的方式来调用坐标轴对象(方法),其中 selection 是指选择集,一般是一个 <g> 元素;axis 是坐标轴对象。关于 selection.call() 方法具体可以参考 https://github.com/d3/d3-selection#selection_call
// 💡 在构建坐标轴时,推荐为容器的四周设置一个 margin 区域(即封装方法的参数 marginTop、marginRight、marginBottom、marginLeft),以便放置坐标轴等注释信息,而中间的「安全区域」才放置主要的可视化图表内容
/**
*
* 创建容器
*
*/
// 主要使用 d3-selection 模块的 API
// 具体可以参考 https://github.com/d3/d3-selection
// 使用方法 d3.create("svg") 创建一个 svg 元素,并返回一个选择集 selection
// 使用选择集的方法 selection.attr() 为选择集中的所有元素(即 <svg> 元素)设置宽高和 viewBox 属性
// 💡 这里使用的是链式调用的方法,因为选择集的方法返回的也是该选择集
const svg = d3.create("svg")
.attr("width", width) // 这里的宽度默认为 640px,但是在 Observable 中,当页面调整大小时 svg 宽度也会随之变换,这是因为 Observable 的 cell 之间可以构成响应式的依赖实现同步变化,具体工作原理可以查看这里 https://observablehq.com/@observablehq/how-observable-runs 如果在项目中也想在页面调整大小时图表也随着变化,代码需要做相应的调整
.attr("height", height)
.attr("viewBox", [0, 0, width, height]) // viewBox 一般设置为与 svg 元素等宽高
.attr("style", "max-width: 100%; height: auto; height: intrinsic;");
/**
*
* 绘制坐标轴
*
*/
// 主要使用 d3-selection 模块的 API
// 绘制横坐标轴
// 使用 svg.append("g") 在选择集 svg 的元素中(这个选择集只有 <svg> 元素),创建一个子元素 <g> 以其作为一个容器(包含坐标轴的轴线和坐标刻度以及坐标值),然后返回包含该元素的选择集(即此时的选择集已经改变了)
// 然后通过一系列的链式调用,主要是使用方法 selection.attr() 为选择集的元素(当前选择集包含的元素是 <g> 元素)设置属性
svg.append("g")
.attr("transform", `translate(0, ${height - marginBottom})`) // 将横坐标轴容器定位到底部
.call(xAxis) // 调用坐标轴(对象)方法,将坐标轴在相应容器内部渲染出来。以下的代码是对坐标轴进行一些定制化的调整
.call(g => g.select(".domain").remove()) // 删掉上一步所生成的坐标轴的轴线(它含有 domain 类名)
.call(g => g.selectAll(".tick line").clone() // 这里复制了一份刻度线,用以绘制散点图中纵向的网格参考线
.attr("y2", marginTop + marginBottom - height) // 调整复制后的刻度线的终点位置(往上移动)
.attr("stroke-opacity", 0.1)) // 调小网格线的透明度
.call(g => g.append("text") // 为坐标轴添加额外信息名称(一般是刻度值的单位等信息)
// 将该文本移动到坐标轴的右侧(即容器的右下角)
.attr("x", width)
.attr("y", marginBottom - 4)
.attr("fill", "currentColor")
.attr("text-anchor", "end") // 设置文本的对齐方式
.text(xLabel)); // 这是封装函数传入的参数
// 绘制纵坐标轴
svg.append("g")
.attr("transform", `translate(${marginLeft}, 0)`) // 这里将纵坐标容器稍微往左移动一点,让坐标轴绘制在预先留出的 margin 区域中
.call(yAxis)
.call(g => g.select(".domain").remove())
.call(g => g.selectAll(".tick line").clone()
.attr("x2", width - marginLeft - marginRight)
.attr("stroke-opacity", 0.1))
.call(g => g.append("text")
.attr("x", -marginLeft)
.attr("y", 10)
.attr("fill", "currentColor")
.attr("text-anchor", "start")
.text(yLabel));
/**
*
* 绘制数据点和相应的标注信息
*
*/
// 如果有为数据点设置标注信息,即变量 T 不为 null 时,绘制出标注信息
// 在 svg 中添加一个容器 <g> 元素
if (T) svg.append("g")
.attr("font-family", "sans-serif")
.attr("font-size", 10)
.attr("stroke-linejoin", "round")
.attr("stroke-linecap", "round") // 设置字体的相关样式
.selectAll("text") // 将数据集 I(包含一系列索引值)和一系列「虚拟」的占位 <text> 元素进行绑定
.data(I)
.join("text") // 将这些 <text> 生成到 <g> 容器中
.attr("dx", 7) // 为文字在横纵轴方向上设置一点小偏移,避免阻挡数据点
.attr("dy", "0.35em")
.attr("x", i => xScale(X[i])) // 设置各个 <text> 元素的属性 x 和 y 将其移动到相应的数据点的位置。第二个参数是一个函数,则每一个 <text> 元素都会依次调用,并传入其绑定的数据 i,通过 X[i] 就可以读取到相应的数据点的横坐标值
.attr("y", i => yScale(Y[i]))
.text(i => T[i]) // 设置标注内容
.call(text => text.clone(true)) // 这里将各文本拷贝一份,用以实现文字描边的效果,可以有效地凸显文字内容,且避免其他元素对文字遮挡
.attr("fill", "none") // 没有填充色
.attr("stroke", halo) // 只是设置描边的颜色和宽度
.attr("stroke-width", haloWidth);
// 绘制出数据点
// 在 svg 中添加一个容器 <g> 元素
svg.append("g")
.attr("fill", fill) // 设置数据点的一些样式属性,包括填充的颜色、描边样式、描边宽度
.attr("stroke", stroke)
.attr("stroke-width", strokeWidth)
.selectAll("circle") // 将数据集 I(包含一系列索引值)和一系列「虚拟」的占位 <circle> 元素进行绑定
.data(I)
.join("circle") // 将这些 <circle> 生成到 <g> 容器中
.attr("cx", i => xScale(X[i])) // 设置各个 <circle> 元素的属性 cx 和 cy 将其移动到相应的位置。第二个参数是一个函数,则每一个 <circle> 元素都会依次调用,并传入其绑定的数据 i,通过 X[i] 就可以读取到相应的数据点的横坐标值
.attr("cy", i => yScale(Y[i]))
.attr("r", r); // 设置圆的半径大小
return svg.node(); // 最后返回 svg 元素,在 Observable 可以直接将其绘制到页面上,如果是在自己的项目中,则需要将 svg 元素 append 到页面上
}