Series and legend

Splitting a mark into one line or bar group per value, naming those series in a legend, and coloring them.

Series

Give a mark a series field and it splits into one line or bar group per distinct value, each taking the next color from the palette. withLegend names them.

This is US unemployment by industry — 1,707 monthly observations across fourteen industries, rolled up per year:

Loading editor

Bars stack by default. layout: 'grouped' puts them side by side, which compares series against each other rather than comparing totals, and layout: 'nested' draws each series inside the one before it, for series that contain one another rather than adding up to something.

Stacking

A stack runs from the value axis' zero outward in the order the series are declared, so the first one sits against the axis. stackOrder: 'reversed' puts it at the far end instead:

bar({ data: rows, x: 'day', y: 'opens', series: 'client', stackOrder: 'reversed' });

Reach for it where the series are ranked and the reader scans from the biggest, so the long tail stays gathered against the axis. Colors are unaffected — the palette follows the declared order whichever way the stack runs.

stackOffset: 'share' measures each segment as its share of its category's total rather than as its own value, which is the hundred-percent stacked bar:

bar({ data: rows, x: 'day', y: 'opens', series: 'client', stackOffset: 'share' });

Every stack then fills the axis, and the chart compares composition rather than volume. The value axis reads in percent unless a format of your own says otherwise, and each row keeps its own number for the tooltip, which reads out both — 58.1% (42). A category whose rows sum to zero draws nothing.

A whole axis reads in shares or none of it does, and only the chart's first value axis can. Where another mark on the same axis is drawing its own values, or where the stack sits on a second value axis, stackOffset has no effect and the stack is drawn in its units like everything else.

Both options apply to area as well, and neither affects layout: 'grouped' or layout: 'nested'.

Nesting

layout: 'nested' is for subset metrics — sent, delivered and opened, or impressions, clicks and conversions — where each series is part of the one before it rather than part of a total. Declare them outermost first:

bar({ data: rows, x: 'day', y: 'messages', series: 'stage', layout: 'nested' });

Every series stands on the same category, each drawn narrower than the one around it and painted over it, so the funnel is what the reader sees. Each bar still measures its own value, so one legend and one shared tooltip carry every stage at its true figure.

Reach for it wherever stacking would be a lie and grouping would lose the point. Stacked segments would have to be differences rather than the metrics themselves, and side by side, three bars are three quantities that happen to be near each other with nothing saying one contains the next.

thickness scales the whole ladder where a chart wants narrower bars. Nothing that describes a stack applies — stackOrder, stackOffset and stackGap are all about an edge two series share, and nested bars share none.

Naming a single series

Not every chart splits its rows. A chart can be one series per mark instead — bars for the volume, a line for the trend over them — where each mark is already a single thing and the name belongs to the mark rather than to any row. seriesName gives it that name:

bar({ data: rows, x: 'month', y: 'delivered', seriesName: 'Delivered' });
line({ data: rows, x: 'month', y: 'openRate', seriesName: 'Open rate' });

A named mark is a series like any other: it takes the next color from the palette, gets a legend entry, names itself in the tooltip, and can be hidden from the legend along with the rest.

Use series wherever the rows carry the split themselves, and seriesName where the mark is the series. A mark given both is split by series.

Hiding series

The legend is interactive: press an entry to take its series off the plot, press it again to bring it back. The axes recompute from what is left, so series that a larger one was flattening become readable.

Loading editor

Every series can be hidden, which leaves an empty plot.

hiddenSeries says which series start hidden. Pass onHiddenSeriesChange alongside it to own the set — to persist what the reader hid, or to keep two charts in step:

const [hiddenSeries, setHiddenSeries] = useState(['SMS']);

<Chart
	ariaLabel="Messages delivered per month by channel"
	marks={marks}
	withLegend={true}
	hiddenSeries={hiddenSeries}
	onHiddenSeriesChange={setHiddenSeries}
/>;

A series is named by the string its series accessor produced, which is what its legend entry reads.

Where the legend sits

legendPlacement moves the legend. 'top' and 'bottom' are a band across the chart, with entries packed along it and wrapped onto as many rows as they need. 'left' and 'right' are a column beside the plot, one entry per row.

Reach for a column when the chart has a handful of series with names too long to sit side by side, or when the plot is round. It costs the plot the width the column takes, so on a narrow chart a band is still the better trade.

legendValueFormat writes a number beside each name. It is called once per series with that series' values added together, the total across every series, and the share of it — so a column of names becomes a small table ranking them. The totals cover every series the chart was given, so the figures stay put when the reader takes one off the plot.

A drawing that is already a proportion of what it plots is the exception — a donut, and a stack with stackOffset: 'share'. There the figures cover the series still on the plot and a hidden one reads as zero, so hiding a series re-proportions them the way it re-proportions the slices or the columns, and each figure keeps matching the shape beside it. A gauge measures against the total it declared, which is what its ring is allocated over.

Loading editor

A column reserves its width from the chart, outside whatever the axis on that side is already using — the plot narrows, the guides stay where they were.

Color

Series take the data-viz-categorical tokens in order, so a chart with no color of its own is already on-palette, and those slots carry hover twins already.

To give a mark a meaning-carrying color, set color from a data-viz helper — the helpers pick the right step for a palette of that size, which reaching for a numbered token by hand skips. color accepts either form the helpers give you:

  • the color object itself (semantic('success', 1)[0]) — preferred, because it carries both the base color and its hover twin, which is what withHoverColor repaints with.
  • a plain color string (semantic('success', 1)[0].token, or any CSS color) — a single color with no twin, so withHoverColor has nothing to swap to and leaves the mark alone.
import { semantic, sequential } from '@customerio/pluma-components/data-viz';

const marks = [
	// The object form: hover-capable.
	bar({
		data: delivered,
		x: 'month',
		y: 'count',
		color: semantic('success', 1)[0],
		withHoverColor: true,
	}),
	// A string is fine where the mark never needs a hover state.
	line({
		data: target,
		x: 'month',
		y: 'count',
		color: sequential('grey', 1)[0].token,
	}),
];

A colored mark is out of the palette's hand-out, not out of the legend. The series it names still gets an entry, and that entry's swatch is painted the color the mark was given — a key drawing a color the plot never used would contradict the thing it labels. Where two marks draw one series in different colors there is nothing a single swatch can honestly say, so the palette's color stands.

When the whole palette is the thing being replaced — a chart of statuses, say — pass palette rather than coloring each mark:

import { semantic } from '@customerio/pluma-components/data-viz';

<Chart
	ariaLabel="Deliveries by status"
	marks={marks}
	palette={[semantic('success', 1)[0].token, semantic('caution', 1)[0].token, semantic('critical', 1)[0].token]}
/>;

The guides — text, grid and background — are CSS variables, so they are set the way any Pluma component variable is: through vanilla-extract, off the component's own exports, never by writing the variable name as a string.

import { style } from '@vanilla-extract/css';
import { PlumaChart } from '@customerio/pluma-components/css/components';
import { themeContractVars as themeVars } from '@customerio/pluma-components/css';

export const denseChart = style({
	vars: {
		[PlumaChart.vars.gridColor]: themeVars.color['border-minimal'],
	},
});

PlumaChart.vars also carries textColor, mutedTextColor, backgroundColor, crosshairColor, and series1Color through series10Color with a series1HoverColor through series10HoverColor beside them, so an individual palette slot — or its hover state — can be overridden from a container when setting it on the mark isn't possible.