You need to enable JavaScript to run this app.
最新活动
大模型
产品
解决方案
定价
生态与合作
支持与服务
开发者
了解我们

关于D3.js堆叠转分组图表适配自定义时间轴数据的技术问询

拆解D3 Stacked-to-Grouped示例:适配自定义日期数据的实现步骤

我刚帮几个朋友搞定过类似的需求,结合D3的Stacked-to-Grouped示例来适配你的日期+名称+总计数据其实没那么复杂。先把示例里的核心方法讲透,再一步步改成你的数据格式:

一、先捋明白原示例里的核心方法到底干啥用

1. map():给数据“整容”的基础操作

原示例里用map是把原始的对象数组,转换成D3堆叠布局能认的结构——说白了就是把每个数据点里的各类数值(比如不同类别的值)抽出来变成数组,方便后续喂给stack布局。比如你的数据里每个对象有nametotalmap就能帮你把所有name和对应的total整理成规整的结构。

2. d3.stack():堆叠柱子的核心引擎

d3.stack()会把你整理好的二维数组(行是每个数据点,列是不同类别),转换成堆叠后的数据结构——每个类别对应一组y0(起始高度)和y1(结束高度),这样D3才能知道怎么把柱子一层叠一层画出来。

3. d3.transpose():实现堆叠转分组的关键魔法

这个方法就是把数组的行和列互换,比如原数组是[[a1,a2],[b1,b2],[c1,c2]],转置后变成[[a1,b1,c1],[a2,b2,c2]]。在Stacked-to-Grouped的过渡动画里,转置是为了把“按类别分层堆叠”的结构,转换成“按日期分组并排”的结构,让柱子能平滑从堆叠变成并排。


二、适配你的自定义数据的分步操作

你的数据格式大概是这样的:

data = [{"id":"1", "month":"4", "total":"1", "year":"2012", "name":"A"}, {"id":"2", "month":"4", "total":"3", "year":"2012", "name":"B"}, ...]

1. 第一步:先把数据整理成D3能直接用的样子

首先要把yearmonth合并成可排序的日期对象,同时按日期分组,把同一个日期下不同nametotal整理好:

// 把year+month转成标准日期对象(注意月份是0开始的,所以要减1)
const processedData = data.map(d => ({
  date: new Date(d.year, d.month - 1),
  name: d.name,
  total: +d.total // 必须把字符串转成数字,不然scale会出错!
}));

// 按日期排序,保证时间轴是正序的
processedData.sort((a, b) => a.date - b.date);

// 提取所有唯一的name,作为分组/堆叠的类别
const names = [...new Set(processedData.map(d => d.name))];

// 把数据转成「每个日期对应一个对象,键是name,值是total」的结构
// 没有对应name的数据填0,避免布局断层
const groupedByDate = d3.group(processedData, d => d.date);
const finalData = Array.from(groupedByDate, ([date, values]) => {
  const dateObj = { date };
  names.forEach(name => {
    const match = values.find(v => v.name === name);
    dateObj[name] = match ? match.total : 0;
  });
  return dateObj;
});

2. 第二步:用d3.stack()生成堆叠数据(如果要做过渡动画)

如果你想保留原示例的堆叠转分组动画,先让stack布局处理数据:

// 初始化stack布局,指定要堆叠的类别是各个name
const stackLayout = d3.stack().keys(names);
// 生成堆叠后的数据结构
const stackedData = stackLayout(finalData);

这时候stackedData里每个元素对应一个name,每个元素下的数组是该name在每个日期的[y0, y1]值(y0是堆叠的起始高度,y1是结束高度)。

3. 第三步:实现分组布局(或堆叠转分组的过渡)

直接画分组柱子(不需要过渡)

如果只想展示分组状态,不用stack,直接计算每个柱子的位置:

  • 分组柱子宽度 = 单日期总宽度 / 类别数量(names.length
  • 每个类别的柱子x位置 = 日期对应的x坐标 + 类别索引 * 分组宽度

代码大概是这样:

// 定义x轴的时间比例尺
const xScale = d3.scaleTime()
  .domain(d3.extent(finalData, d => d.date))
  .range([margin.left, width - margin.right]);

// 定义y轴的线性比例尺
const yScale = d3.scaleLinear()
  .domain([0, d3.max(finalData, d => d3.sum(names, name => d[name]))])
  .range([height - margin.bottom, margin.top]);

// 每个日期的总柱子宽度(根据你的画布宽度调整)
const dateBarWidth = (width - margin.left - margin.right) / finalData.length;
// 每个分组柱子的宽度(留2px间隙)
const groupBarWidth = dateBarWidth / names.length - 2;

// 按日期创建分组容器
const dateGroups = svg.selectAll('.date-group')
  .data(finalData)
  .enter()
  .append('g')
  .attr('class', 'date-group')
  .attr('transform', d => `translate(${xScale(d.date)}, 0)`);

// 在每个日期分组里画各个name的柱子
dateGroups.selectAll('.group-bar')
  .data(d => names.map(name => ({ name, value: d[name] })))
  .enter()
  .append('rect')
  .attr('class', 'group-bar')
  .attr('x', (d, i) => i * (groupBarWidth + 2)) // 加2是间隙
  .attr('y', d => yScale(d.value))
  .attr('width', groupBarWidth)
  .attr('height', d => height - margin.bottom - yScale(d.value))
  .attr('fill', d => d3.schemeCategory10[names.indexOf(d.name)]); // 按name分配颜色

做堆叠转分组的过渡动画

如果要实现原示例的切换效果,监听按钮点击,用transition切换柱子的位置和高度:

// 假设你有个切换按钮
d3.select('#toggle-btn').on('click', () => {
  svg.selectAll('.stack-bar')
    .transition()
    .duration(1000)
    .attr('x', (d, i) => {
      // 计算分组后的x位置
      const dateX = xScale(d.data.date);
      return dateX + names.indexOf(d.key) * (groupBarWidth + 2);
    })
    .attr('y', d => yScale(d[1] - d[0])) // 分组后柱子从y=value的位置开始
    .attr('height', d => height - margin.bottom - yScale(d[1] - d[0]));
});

这里d[1]-d[0]就是该name在当前日期的total值(堆叠的高度差)。

4. 第四步:时间轴和颜色优化

  • 时间轴直接用d3.axisBottom(xScale)生成,自动处理日期刻度:
svg.append('g')
  .attr('transform', `translate(0, ${height - margin.bottom})`)
  .call(d3.axisBottom(xScale).ticks(d3.timeMonth.every(1))); // 按月显示刻度
  • 颜色可以用d3.scaleOrdinal()做更规整的映射,避免索引出错:
const colorScale = d3.scaleOrdinal()
  .domain(names)
  .range(d3.schemeCategory10); // 用内置的10色方案,或者自定义颜色数组

// 画柱子时直接用:
.attr('fill', d => colorScale(d.name))

三、踩过的坑提醒你避一避

  • 数据类型别错total一定要转成数字,不然D3的比例尺会把字符串当文本处理,直接崩
  • 日期别搞反:JS的Date月份是0开始的,所以month要减1,不然4月会变成5月
  • 缺失数据要补0:同一个日期下没有某个name的数据时,一定要填0,不然堆叠或分组布局会出现空白断层
  • 柱子宽度要算准:分组柱子的宽度要考虑总画布宽度和日期数量,避免溢出x轴

内容的提问来源于stack exchange,提问作者Jamie

火山引擎 最新活动