Stacked Area Chart
Multiple series stacked as filled areas with smooth Catmull-Rom curves. Uses d3-shape's stack() layout to compute y0/y1 pairs and a reusable AxisChart for axes. Includes a crosshair tooltip for multi-series inspection.
Live Preview
Full Source
js
import { select, pointer } from 'd3-selection';
import { D3Blueprint } from 'd3-blueprint';
import { scaleLinear, scaleOrdinal } from 'd3-scale';
import { max } from 'd3-array';
import { stack, area, curveCatmullRom } from 'd3-shape';
import { AxisChart } from './charts/AxisChart.js';
import { tooltipPlugin } from './plugins/Tooltip.js';
const COLORS = ['steelblue', '#e45858', '#50a060'];
const KEYS = ['Desktop', 'Mobile', 'Tablet'];
const MARGIN = { top: 20, right: 80, bottom: 30, left: 45 };
class StackedAreaChart extends D3Blueprint {
initialize() {
this.xScale = scaleLinear();
this.yScale = scaleLinear();
this.colorScale = scaleOrdinal().domain(KEYS).range(COLORS);
this.areaFn = area().curve(curveCatmullRom);
this.stacked = [];
this.chart = this.base
.append('g')
.attr('transform', `translate(${MARGIN.left},${MARGIN.top})`);
this.attach('axes', AxisChart, this.chart);
// Layer 1: one path per stacked series
const areasGroup = this.chart.append('g').attr('class', 'areas');
this.layer('areas', areasGroup, {
dataBind: (selection) => {
return selection.selectAll('path').data(this.stacked, (d) => d.key);
},
insert: (selection) => selection.append('path'),
events: {
enter: (selection) => {
selection
.attr('fill', (d) => this.colorScale(d.key))
.attr('fill-opacity', 0.7)
.attr('d', (d) => this.areaFn(d));
},
'merge:transition': (transition) => {
transition
.duration(800)
.attr('fill', (d) => this.colorScale(d.key))
.attr('d', (d) => this.areaFn(d));
},
'exit:transition': (transition) => {
transition.duration(200).attr('opacity', 0).remove();
},
},
});
// Layer 2: inline labels at the end of each series
const labelsGroup = this.chart.append('g').attr('class', 'labels');
this.layer('labels', labelsGroup, {
dataBind: (selection) => {
return selection.selectAll('text').data(KEYS);
},
insert: (selection) => selection.append('text'),
events: {
merge: (selection) => {
selection
.attr('dy', '0.35em')
.attr('font-size', '11px')
.attr('font-weight', '500')
.attr('fill', (d) => this.colorScale(d))
.text((d) => d);
this._positionLabels(selection);
},
},
});
const innerWidth = 600 - MARGIN.left - MARGIN.right;
const innerHeight = 400 - MARGIN.top - MARGIN.bottom;
// Crosshair + overlay for mouse tracking
this.crosshair = this.chart
.append('line')
.attr('y1', 0)
.attr('y2', innerHeight)
.attr('stroke', '#999')
.attr('stroke-dasharray', '4,3')
.style('display', 'none');
this.tooltip = tooltipPlugin(this.chart);
this.overlay = this.chart
.append('rect')
.attr('width', innerWidth)
.attr('height', innerHeight)
.attr('fill', 'none')
.style('pointer-events', 'all');
}
_positionLabels(sel) {
const lastIdx = this.stacked[0]?.length - 1;
if (lastIdx == null) return;
sel.each((key, i, nodes) => {
const series = this.stacked.find((s) => s.key === key);
if (!series) return;
const d = series[lastIdx];
const midY = (this.yScale(d[0]) + this.yScale(d[1])) / 2;
select(nodes[i])
.attr('x', this.xScale(lastIdx) + 8)
.attr('y', midY);
});
}
preDraw(data) {
const innerWidth = 600 - MARGIN.left - MARGIN.right;
const innerHeight = 400 - MARGIN.top - MARGIN.bottom;
this.stacked = stack().keys(KEYS)(data);
const maxVal = max(this.stacked[this.stacked.length - 1], (d) => d[1]);
this.xScale.domain([0, data.length - 1]).range([0, innerWidth]);
this.yScale.domain([0, maxVal * 1.05]).range([innerHeight, 0]);
this.areaFn
.x((d) => this.xScale(d.data.x))
.y0((d) => this.yScale(d[0]))
.y1((d) => this.yScale(d[1]));
this.attached.axes.config({
xScale: this.xScale,
yScale: this.yScale,
innerWidth,
innerHeight,
duration: 800,
xTickCount: 6,
yTickCount: 5,
});
}
postDraw(data) {
const { xScale, yScale, colorScale, tooltip, crosshair, overlay } = this;
overlay
.on('mousemove', function (event) {
const [mx] = pointer(event, this);
const idx = Math.max(0, Math.min(Math.round(xScale.invert(mx)), data.length - 1));
const cx = xScale(idx);
crosshair.attr('x1', cx).attr('x2', cx).style('display', null);
const lines = KEYS.map((key) => ({
text: `${key}: ${data[idx][key]}`,
color: colorScale(key),
}));
tooltip.show(cx, yScale(data[idx].Desktop + data[idx].Mobile + data[idx].Tablet), lines);
})
.on('mouseleave', () => {
crosshair.style('display', 'none');
tooltip.hide();
});
}
}Usage
js
import { select } from 'd3-selection';
const chart = new StackedAreaChart(
select('#chart').append('svg').attr('width', 600).attr('height', 400),
);
// Each row has an x index plus one value per series key
const data = Array.from({ length: 12 }, (_, i) => ({
x: i,
Desktop: 20 + Math.round(Math.random() * 40),
Mobile: 10 + Math.round(Math.random() * 30),
Tablet: 5 + Math.round(Math.random() * 15),
}));
await chart.draw(data);Key Takeaways
stack()computes y0/y1 pairs: D3's stack layout transforms flat rows into nested arrays where each point has[y0, y1]boundaries, so the areas stack on top of each other without overlap.area()uses y0/y1 for stacking: unlike a simple area chart that usesy0(baseline), the stacked version reads bothy0andy1from the stack layout output.- Same crosshair pattern as the multiline chart: a transparent overlay captures
mousemove, snaps to the nearest data index, and shows all series values in the tooltip. dataBindreadsthis.stacked: sincepreDraw()stores the computed stack on the instance, the layer ignores the raw data argument and reads from the precomputed property.- Inline labels: positioned at the midpoint of each series at the rightmost data point, with extra right margin to accommodate them.