Public
Edited
Nov 15, 2022
Insert cell
Insert cell
Insert cell
chart = {
// 创建缩放器
const zoom = d3.zoom()
.on("zoom", zoomed);

// 创建 svg(返回的是一个包含 svg 元素的选择集)
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height]);

// 创建数据点的容器
const g = svg.append("g")
.attr("fill", "none")
.attr("stroke-linecap", "round"); // 设置容器内的路径的收口形状是圆形,这对于后面使用 <path> 元素来绘制数据圆点有用

// 绘制数据点
g.selectAll("path")
.data(data)
.join("path")
// 这里用了一点 tricky 方法来绘制数据点
// 不是用 <circle> 元素,而是用 <path> 元素
// 其中 d 属性的 Mx,y 是将画笔移动到数据点对应的 (x, y) 位置
// 然后 h0 是将路径的延伸相对长度设置为 0(即路径的实际长度为 0)
.attr("d", d => `M${x(d[0])},${y(d[1])}h0`)
// 而真正绘制出圆点的是靠边框,颜色是根据 z 比例尺来设定,不同的数据集(聚类)采用不同的颜色,共有 3 种颜色
.attr("stroke", d => z(d[2]));

// 创建 X 坐标轴的容器
const gx = svg.append("g");

// 创建 Y 坐标轴的容器
const gy = svg.append("g");

// 调用 zoom.transform 方法执行缩放(以得到缩放后的变换对象)
// 将当前选中的数据集所对应的变换对象作为(第二个)参数传递进去
// 因为通过 call 来调用,所以实际上 zoom.transform 的第一个参数是 svg 选择集
// zoom.transform(selection, transform[, point])
svg.call(zoom.transform, viewof transform.value);

// 缩放事件的回调函数
// 移动数据点的定位,并校正数据点的大小尺寸
function zoomed(event) {
const {transform} = event; // 从回调函数的事件中解构出当前的缩放变换值
g.attr("transform", transform) // 将缩放变换值应用到数据点的容器 <g> 上(作为 CSS transform 的值),整体进行变换
.attr("stroke-width", 5 / transform.k); // 校正数据点的尺寸(路径的默认值宽度为 5,由于放大了数据点,所以要对路径的宽度进行校正)
// 使用 transform.rescaleX 方法对比例尺重新构建
// 返回一个定义域经过缩放变换的比例尺(这样映射关系就会相应的改变,会考虑上缩放变换对象 transform 的缩放比例)
// 并用新的比例尺重绘 X 轴和 Y 轴
gx.call(xAxis, transform.rescaleX(x));
gy.call(yAxis, transform.rescaleY(y));
}

// 💡 这里采用图像化的缩放 Geometric zoom 先对元素(数据点的容器)整体进行变换,不过这样会造成所有元素无差别缩放平移,所以需要再进行修正(坐标轴和数据点的大小)
// 💡 而另一种方法是语义化缩放 Semantic zoom,可以先计算出变换后的新比例尺,再基于新的比例重新计算整个图表,包括数据点的定位和坐标轴,不过这会导致整个页面重绘
// 参考:
// * https://www.datamake.io/blog/d3-zoom#:~:text=Geometric
// * https://www.datamake.io/blog/d3-zoom#:~:text=Semantic

return Object.assign(svg.node(), {
update(transform) {
svg.transition()
.duration(1500)
.call(zoom.transform, transform);
}
});
}
Insert cell
chart.update(transform)
Insert cell
data = {
const random = d3.randomNormal(0, 0.2);
const sqrt3 = Math.sqrt(3);
// 生成 3 个列表(相当于有 3 个数据集)并合并 concat 为一个列表
// 每个数据集都随机生成 300 个数据点,共 900 个数据点
// 每个数据点都由 3 个元素构成
// 在生成数据点时附加了一些约束条件
// 第一个数据集是在散点图中右上角蓝色的一类,它的横向均值是 Math.sqrt(3) 约 1.73 左右,纵轴均值是 1,数据点的最后一个元素都是 0
// 第二个数据集是在散点图中左上角橙色的一类,它的横向均值是 -1.73 左右,纵向均值是 1,数据点的最后一个元素是 1
// 第三个数据集是在散点图中下方绿色的一类,它的横向均值是 0,纵向均值是 -1,数据点的最后一个元素是 2
// 根据最后一个元素,就可以将这些数据点进行区分(映射为不同的颜色),在散点图中表现为相同数据集的数据点聚类在一起
return [].concat(
Array.from({length: 300}, () => [random() + sqrt3, random() + 1, 0]),
Array.from({length: 300}, () => [random() - sqrt3, random() + 1, 1]),
Array.from({length: 300}, () => [random(), random() - 1, 2])
);
}
Insert cell
// 一个列表,表示每个数据集相应的(缩放平移)变换值,除了 3 个数据集还包括全局视图,共 4 个元素
// 元素也是一个列表,第一个值是数据集的名称,第二个值是应用于 svg 的变换对象,以放大展示该数据集
// 第一个元素 ["Overview", d3.zoomIdentity] 即展示全局视图时,不需要对 svg 进行缩放
// 之后的元素采用 d3.groups 方法对数据集进行转换来构建,并合并 concat 为一个列表
// 在使用 d3.groups 转换时,分组依据是数据点的第 3 个值,这样就可以将 900 个数据点分成 3 组(数据集)
transforms = [["Overview", d3.zoomIdentity]].concat(d3.groups(data, d => d[2]).map(([key, data]) => {
// 对于每一个数据集的数据再计算其定义域和值域
// 并通过相应的比例尺计算出相应的页面尺寸范围
const [x0, x1] = d3.extent(data, d => d[0]).map(x); // 基于 X 轴比例尺,计算出该数据集的所有数据在横轴所覆盖的相应范围
const [y1, y0] = d3.extent(data, d => d[1]).map(y); // 基于 Y 轴比例尺,计算出该数据集的所有数据在纵轴所覆盖的相应范围
// 计算放大该数据集时的变换参数
// 为了保证在放大后,该数据集的所有数据都可以看到,需要取横向缩放范围 width / (x1 - x0) 和纵向缩放范围 height / (y1 - y0) 的最小值
// 还要乘上一个系数 0.9 别放太大,其作用也是保证数据点可以完整看到,类似于留一些 padding 在四周
const k = 0.9 * Math.min(width / (x1 - x0), height / (y1 - y0));
// 💡由于变换的原点默认为元素的中点,所以还需要进行平移校正,将该数据集的点移到 svg 画布中间
const tx = (width - k * (x0 + x1)) / 2;
const ty = (height - k * (y0 + y1)) / 2;
// 返回该数据集所对应的缩放参数
// 第一个值是数据集的名称,如第一个数据集为 Cluster 0
// 第一个元素是变换对象(基于标准缩放变换对象 d3.zoomIdentity 来构建)
return [`Cluster ${key}`, d3.zoomIdentity.translate(tx, ty).scale(k)];
}))
Insert cell
// X 轴比例尺
// 通过线性比例尺将数据范围与页面的横向宽度进行映射
x = d3.scaleLinear()
.domain([-4.5, 4.5])
.range([0, width])
Insert cell
// Y 轴比例尺
// 通过线性比例尺将数据范围与页面的纵向宽度进行映射
y = d3.scaleLinear()
.domain([-4.5 * k, 4.5 * k])
.range([height, 0])
Insert cell
// 分类比例尺
// 将离散的数据映射为不同的颜色
// d3.schemeCategory10 是一个 Color Schemes
// 相关模块是 https://github.com/d3/d3-scale-chromatic/
z = d3.scaleOrdinal()
.domain(data.map(d => d[2]))
.range(d3.schemeCategory10)
Insert cell
// 基于比例尺绘制 X 坐标轴
xAxis = (g, x) => g
.attr("transform", `translate(0,${height})`)
.call(d3.axisTop(x).ticks(12))
.call(g => g.select(".domain").attr("display", "none"))
Insert cell
// 基于比例尺绘制 Y 坐标轴
yAxis = (g, y) => g
.call(d3.axisRight(y).ticks(12 * k))
.call(g => g.select(".domain").attr("display", "none"))
Insert cell
k = height / width // 页面的长宽比例,用于缩放过程中校正数据点的尺寸
Insert cell
height = 600 // svg 的高度约束为 600px
Insert cell
d3 = require("d3@6")
Insert cell
// 一个 color schema 配色方案
// 用于分类
// 它是一个包含 10 个元素的数组,里面的元素都是表示颜色值的字符串
// 参考 https://github.com/d3/d3-scale-chromatic/blob/v3.0.0/README.md#categorical
d3.schemeCategory10
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more