function ScatterplotMatrix(data, {
columns = data.columns,
x = columns,
y = columns,
z = () => 1,
padding = 20,
marginTop = 10,
marginRight = 20,
marginBottom = 30,
marginLeft = 40,
width = 928,
height = width,
xType = d3.scaleLinear,
yType = d3.scaleLinear,
zDomain,
fillOpacity = 0.7,
colors = d3.schemeCategory10,
} = {}) {
/**
*
* 对原始数据 data 进行转换
* 提取出相应的**轴**(X 轴、Y 轴,还可能包括其他维度,如 Z 轴,作为分类)所需的数据,以数组的形式
* 然后在使用数据时,可以依据数据点的索引很方便地获取相应轴的值
*
*/
// 从原始数据 data 中提取出用于绘制矩阵中**每个散点图**的横坐标所需的数据
// 由于该实例是 4 x 4 的散点图矩阵,所以 X 是一个具有 4 个元素的数组
// 而 X 里面的每个元素还是数组(看上一个 cell 的演示)
// 每个元素就是相应那一列的散点图的数据点的横坐标数据集
// 所以在转换时需要进行两次转换 mapping,最终可以为每个散点图提取出其数据点的横坐标(对应一个属性)的值
const X = d3.map(
x, // 首先第一层映射是以矩阵横向的属性(数组)x 作为入参,这样 mapping 得到的是 4 个属性所对应的数据
// 然后第二层映射是以原数据 data 作为入参,然后提取出相应的属性值
x => d3.map(data, typeof x === "function" ? x : d => d[x]));
// 从原始数据 data 中提取出用于绘制矩阵中**每个散点图**的纵坐标所需的数据
// 该实例 Y 和 X 其实是一样的
const Y = d3.map(y, y => d3.map(data, typeof y === "function" ? y : d => d[y]));
// 从原始数据 data 中提取数据点的分类依据的数据
const Z = d3.map(data, z);
/**
*
* 计算出 Z 轴的定义域,即数据中的所有分类(离散型数据)
* 对于该实例就是所有的企鹅种类
*
*/
// 如果没有预设的种类 zDomain 则先将定义域设置为 Z,即先设置为所有数据点的分类依据值所构成的数据
if (zDomain === undefined) zDomain = Z;
// 然后再基于原来的 zDomain 值创建一个 InternSet 对象,以便去重(由于上面的 Z 中可能会有重复值,即使是指定了 zDomain 也未必能确保没有重复的分类值)
// 这样所得的 zDomain 里的元素都是唯一的,作为 Z 轴的定义域(分类的依据)
zDomain = new d3.InternSet(zDomain);
// 在绘制散点图之前,这里还做了一步数据清洗
// 使用 JavaScript 数组的原生方法 arr.filter() 筛掉不属于 zDomain 所预设的任何一类的数据点
// 返回一个数组,其元素是一系列数字,对应于原数据集的元素的索引位置
const I = d3.range(Z.length).filter(i => zDomain.has(Z[i]));
/**
*
* 设置矩阵「单元」(即散点图)的尺寸
*
*/
// 其中 X 和 Y 分别是前面数据转换得到的数组,其中 X.length 和 Y.length 长度就是矩阵在横向和纵向的维度(即在该方向上有几个属性,即散点图)
const cellWidth = (width - marginLeft - marginRight - (X.length - 1) * padding) / X.length;
const cellHeight = (height - marginTop - marginBottom - (Y.length - 1) * padding) / Y.length;
/**
*
* 构建比例尺和坐标轴
*
*/
// 构建矩阵散点图的横向变量的(映射)比例尺
// xScales 是一个数组,每一个元素对应于一列散点图的横轴(变量)比例尺
const xScales = X.map(X => xType(d3.extent(X), [0, cellWidth]));
// 构建矩阵散点图的纵向变量的(映射)比例尺
// yScales 也是一个数组,每一个元素对应于一行散点图的纵轴(变量)比例尺
const yScales = Y.map(Y => yType(d3.extent(Y), [cellHeight, 0]));
// 构建分类比例尺
// 将离散的数据(在该实例中是企鹅的不同种类)映射为不同的颜色
const zScale = d3.scaleOrdinal(zDomain, colors);
// 坐标轴对象
const xAxis = d3.axisBottom().ticks(cellWidth / 50); // 横轴是一个朝下的坐标轴
const yAxis = d3.axisLeft().ticks(cellHeight / 35); // 纵轴是一个朝左的坐标轴
// 创建 svg(返回的是一个包含 svg 元素的选择集)
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [-marginLeft, -marginTop, width, height])
.attr("style", "max-width: 100%; height: auto; height: intrinsic;");
// 绘制纵向坐标轴
svg.append("g")
.selectAll("g")
// 绑定的数据是 yScales(不同散点图的纵向属性对应不同的比例尺)
// 一个数组,每一个元素对应于一行散点图的纵轴(变量)比例尺
.data(yScales)
// 为每一行散点图创建一个纵向坐标轴容器
.join("g")
// 通过设置 CSS 的 transform 属性将这些纵向坐标轴容器「移动」到相应的位置
// 其中 translate() 第一个参数 `0` 表示将每一个纵坐标轴容器都定位到左侧
// 而第二个参数 ${i * (cellHeight + padding)} 表示每一个纵坐标轴容器的高度会随着其绑定的数据的索引值而变化
.attr("transform", (d, i) => `translate(0, ${i * (cellHeight + padding)})`)
// 对选择集中的每个元素(纵向坐标轴容器)都调用一次函数 function 执行相应的操作
// 该函数的入参是纵向坐标轴容器所绑定的数据(该属性相应的比例尺)
.each(function(yScale) {
// 在纵向坐标轴容器里用相应的比例尺绘制出坐标轴
// 在函数内的 this 是指当前迭代的纵向坐标轴容器 <g> 元素
return d3.select(this).call(yAxis.scale(yScale));
})
.call(g => g.select(".domain").remove()) // 删掉上一步所生成的坐标轴的轴线(它含有 domain 类名)
.call(g => g.selectAll(".tick line").clone() // 这里复制了一份刻度线,用以绘制散点图中纵向的网格参考线
.attr("x2", width - marginLeft - marginRight) // 调整复制后的刻度线的终点位置(往右移动)
.attr("stroke-opacity", 0.1)); // 调小网格线的透明度
// 绘制横向坐标轴
svg.append("g")
.selectAll("g")
.data(xScales)
.join("g")
// 通过设置 CSS 的 transform 属性将这些横向坐标轴容器「移动」到相应的位置
// 其中 translate() 第一个参数 ${i * (cellWidth + padding)} 表示每一个横坐标轴容器的水平位置会随着其绑定的数据的索引值而变化
// 而第二个参数 ${height - marginBottom - marginTop} 表示将每一个横坐标轴容器都定位到底部
.attr("transform", (d, i) => `translate(${i * (cellWidth + padding)}, ${height - marginBottom - marginTop})`)
.each(function(xScale) { return d3.select(this).call(xAxis.scale(xScale)); })
.call(g => g.select(".domain").remove())
.call(g => g.selectAll(".tick line").clone()
.attr("y2", -height + marginTop + marginBottom)
.attr("stroke-opacity", 0.1))
/**
*
* 构建散点图矩阵的各个「单元」
*
*/
const cell = svg.append("g")
.selectAll("g")
// 基于行和列的维度,构建出索引值作为矩阵中各个「单元」所绑定的数据
// 使用 d3.range() 生成一个等差数列,作为行/列的索引值
// 使用 d3.cross() 将两个数组的元素交叉组合 [i, j] 作为二维矩阵中各元素的索引值
// 例如 [1, 1] 可以表示第一行第一列的那个散点图
.data(d3.cross(d3.range(X.length), d3.range(Y.length)))
.join("g")
.attr("fill-opacity", fillOpacity) // 设置透明度
// 通过设置 CSS 的 transform 属性,基于每个「单元」所绑定的索引值,将它们「移动」到相应的(行和列)位置
// 其中 translate() 第一个参数 ${i * (cellWidth + padding)} 表示该「单元」的横向位置
// 而第二个参数 ${j * (cellHeight + padding)} 表示该「单元」的纵向位置
.attr("transform", ([i, j]) => `translate(${i * (cellWidth + padding)}, ${j * (cellHeight + padding)})`);
// 为每个「单元」设置一个边框,以便区分邻近的散点图
cell.append("rect")
.attr("fill", "none")
.attr("stroke", "currentColor")
.attr("width", cellWidth)
.attr("height", cellHeight);
// 绘制数据点
// 对每个「单元」(散点图容器)都调用一次函数 function 执行相应的操作
// 该函数的入参是每个「单元」(散点图容器)所绑定的数据(即「单元」所对应的索引值 [x, y],一个二元数组)
cell.each(function([x, y]) {
// 在函数内的 this 是指当前迭代的「单元」(散点图容器) <g> 元素
// 将数据点绘制在散点图中
d3.select(this).selectAll("circle")
// 这里在绑定数据时,再进行一次数据清洗
// (基于 i 索引值进行迭代)筛掉在当前散点图所对应的横向属性 X[x] 或纵向属性 Y[y] 任意一个为空的数据点
// 即 !isNaN(X[x][i]) 和 isNaN(Y[y][i]) 均需要成立
.data(I.filter(i => !isNaN(X[x][i]) && !isNaN(Y[y][i])))
.join("circle")
.attr("r", 3.5) // 设置数据点的大小(圆的半径大小)
// 设置各个 <circle> 元素的属性 cx 和 cy 将其移动到相应的位置
// 其中 X[x][i] 就是当前数据点的横向(原始)值,xScales[x] 就是当前散点图的比例尺(用于对数据进行映射)
.attr("cx", i => xScales[x](X[x][i]))
.attr("cy", i => yScales[y](Y[y][i])) // 纵向值
.attr("fill", i => zScale(Z[i])); // 设置数据点的颜色,根据 Z 比例尺来设定
});
// 当散点图矩阵(横向维度等于纵向维度时 x===y)是一个方阵时(TODO 是否需要支持在非对称的散点图矩阵也添加标注?)
// 在对角线上的散点图添加标注
// 以表示(在对角线上)散点图所在的行和列所表示的属性变量
if (x === y) svg.append("g") // 创建标注文本的容器
.attr("font-size", 10)
.attr("font-family", "sans-serif")
.attr("font-weight", "bold")
.selectAll("text")
// 因为这是一个方阵,横轴和纵轴的散点图所映射的变量数量和名称都是相同的
// 所以在绑定数据时,只需要绑定横向轴(或纵向轴)所映射的属性即可
.data(x)
.join("text")
// 通过设置 CSS 的 transform 属性,将它们「移动」到相应的位置
// 基于每个标注文本的容器所绑定的索引值,将它们定位到相应的(对角线上的)散点图里
.attr("transform", (d, i) => `translate(${i * (cellWidth + padding)}, ${i * (cellHeight + padding)})`)
// 为标注文本设置定位(相对于其容器)和纵向的偏移 dy,避免文字贴着散点图的边框
.attr("x", padding / 2)
.attr("y", padding / 2)
.attr("dy", ".71em")
.text(d => d); // 设置标注内容
return Object.assign(svg.node(), {scales: {color: zScale}});
}