Line and area

The two continuous marks — a line traced through the rows, and the fill beneath it — plus the curve and the labels that go with them.

line traces the rows in order; area fills beneath them. They take the same x and y fields, split into series the same way, and read the same scales, so they are usually reached for together rather than as alternatives.

Areas and gradients

area fills the space between the rows and the value axis' zero. It draws no edge of its own, so a filled line chart is two marks over the same rows — which is what keeps line's options available for the edge.

withGradient fills it with the series' color fading to nothing instead of a flat wash. fillOpacity sets how strong the fill is where it is strongest: the top of a gradient, or the whole of a flat area.

Loading editor

An area splits into stacked bands with series, and each band gets its own gradient in that series' color.

Fills are opaque unless asked otherwise. The palette's colors clear 3:1 against a surface but not against each other, so a translucent band sitting over another one lands at a color nothing has checked. What a fill is doing varies too much to guess at, so fillOpacity is where you say.

Separating a stack

Adjacent chart colors don't meet contrast against each other, so stacked areas and stacked bars leave a 1px transparent gap between neighboring segments — a gap rather than a drawn border, so it adds no color of its own, holds on any background, and can't be mistaken for data.

stackGap changes it, on either mark:

// Wider, for a chart read from across a room.
bar({ data: rows, x: 'month', y: 'delivered', series: 'channel', stackGap: 2 });
// Flush, the way it drew before.
area({ data: rows, x: 'month', y: 'delivered', series: 'channel', stackGap: 0 });

Only the edge where two segments meet moves. The one sitting on the value axis' zero stays put, so nothing floats off the baseline — which also means this has no effect on a single series, or on layout: 'grouped', where every bar already starts at zero.

Where a gradient fades out

A gradient runs the full height of the area it fills — from the highest point down to the area's baseline at zero, not down to the bottom of the plot.

On an ordinary chart those are the same place. They stop being the same place as soon as the value axis starts above zero — see Keeping zero off the axis for the two ways to get there. Zero then sits somewhere below the plot, the fade finishes down there out of sight, and only its top slice shows — so the fill reads as denser the further the values sit from nothing. A series hovering near zero fades away almost completely; one far above it looks close to solid.

That is deliberate, and it is the reason to reach for a gradient on a chart with no visible value axis: the density of the fill carries the magnitude that the missing axis would have.

Smoothing

line and area take a curve. 'smooth' rounds the joins into a spline instead of meeting them at a corner:

Loading editor

The curve runs through every row and adds no peak or trough the rows don't have. It says nothing about what happened between two rows, though: that part of the path is drawn rather than measured.

Use it where the shape is what matters — a sparkline on a metric card, a filled trend behind a headline number. Leave it off where individual values matter more than the trend.

A missing value still breaks the shape rather than being curved across.

Dense rows gain nothing from it. Once points land a pixel apart there are no joins left to round.

Marking a stretch as provisional

zones strokes part of a line differently from the rest. Each zone takes over at a value on the horizontal axis and holds until the next one starts; the line's own styling covers everything before the first. So one zone is all it takes to say "past here the numbers aren't settled yet":

line({
	data: rows,
	x: 'date',
	y: 'opens',
	zones: [{ from: rows.at(-3).date, strokeDasharray: '6 8' }],
});

The usual reason is a series whose last bins cover a period the date range only partly includes — a week or a month still being filled in. Those bins are real readings of an incomplete period, so leaving them off the chart hides a trend and drawing them plainly overstates a dip.

A zone styles one series rather than adding another. The line stays a single series: the legend names it once, the tooltip reports it once, and the keyboard walks it as one. So this is where to reach when part of a line should look different — not two marks over two slices of the rows, which is two series and reads as two.

An area layered under a zoned line still lines up with its edge exactly:

Loading editor

A zone can also set color, and several can be laid along one line. Along the length of a line, reach for the dash first: the legend names a series by one color, so a series drawn in two says something the legend can't, while a dash reads as the same series qualified.

A zone changes the color and the dash and nothing else. Stroke width is set once, for the whole line.

A zone is a range, and to bounds the other end of it. So the earlier stretch takes one zone, not a pair:

// Dashed up to the cutoff, ordinary after it.
zones: [{ to: cutoff, strokeDasharray: '6 8' }];

// Both ends: one stretch picked out, the line's own stroke either side.
zones: [{ from: start, to: end, color: categorical(3)[1] }];

Ranges are half open — a zone covers its from and stops short of its to — so two that meet at a value divide the line there with nothing counted twice:

zones: [
	{ to: 100, color: semantic('success', 1)[0] },
	{ from: 100, color: semantic('critical', 1)[0] },
];

Where zones overlap, the one declared later wins. Everywhere none reaches, the line keeps its own stroke.

Picking out a threshold

zoneAxis: 'y' measures the boundaries up the plot instead of along it, which turns a zone from a stretch into a threshold: the parts of the line above a value are styled and the rest is left alone.

line({
	data: rows,
	x: 'date',
	y: 'errorRate',
	zoneAxis: 'y',
	zones: [{ from: 5, color: semantic('critical', 1)[0] }],
});

Here a color is the right choice, where along the length of a line a dash usually is. Going over a limit is a state the reader already has a color for, and the crossing is what the chart is about; a dash would only say the readings were qualified.

A series can cross a threshold as often as it likes: every stretch above the value is styled, not just the first. The style changes at the threshold itself rather than at the nearest row, which matters here — a series rarely goes over a limit exactly at a reading, and the crossing is drawn where it happened.

Loading editor

zoneAxis applies to the whole line: its zones measure on one axis or the other, never both.

Zones and interaction

A zoned line is still one series, so the tooltip and the legend read as one: one row, named once, whichever zone the pointer is over. The swatch beside that row takes the color of the zone the row falls in, so it says what the stroke there says.

Zones bounded by categories are the exception — the swatch is resolved from the rows' own values, and a category has no order to compare against a boundary, so those rows keep the series' color.

A colored zone keeps its color under focus. withHoverColor swaps a series to its hover color while the pointer is on the chart; a zone with a color of its own moves to its own hover color instead of the line's.

A zone colored from the data-viz palette gets that hover color for free, since those colors carry a vetted hover twin. Set hoverColor where the zone's color is a plain string, which names one color and has no twin to find:

zones: [
	// A palette color brings its own hover color.
	{ from: 5, color: semantic('critical', 1)[0] },
	// A plain string has none, so name the pair.
	{ from: 20, color: 'crimson', hoverColor: 'darkred' },
];

A zone that only dashes has no color of its own, so it follows the line's hover color along with the rest of the series.

Labelling points

A chart drawn without a value axis has nothing to read its shape against, so its first and last values have to be written somewhere inside the plot. Wherever they go, they land on top of the series.

autoPositionedLabel writes them and works out where they fit. Each label goes above its point, below it, or clear of the whole run of line beneath it — whichever is free — and never leaves the plot.

Loading editor

The labels are decorative: they repeat values the line already exposes to the keyboard and to tooltips, so they are hidden from screen readers rather than read out a second time.

A label stays near what it labels: it moves at most a third of the plot's height to get out of the way. Where nothing clear is within that — a short plot whose series runs its whole height, most often — it sits on the line beside its point rather than somewhere clear but too far off to attribute.

Shorter text has more places to fit, so how the value is written is the lever worth reaching for first. On a 96px plot, 100% finds clear space where 100.0% doesn't.

Two things it deliberately doesn't do. It doesn't move labels apart from each other — only away from the line — so at: 'all' on a series dense enough for the text to collide will overlap. And it only avoids the line of its own rows, not other marks on the chart.

For a mark that needs different placement rules, resolveLabelPlacement is the same geometry on its own, over plain pixel numbers: give it a point, the extent of whatever it should clear, and the plot's bounds, and it returns the y to use. maxPush changes how far it may move, and isOverlapping reports when it landed on the line.