堆叠图是柱状图的一种形式,它显示了一些相对或绝对变量随时间变化的构成和比较。看起来像一系列堆叠在一起的柱状图。如果使用得当,堆叠图是一种非常有效的比较工具。它们旨在比较不同类别的总值。点击查看Demo
d3.stack
D3提供了处理生成堆叠数据的API——d3.stack:
d3.stack- 创建一个新的堆叠生成器。stack- 为给定数据生成堆叠数据。stack.keys- 设置键访问器。stack.value- 设置值访问器。stack.order- 设置排序访问器。d3.stackOrderAscending- 将最小值放在底部。d3.stackOrderDescending- 将最大值放在底部。d3.stackOrderInsideOut- 将最大值放在中部。d3.stackOrderNone- 使用给定的系列顺序。d3.stackOrderReverse- 系列给定的系列相反的顺序。
stack.offset- 设置偏移访问器。d3.stackOffsetExpand- 标准化为0=1之间。d3.stackOffsetNone- 应用零基准。d3.stackOffsetSilhouette- 将流图居中在0附近。d3.stackOffsetWiggle- 流图最小摆动。
const data = [
{month: new Date(2015, 0, 1), apples: 3840, bananas: 1920, cherries: 960, dates: 400},
{month: new Date(2015, 1, 1), apples: 1600, bananas: 1440, cherries: 960, dates: 400},
{month: new Date(2015, 2, 1), apples: 640, bananas: 960, cherries: 640, dates: 400},
{month: new Date(2015, 3, 1), apples: 320, bananas: 480, cherries: 640, dates: 400}
];
const stack = d3.stack()
.keys(["apples", "bananas", "cherries", "dates"])
.order(d3.stackOrderNone) // 设置排序访问器 使用给定的系列顺序
.offset(d3.stackOffsetNone); // 设置偏移访问器 应用零基准
const dataset = stack(data);
console.log(dataset);
我们会得到如下的一个数组:
[
[[ 0, 3840], [ 0, 1600], [ 0, 640], [ 0, 320]], // apples
[[3840, 5760], [1600, 3040], [ 640, 1600], [ 320, 800]], // bananas
[[5760, 6720], [3040, 4000], [1600, 2240], [ 800, 1440]], // cherries
[[6720, 7120], [4000, 4400], [2240, 2640], [1440, 1840]], // dates
]
最开始,我理解的生成的数组,对应的就是每一个时间对应的柱状图,类似下面这种格式:
[
[[apples.st, apples.ed], [bananas.st, bananas.ed], [cherries.st, cherries.ed], [dates.st, dates.ed]]
...
]
实际情况却是,把需要堆叠的数据按顺序处理为一组,每组对应数组下标的数据,组成一个柱形图:
感觉说的很绕口,这样我们只先展示apple的数据:
const stack = d3.stack()
.keys(["apples"])
/*
[
[[0, 3840], [0, 1600], [0, 640], [0, 320]]
]
*/
这样就是生成四个柱形图,每个柱形图只有apple这一个类别的数据。
其实这里就可以联想到Echarts中的图例(
legend)是怎么控制不同类别的数据是否显示的,在D3的堆叠数据中来说,我们只需要控制keys的值就行了,很方便。
堆叠柱状图
比例尺
我们需要三个比例尺:x轴比例尺,y轴比例尺和颜色比例尺。关于比例尺的具体内容,本章就不再重复了,大家可以回看D3(v7)入门四:比例尺。
const xScale = d3.scaleBand()
.domain(data.map((d) => d.month))
.range([0, dimensions.boundedWidth])
.padding(0.1);
const yScale = d3.scaleLinear()
.domain([0, d3.max(dataset, (d) => d3.max(d, (d) => d[1]))])
.range([dimensions.boundedHeight, 0]);
const color = d3.scaleOrdinal()
.domain(["apples", "bananas", "cherries", "dates"])
.range(["#c51b7d", "#e9a3c9", "#fde0ef", "#f1e4f1"]);
绘制数据
bounds.selectAll("g")
.data(dataset) // 绑定数据绘制四个分组(柱状图盒子)
.join("g")
.selectAll("rect")
.data((d) => d) // 绑定每一组的数据,绘制四个矩形
.join("rect")
.attr("x", (d, i) => {
console.log(d)
return xScale(d.data.month)
})
.attr("y", (d) => yScale(d[1]))
.attr("width", xScale.bandwidth())
.attr("height", (d) => yScale(d[0]) - yScale(d[1]))
.attr("fill", (d, i, nodes) => {
console.log(nodes[i].parentNode.__data__)
return color(nodes[i].parentNode.__data__.key);
});
每一个d的数据:
每一个分组g上绑定的数据nodes[i].parentNode.__data__:
坐标轴
const xAxis =d3.axisBottom(xScale)
.tickFormat(d3.timeFormat("%b"));
const yAxis = d3.axisLeft(yScale);
bounds.append("g")
.attr("transform", `translate(0,${dimensions.boundedHeight})`).call(xAxis);
bounds.append("g").call(yAxis);
这里用到了tickFormat 和 d3.timeFormat,tickFormat支持自定义刻度显示内容格式。
d3.timeFormat
const dateString = new Date()
const today = d3.timeFormat("%Y-%m-%d %H:%M:%S")(dateString)
console.log(today) // 2022-11-12 21:51:07
%f- milliseconds 毫秒%S- seconds 秒%M- minute 分钟%H- hour (24h)%I- hour (12h)%p- AM / PM%d- day (01-31)%a- abbreviated 工作日缩写%A- 工作日%b- abbreviated 月份缩写%B- 月份%m- month (01-12)%Y- year (four digits)%Z- time zone offset 时区偏移百分比
还有一些高级的用法:
const start = new Date(2020, 0, 1)
const end = new Date(2022, 0, 2)
console.log(d3.timeDay.count(start, end)) // 计算两个日期之间的天数
console.log(d3.timeDay.every(2).range(start, end).map(d3.timeFormat("%Y-%m-%d"))) // 生成两个日期之间的每个第二天的日期数组
console.log(d3.timeFormat("%Y-%m-%d")(d3.timeDay.floor(start))) // 向下取整
console.log(d3.timeFormat("%Y-%m-%d")(d3.timeDay.ceil(start))) // 向上取整
console.log(d3.timeFormat("%Y-%m-%d")(d3.timeDay.round(start))) // 四舍五入
console.log(d3.timeFormat("%Y-%m-%d")(d3.timeDay.offset(start, 2))) // 日期偏移
console.log(d3.timeFormat("%Y-%m-%d")(d3.timeDay.offset(start, -2))) // 日期偏移
console.log(d3.timeDay.filter(d => d.getDay() === 0).range(start, end).map(d3.timeFormat("%Y-%m-%d"))) // 过滤出周日的日期数组
console.log(d3.timeDays(start, end).map(d3.timeFormat("%Y-%m-%d"))) // 生成两个日期之间的所有日期
多列柱状图
堆叠柱状图也可以很方便的转换成多列柱状图,只需要下列两步:
- 柱状图的宽度 =
xScale.bandwidth / 4 - x =
xScale() + 柱状图的宽度 * index
bounds.selectAll("g")
.data(dataset)
.join("g")
.selectAll("rect")
.data((d) => d)
.join("rect")
.attr("x", (d, i, nodes) => {
return (xScale(data[i].month) +(xScale.bandwidth() / 4) * nodes[i].parentNode.__data__.index);
}) // 改x位置
.attr("y", (d) => yScale(d[1] - d[0]))
.attr("width", xScale.bandwidth() / 4) // 改宽度
.attr("height", (d) => yScale(d[0]) - yScale(d[1]))
.attr("fill", (d, i, nodes) => {
return color(nodes[i].parentNode.__data__.key);
});
总结
最后点一下文章开头提到的「它们旨在比较不同类别的总值」,也就是通过排序实现的,排序的规则是不同类别的总数进行排序,也就是apples, bananas, cherries, dates这四天的总数,对应的结果就是 apples的总数最大,所以apples显示在最下面(y从0开始),当然这是通过order可以配置的。
同一维度,不同类别的数据都可以采用堆叠数据的处理来进行对比。