如何在 D3 中动态呈现水平堆积条形图

How to dynamically render horizontal stacked bar charts in D3

出于两个原因,我要说得有点冗长:

  1. 为了证明我已经努力尝试解决我提出的问题,因为互联网上有太多短暂的东西
  2. D3 很棒,因为它很复杂,很难完全理解。最近的主要版本强调了数据连接的使用,旨在简化一般更新模式。

因此,这可能有点写意,但我真的很想了解我的问题的答案,所以请耐心等待。

上下文

我想创建一个动态堆叠水平条形图,以可视化 single transferable voting 过程的不同阶段(准确地说是在苏格兰的地方选举中)。

D3 堆栈

这与 Bostock 的 Stacked Bar Chart, Horizontal 非常相似,它显示了按年龄组划分的美国各州的人口。

毫不奇怪,Bostock 使用 D3 的堆栈 生成器,我很失望地发现它在 Y 轴上将 SVG 矩形组织成组(svg g 元素)。进行以下基于 Bostock 的 Stacked Bar Chart, Horizontal 示例的实验:

const data = [
    {month: "Jan", apples: 3840, bananas: 1920, cherries: 960, dates: 400},
    {month: "Feb", apples: 1600, bananas: 1440, cherries: 960, dates: 400},
    {month: "March", apples:  640, bananas:  960, cherries: 640, dates: 400},
    {month: "Apr", apples:  3120, bananas:  1480, cherries: 640, dates: 400}
];

以上数据使用与 Bostock 示例中相同的方法进行旋转,并传递给 StackedBarChart(),我在其中添加了一个过渡,从而导致呈现以下内容:

在上面的例子中,数据绑定不是按月份,而是按水果。

最终状态很好,但动态(转换后的)数据更改会很困难。

这是一个有些复杂的领域。我毫不掩饰地承认我不理解 Bostock 对堆栈的使用,取自他上面提到的例子:

// Compute a nested array of series where each series is [[x1, x2], [x1, x2],
// [x1, x2], …] representing the x-extent of each stacked rect. In addition,
// each tuple has an i (index) property so that we can refer back to the
// original data point (data[i]). This code assumes that there is only one
// data point for a given unique y- and z-value.
const series = d3.stack()
  .keys(zDomain)
  .value(([, I], z) => X[I.get(z)])
  .order(order)
  .offset(offset)
(d3.rollup(I, ([i]) => i, i => Y[i], i => Z[i]))
.map(s => s.map(d => Object.assign(d, {i: d.data[1].get(s.key)})));

嵌套数据连接

也许有人可以进一步阐明以上内容。也许我不了解堆栈(很可能;)。也许如果图表是垂直的而不是水平的,事情会更容易吗?不知道。

我决定放弃 D3 Stacks,转而使用数据连接嵌套数据,有点回归基础。

general update pattern are helpful along with reading about the newish join method上重复阅读Bostock近十年的历史post,旨在进一步抽象通用更新模式。-*

以下代码是对 documentation nested data join example 的改编(只要确保在某处加载了 d3 库):

    const svg = d3.select("body")
        .append("svg");

    function doD3() {
        svg
            .selectAll("g")
            .data(generate, (d) => d[0].id.split("_")[0])
            .join("g")
            .attr("transform", (d, i) => "translate(0, "  + i * 30 + ")")
            .selectAll("text")
            .data(d => d)
            .join(enter => enter
                    .append("text").attr("fill", "green"),
                update => update,
                exit => exit.remove())
            .attr("x", (d, i) => i * 50)
            .text(d => d.value);
    }

    function generateRow(rowId, cols) {
        let row = [];

        for(let i = 0; i < cols; i++) {
            row.push({"id": rowId + "_" + i, "value": highRandom()});
        }

        return row;

    }

    const lowRandom = d3.randomInt(3, 15);
    const highRandom = d3.randomInt(1e2, 1e5);

    function generate() {
        const cols = lowRandom();
        const rows = lowRandom();

        let matrix = [];

        for(let i = 0; i < rows; i++) {
            matrix.push(generateRow(i, cols));
        }

        return matrix;


    }

    window.setInterval(doD3, 2000);

可能是原始的,但旨在演示成功的数据绑定与关键功能。 generategenerateRow 函数共同生成具有 id 和值的随机对象矩阵。

每两秒调用一次。所有节点(文本)都在输入时呈现。我哪里不对?

感谢阅读,新年快乐:))

-* 不幸的是,我无法 post 链接到 JSFiddle,因为支持的最新 D3 版本是版本 5,我正在(可能不必要地)使用最新版本 7。Bostock 已经开始使用一个平台允许进行名为 Observable 的实验,但我发现这很混乱。

这是一个使用水果数据集的例子。该图表是动画的,因此一次显示一个水果的条形图。我通过为每个水果的条形图设置不同的过渡来做到这一点 delay

<!DOCTYPE html>
<html>

<head>
  <meta charset="UTF-8">
  <script src="https://d3js.org/d3.v7.js"></script>
</head>

<body>
  <div id="chart"></div>

  <script>
    // set up

    const margin = { top: 10, right: 10, bottom: 20, left: 40 };

    const width = 300 - margin.left - margin.right;
    const height = 200 - margin.top - margin.bottom;

    const svg = d3.select('#chart')
      .append('svg')
        .attr('width', width + margin.left + margin.right)
        .attr('height', height + margin.top + margin.bottom)
      .append('g')
        .attr('transform', `translate(${margin.left},${margin.top})`);

    // data

    const data = [
      {month: "Jan", apples: 3840, bananas: 1920, cherries: 960, dates: 400},
      {month: "Feb", apples: 1600, bananas: 1440, cherries: 960, dates: 400},
      {month: "March", apples:  640, bananas:  960, cherries: 640, dates: 400},
      {month: "Apr", apples:  3120, bananas:  1480, cherries: 640, dates: 400}
    ];

    const fruit = Object.keys(data[0]).filter(d => d != "month");
    const months = data.map(d => d.month);

    const stackedData = d3.stack()
        .keys(fruit)(data);

    const xMax = d3.max(stackedData[stackedData.length - 1], d => d[1]);

    // scales


    const x = d3.scaleLinear()
        .domain([0, xMax]).nice()
        .range([0, width]);

    const y = d3.scaleBand()
        .domain(months)
        .range([0, height])
        .padding(0.25);

    const color = d3.scaleOrdinal()
        .domain(fruit)
        .range(d3.schemeTableau10);

    // axes

    const xAxis = d3.axisBottom(x).ticks(5, '~s');
    const yAxis = d3.axisLeft(y);

    svg.append('g')
        .attr('transform', `translate(0,${height})`)
        .call(xAxis)
        .call(g => g.select('.domain').remove());

    svg.append("g")
        .call(yAxis)
        .call(g => g.select('.domain').remove());

    // draw bars

    // create one group for each fruit
    const layers = svg.append('g')
      .selectAll('g')
      .data(stackedData)
      .join('g')
        .attr('fill', d => color(d.key));

    // transition for bars
    const duration = 1000;
    const t = d3.transition()
        .duration(duration)
        .ease(d3.easeLinear);

    layers.each(function(_, i) {
      // this refers to the group for a given fruit
      d3.select(this)
        .selectAll('rect')
        .data(d => d)
        .join('rect')
          .attr('x', d => x(d[0]))
          .attr('y', d => y(d.data.month))
          .attr('height', y.bandwidth())
        .transition(t)
          // i is the index of this fruit.
          // this will give the bars for each fruit a different delay
          // so that the fruits will be revealed one at a time.
          // using .each() instead of a normal data join is needed
          // so that we have access to what fruit each bar belongs to.
          .delay(i * duration)
          .attr('width', d => x(d[1]) - x(d[0]));
    });


  </script>
</body>

</html>