Skip to content

Alluvial Diagram

An alluvial (Sankey-style) diagram showing flows between energy sources and end uses. No AxisChart is needed because this chart computes its own node positions proportional to flow volume and draws cubic-bezier ribbon paths between source and target columns.

Live Preview

Full Source

js
import { select } from 'd3-selection';
import { D3Blueprint } from 'd3-blueprint';
import { scaleOrdinal } from 'd3-scale';
import { tooltipPlugin } from './plugins/Tooltip.js';

const WIDTH = 500;
const HEIGHT = 400;
const MARGIN = { top: 20, right: 120, bottom: 20, left: 120 };
const NODE_WIDTH = 16;
const NODE_PAD = 8;
const COLORS = ['steelblue', '#e45858', '#50a060', '#e8a838'];

class AlluvialDiagram extends D3Blueprint {
  initialize() {
    this.colorScale = scaleOrdinal().range(COLORS);

    this.chart = this.base
      .append('g')
      .attr('transform', `translate(${MARGIN.left},${MARGIN.top})`);

    const innerWidth = WIDTH - MARGIN.left - MARGIN.right;
    const innerHeight = HEIGHT - MARGIN.top - MARGIN.bottom;

    // Layer 1: link ribbons
    const linksGroup = this.chart.append('g').attr('class', 'links');

    this.layer('links', linksGroup, {
      dataBind: (selection) => {
        return selection.selectAll('path').data(this.computedLinks, (d) => `${d.source}-${d.target}`);
      },
      insert: (selection) => selection.append('path'),
      events: {
        enter: (selection) => {
          selection
            .attr('d', (d) => this.ribbonPath(d))
            .attr('fill', (d) => this.colorScale(d.source))
            .attr('fill-opacity', 0)
            .attr('stroke', 'none');
        },
        'enter:transition': (transition) => {
          transition.duration(600).attr('fill-opacity', 0.4);
        },
        'merge:transition': (transition) => {
          transition
            .duration(600)
            .attr('fill-opacity', 0.4)
            .attr('d', (d) => this.ribbonPath(d))
            .attr('fill', (d) => this.colorScale(d.source));
        },
        'exit:transition': (transition) => {
          transition.duration(200).attr('fill-opacity', 0).remove();
        },
      },
    });

    // Layer 2: nodes (source + target rects)
    const nodesGroup = this.chart.append('g').attr('class', 'nodes');

    this.layer('nodes', nodesGroup, {
      dataBind: (selection) => {
        return selection.selectAll('rect').data(this.computedNodes, (d) => `${d.side}-${d.name}`);
      },
      insert: (selection) => selection.append('rect'),
      events: {
        enter: (selection) => {
          selection
            .attr('x', (d) => d.x)
            .attr('y', (d) => d.y)
            .attr('width', NODE_WIDTH)
            .attr('height', (d) => d.height)
            .attr('fill', (d) => d.side === 'source' ? this.colorScale(d.name) : '#999')
            .attr('rx', 2)
            .attr('opacity', 0);
        },
        'enter:transition': (transition) => {
          transition.duration(600).attr('opacity', 1);
        },
        'merge:transition': (transition) => {
          transition
            .duration(600)
            .attr('opacity', 1)
            .attr('y', (d) => d.y)
            .attr('height', (d) => d.height);
        },
        'exit:transition': (transition) => {
          transition.duration(200).attr('opacity', 0).remove();
        },
      },
    });

    // Layer 3: labels beside each node
    const labelsGroup = this.chart.append('g').attr('class', 'labels');

    this.layer('labels', labelsGroup, {
      dataBind: (selection) => {
        return selection.selectAll('text').data(this.computedNodes, (d) => `${d.side}-${d.name}`);
      },
      insert: (selection) => selection.append('text'),
      events: {
        enter: (selection) => {
          selection
            .attr('x', (d) => d.side === 'source' ? d.x - 6 : d.x + NODE_WIDTH + 6)
            .attr('y', (d) => d.y + d.height / 2)
            .attr('dy', '0.35em')
            .attr('text-anchor', (d) => d.side === 'source' ? 'end' : 'start')
            .attr('font-size', '11px')
            .text((d) => d.name);
        },
        'merge:transition': (transition) => {
          transition
            .duration(600)
            .attr('y', (d) => d.y + d.height / 2);
        },
        'exit:transition': (transition) => {
          transition.duration(200).attr('opacity', 0).remove();
        },
      },
    });

    this.tooltip = tooltipPlugin(this.chart);
  }

  ribbonPath(d) {
    const cx = (d.x0 + d.x1) / 2;
    return `M${d.x0},${d.y0} C${cx},${d.y0} ${cx},${d.y1} ${d.x1},${d.y1} ` +
      `L${d.x1},${d.y1b} C${cx},${d.y1b} ${cx},${d.y0b} ${d.x0},${d.y0b} Z`;
  }

  preDraw(data) {
    const innerWidth = WIDTH - MARGIN.left - MARGIN.right;
    const innerHeight = HEIGHT - MARGIN.top - MARGIN.bottom;

    const sourceTotals = {};
    const targetTotals = {};
    data.forEach((d) => {
      sourceTotals[d.source] = (sourceTotals[d.source] || 0) + d.value;
      targetTotals[d.target] = (targetTotals[d.target] || 0) + d.value;
    });

    const sources = Object.keys(sourceTotals);
    const targets = Object.keys(targetTotals);
    const totalFlow = Object.values(sourceTotals).reduce((a, b) => a + b, 0);
    this.colorScale.domain(sources);

    const maxSlots = Math.max(sources.length, targets.length) - 1;
    const availableHeight = innerHeight - maxSlots * NODE_PAD;

    // Build source and target node arrays with y positions ...
    // Compute link ribbon coordinates ...
    // (see live demo source for full layout logic)
  }

  postDraw() {
    const tooltip = this.tooltip;
    this.chart.selectAll('.links path')
      .on('mouseenter', function (event, d) {
        select(this).attr('fill-opacity', 0.7);
        tooltip.show((d.x0 + d.x1) / 2, (d.y0 + d.y1) / 2, [
          { text: `${d.source} → ${d.target}` },
          { text: `Flow: ${d.value}` },
        ]);
      })
      .on('mouseleave', function () {
        select(this).attr('fill-opacity', 0.4);
        tooltip.hide();
      });
  }
}

Usage

js
import { select } from 'd3-selection';

const chart = new AlluvialDiagram(
  select('#chart').append('svg').attr('width', 500).attr('height', 400),
);

await chart.draw([
  { source: 'Solar', target: 'Heating', value: 20 },
  { source: 'Solar', target: 'Lighting', value: 15 },
  { source: 'Wind', target: 'Industry', value: 25 },
  { source: 'Wind', target: 'Transport', value: 10 },
  { source: 'Hydro', target: 'Industry', value: 18 },
  { source: 'Hydro', target: 'Heating', value: 12 },
  { source: 'Gas', target: 'Heating', value: 30 },
  { source: 'Gas', target: 'Transport', value: 22 },
]);

Key Takeaways

  • Custom layout in preDraw(): node heights are proportional to total flow, and link ribbon coordinates are accumulated per-node so they stack without overlap.
  • Cubic-bezier ribbons: each link is a closed <path> with two cubic bezier curves (top and bottom edges), creating the signature alluvial "flow" shape.
  • Computed data in dataBind: unlike axis-based charts where scales transform raw values, alluvial layouts pre-compute all pixel positions and store them on the chart instance for each layer to consume.
  • Source-colored ribbons: links inherit their source node's color with reduced opacity, making it easy to trace where energy originates.
  • No AxisChart needed: the two-column layout with proportional node sizing replaces traditional axes entirely.