Skip to examples
Bento / Kitchen sink
Bento / compositions

Charts

Ordinary product metrics: trends, breakdowns, shares. Every chart names itself, keeps its exact values available as text, and never turns a missing measurement into zero. Motion is data: every chart takes an animation prop, and reduced motion always wins. Point at a chart, or Tab into it and use the arrow keys, to read it position by position.

ChartFrame and LineChart

series · gaps · range change animates

Active workspaces

Workspaces with at least one sign-in in the month.

  • This year
  • Last year
Use the left and right arrow keys to read each position.
View data for Active workspaces by month
Active workspaces by month
Label This yearLast year
Oct 1,180840
Nov 1,242876
Dec 1,204910
Jan 1,310902
Feb 1,398954
Mar 1,4671,003
Apr 1,5201,041
May 1,6041,066
Jun 1,6881,102
Jul 1,7311,150
Aug 1,8401,162
Sep 1,9621,175

Change the range and the lines and axis move to the new data instead of jumping: charts tween their values, then redraw from the in-between values each frame.

The plot stretches; the text does not. The SVG scales to its container and the axis labels are HTML placed by percentage, so labels stay crisp at any width. Narrow, fewer x labels show.

Point at it, or Tab into it. The pointer shows every series' value at the nearest position; from the keyboard, ← and → step, Home and End jump, and Escape lets go, and each step is announced. Stacks add the total, percent stacks each share, and a gap reads “Not measured”.

Source src/lib/components/charts/doc.ts · src/lib/components/charts/chart-frame/doc.ts · src/lib/components/charts/chart-frame/ChartFrame.svelte · src/lib/components/charts/chart-frame/chart-frame.module.css · src/lib/components/charts/chart-legend/doc.ts · src/lib/components/charts/chart-legend/ChartLegend.svelte · src/lib/components/charts/_kernel/inspect.ts · src/lib/components/charts/_shared/ChartInspector.svelte · src/lib/components/charts/line-chart/doc.ts · src/lib/components/charts/line-chart/Lines.svelte · src/lib/components/charts/line-chart/LineChart.svelte · src/lib/components/charts/line-chart/AreaChart.svelte · src/lib/components/charts/_kernel/lines.ts

src/lib/components/charts/doc.ts

/**
 * charts — pictures of ordinary product metrics.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Rules for every member
 *
 * R1  Every chart has a required name (`label`), and its exact values are
 *     available as text: printed beside the marks, in a legend, or in a data
 *     table behind "View data".
 * R2  A missing or invalid measurement (null, NaN, ±Infinity) is never drawn
 *     as zero. It breaks a line, draws no column, and reads "Unavailable".
 *     Zero is a measurement and is drawn.
 * R3  No rows says "No data to display"; rows with nothing measured say
 *     "Measurements unavailable", and the table still lists the rows.
 * R4  Colour is four categorical slots, then neutral. The palette never
 *     cycles. Series can also differ by line style, which survives greyscale.
 * R5  Every chart takes `animation` (see lib/motion): an entrance for its
 *     marks, and an update timing for new data. Reduced motion always wins.
 * R6  Axis text stays at reading size at any width; narrow plots show fewer
 *     x labels rather than smaller ones.
 * R7  Numbers print in one fixed locale, so server and client agree.
 *
 * # Members
 *
 *     ChartFrame    figure: title, description, actions, chart, footer
 *     ChartLegend   which colour and line style is which
 *     LineChart     values over ordered positions; AreaChart fills to zero
 *                   or stacks
 *     ColumnChart   vertical columns, grouped or stacked
 *     BarChart      labelled horizontal bars with values printed
 *     Sparkline     a trend without axes, for cards and cells
 *     DonutChart    shares of one whole, with a value legend
 *
 *     PlotFrame     two numeric axes and a plot area, for the charts below
 *     Volcano       effect against significance, three states
 *     BubblePlot    two measures by position, a third by area
 *     RankedBar     columns sorted by the chart, values printed
 *     Matrix        a table of coloured cells, four kinds of cell
 *     ColourBar     the stepped scale a colour-coded chart uses
 *
 *     ProgressRing  one value against a total, ring or gauge, as a meter
 *     PieChart      a donut filled to the centre
 *     FunnelChart   stages with their conversions
 *     StackedBarChart  horizontal parts per row: stacked, percent, diverging
 *     ScatterPlot   two measures, series by colour and shape, quadrants
 *     ComboChart    columns and lines on one axis; ParetoChart
 *
 *     RadarChart    profiles across a handful of attributes, one scale
 *     BoxPlot       distributions: quartiles, whiskers, outliers
 *     WaterfallChart   a level, its changes, and where it lands
 *     CalendarHeatmap  days in week columns, four states
 *     SunburstChart    a hierarchy of shares, as rings
 *
 *     NetworkGraph  nodes and edges, six layouts, deterministic
 *     SankeyChart   quantities flowing through stages
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — how this implementation meets the contract.                 │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * Hand-drawn SVG over a pure kernel (_kernel: scales, encoding, formatting,
 * stacking), shared verbatim with the Svelte app. Cartesian plots draw into a
 * 0–1000 viewBox stretched with preserveAspectRatio="none"; strokes use
 * vector-effect: non-scaling-stroke, and points are zero-length round-capped
 * strokes, so nothing distorts. Axis labels are HTML positioned by percentage
 * (R6), thinned by a container query.
 *
 * Marks that animate carry data-mark; the motion runner finds them by it.
 */
export {};

src/lib/components/charts/chart-frame/doc.ts

/**
 * ChartFrame — a figure for one chart.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     title         REQUIRED
 *     description?  a line under the title
 *     actions?      beside the title: a range toggle, an export
 *     footer?       under the chart: source, caveat, definition
 *     level?        render the title as a heading of this level
 *     children      the chart
 *
 * # Behaviour
 *
 * R1  A figure whose caption is the title and description.
 * R2  The title is not a heading unless `level` is given; give one when the
 *     chart is a section of the page a reader would navigate to.
 * R3  Actions wrap under the title when there is no room.
 * R4  It assumes nothing about what draws the chart.
 */
export {};

src/lib/components/charts/chart-frame/ChartFrame.svelte

<script lang="ts">
	import type { Snippet } from 'svelte';
	import type { HTMLAttributes } from 'svelte/elements';
	import { Heading } from '$lib/components/typography/heading';
	import { Text } from '$lib/components/typography/text';
	import { cn } from '$lib/utils/cn';
	import styles from './chart-frame.module.css';

	/* A figure for one chart: title, description, actions, the chart, a footer.
	   It assumes nothing about what draws the chart. */
	let {
		title,
		description,
		actions,
		footer,
		level,
		class: className = '',
		children,
		...rest
	}: Omit<HTMLAttributes<HTMLElement>, 'title' | 'class'> & {
		/** What the chart shows. */
		title: string;
		description?: string;
		/** Beside the title: a range select, an export button. */
		actions?: Snippet;
		/** Under the chart: the source, a caveat, a definition. */
		footer?: Snippet | string;
		/** Render the title as a heading of this level, so it joins the outline. */
		level?: 1 | 2 | 3 | 4 | 5 | 6;
		class?: string;
		children: Snippet;
	} = $props();
</script>

<figure {...rest} class={cn(styles.root, className)}>
	<figcaption class={styles.header}>
		<div class={styles.heading}>
			{#if level}
				<Heading {level} size="sm">{title}</Heading>
			{:else}
				<span class={styles.title}>{title}</span>
			{/if}
			{#if description}<Text size="sm" tone="muted">{description}</Text>{/if}
		</div>
		{#if actions}<div class={styles.actions}>{@render actions()}</div>{/if}
	</figcaption>
	{@render children()}
	{#if footer}
		<div class={styles.footer}>
			{#if typeof footer === 'string'}{footer}{:else}{@render footer()}{/if}
		</div>
	{/if}
</figure>

src/lib/components/charts/chart-frame/chart-frame.module.css

@layer primitive {
	.root {
		display: grid;
		min-width: 0;
		align-content: start;
		gap: var(--space-6);
		margin: 0;
	}
	.header {
		display: flex;
		flex-wrap: wrap;
		align-items: start;
		justify-content: space-between;
		gap: var(--space-5);
	}
	.heading {
		display: grid;
		min-width: 0;
		gap: var(--space-2);
	}
	.title {
		color: var(--ink);
		font-size: var(--text-15);
		font-weight: var(--weight-strong);
	}
	.actions {
		display: flex;
		align-items: center;
		gap: var(--space-3);
	}
	.footer {
		padding-top: var(--space-4);
		border-top: 1px solid var(--line);
		color: var(--ink-3);
		font-size: var(--text-12);
	}
}

src/lib/components/charts/chart-legend/doc.ts

/**
 * ChartLegend — which colour, and which line style, is which.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     items    { key, label, color, line?, value?, note? }[]
 *     label?   names the list, default "Legend"
 *     layout?  "inline" (wraps) | "list" (one per row, values aligned)
 *
 * # Behaviour
 *
 * R1  A list; every item is named in text, so identity never rests on colour.
 * R2  An item with `line` shows a line in that style; otherwise a dot.
 * R3  `value` and `note` (a share) print at the row's end, in tabular figures.
 *
 * Charts with several series render their own legend; use this directly for
 * a legend shared by several charts.
 */
export {};

src/lib/components/charts/chart-legend/ChartLegend.svelte

<script lang="ts">
	import { cn } from '$lib/utils/cn';
	import { shapePath } from '../_kernel/encode';
	import styles from './chart-legend.module.css';
	import type { LegendItem } from './types';

	/* Which colour (and line style) is which. Identity never rests on colour
	   alone: every item is named in text. */
	let {
		items,
		label = 'Legend',
		layout = 'inline',
		class: className = ''
	}: {
		items: readonly LegendItem[];
		/** Names the list. */
		label?: string;
		/** Inline and wrapping, or one item per row with values aligned. */
		layout?: 'inline' | 'list';
		class?: string;
	} = $props();
</script>

<ul aria-label={label} data-layout={layout} class={cn(styles.root, className)}>
	{#each items as item (item.key)}
		<li class={styles.item} style:--series={item.color}>
			<svg class={styles.swatch} width={item.line ? 20 : 10} height="10" aria-hidden="true">
				{#if item.line}
					<line x1="1" y1="5" x2="19" y2="5" data-line={item.line} />
				{:else if item.shape}
					<path d={shapePath(item.shape, 3.6)} transform="translate(5 5)" />
				{:else}
					<circle cx="5" cy="5" r="4.5" />
				{/if}
			</svg>
			{item.label}
			{#if item.value !== undefined}
				<span class={styles.value}>
					{item.value}{#if item.note !== undefined}<span class={styles.note}
							>&nbsp;· {item.note}</span
						>{/if}
				</span>
			{/if}
		</li>
	{/each}
</ul>

src/lib/components/charts/_kernel/inspect.ts

import type { BoxStats } from './box';
import type { CartesianDatum } from './cartesian';
import { colorVar, seriesColor, type ChartSeries } from './encode';
import { formatPercent } from './format';
import type { RankedDatum } from './ranked';
import type { WaterfallBar } from './waterfall';
import { isValue, PLOT } from './scale';

/* Inspecting a chart over labelled positions: which position the pointer or
   the keyboard is on, and what to say about it. Pure; the frameworks only
   draw the crosshair and the card. */

export type ReadoutRow = {
	key: string;
	label: string;
	color: string;
	/** The value as formatted, or "Not measured". */
	text: string;
	missing: boolean;
};

export type Readout = {
	title: string;
	rows: ReadoutRow[];
	/** The position's total, for stacked charts. */
	total: string | null;
	/** A line under the rows: what the numbers are, or where the item sits. */
	note: string | null;
	/** The whole readout as one sentence, for a screen reader. */
	sentence: string;
};

const MISSING = 'Not measured';

/** A readout from its parts. The sentence says what the card shows, in the
 *  same order, unless the chart has a fuller one to say. */
export function describe(
	title: string,
	rows: ReadoutRow[],
	{
		total = null,
		note = null,
		sentence
	}: { total?: string | null; note?: string | null; sentence?: string } = {}
): Readout {
	const parts = rows.map((row) => `${row.label} ${row.text}`);
	if (total !== null) parts.push(`total ${total}`);
	if (note !== null) parts.push(note.toLowerCase());
	return {
		title,
		rows,
		total,
		note,
		sentence: sentence ?? `${title}: ${parts.join(', ')}.`
	};
}

/** A position's values, as the card shows them and a screen reader hears
 *  them. Values are the data's, never the drawing's: a percent-stacked chart
 *  reads the raw value and its share; a missing value says so. */
export function readout(
	datum: CartesianDatum,
	series: readonly ChartSeries[],
	{
		format,
		total = false,
		share = false,
		colors
	}: {
		format: (value: number) => string;
		/** Add the position's total: for stacks. */
		total?: boolean;
		/** Follow each value with its share of the total: for percent stacks. */
		share?: boolean;
		/** Each series' colour, when the chart does not number them in order. */
		colors?: readonly string[];
	}
): Readout {
	const measured = series.flatMap((entry) => {
		const value = datum.values[entry.key];
		return isValue(value) ? [value] : [];
	});
	const sum = measured.reduce((a, b) => a + b, 0);
	const rows = series.map((entry, index): ReadoutRow => {
		const value = datum.values[entry.key];
		const color = colors?.[index] ?? seriesColor(entry, index);
		if (!isValue(value))
			return {
				key: entry.key,
				label: entry.label,
				color,
				text: MISSING,
				missing: true
			};
		const text =
			share && sum > 0 ? `${format(value)} (${formatPercent(value / sum)})` : format(value);
		return { key: entry.key, label: entry.label, color, text, missing: false };
	});
	return describe(datum.label, rows, {
		total: total && measured.length ? format(sum) : null
	});
}

const signed = (format: (value: number) => string) => (value: number) =>
	value > 0 ? `+${format(value)}` : value < 0 ? `−${format(-value)}` : format(value);

const KIND_COLOR = {
	total: colorVar('neutral'),
	increase: 'var(--chart-pos)',
	decrease: 'var(--chart-neg)',
	unavailable: colorVar('neutral')
} as const;

/** A waterfall step: a level reads as itself; a change reads as the change
 *  and where it lands. Past an unmeasured change, levels are unknown. */
export function waterfallReadout(bar: WaterfallBar, format: (value: number) => string): Readout {
	const color = KIND_COLOR[bar.kind];
	const row = (key: string, label: string, value: number | null, sign = false) =>
		({
			key,
			label,
			color,
			text:
				value === null
					? key === 'to'
						? 'Unknown'
						: MISSING
					: sign
						? signed(format)(value)
						: format(value),
			missing: value === null
		}) satisfies ReadoutRow;
	return describe(
		bar.label,
		bar.kind === 'total'
			? [row('to', 'Level', bar.to)]
			: [row('value', 'Change', bar.value, true), row('to', 'Running total', bar.to)]
	);
}

/** A box plot group: per series, the median and the middle half; the
 *  sentence adds the whiskers and outliers. */
export function boxReadout(
	title: string,
	series: readonly ChartSeries[],
	stats: readonly (BoxStats | null)[],
	format: (value: number) => string
): Readout {
	const rows = series.map((entry, index): ReadoutRow => {
		const s = stats[index];
		return {
			key: entry.key,
			label: entry.label,
			color: seriesColor(entry, index),
			text: s ? `${format(s.median)} (${format(s.q1)}–${format(s.q3)})` : MISSING,
			missing: !s
		};
	});
	const spoken = series.map((entry, index) => {
		const s = stats[index];
		if (!s) return `${entry.label} ${MISSING.toLowerCase()}`;
		const outliers = s.outliers?.length ?? 0;
		return (
			`${entry.label} median ${format(s.median)}, middle half ${format(s.q1)} to ${format(s.q3)}, ` +
			`whiskers ${format(s.min)} to ${format(s.max)}` +
			(outliers ? `, ${outliers} outlier${outliers === 1 ? '' : 's'}` : '')
		);
	});
	return describe(title, rows, {
		note: 'Median (middle half)',
		sentence: `${title}: ${spoken.join('; ')}.`
	});
}

/** A ranked item: its value and its place. */
export function rankedReadout(
	item: RankedDatum & { value: number },
	index: number,
	count: number,
	{ title, color, format }: { title: string; color: string; format: (value: number) => string }
): Readout {
	return describe(
		item.label,
		[
			{
				key: item.id,
				label: title,
				color,
				text: format(item.value),
				missing: false
			}
		],
		{ note: `Rank ${index + 1} of ${count}` }
	);
}

/** The position nearest `x` (in plot units). */
export function nearestIndex(x: number, positions: readonly number[]) {
	let best = 0;
	for (let index = 1; index < positions.length; index++)
		if (Math.abs(positions[index] - x) < Math.abs(positions[best] - x)) best = index;
	return best;
}

/** Where a key moves the inspection, or null when the key is not one of
 *  ours. From nothing, → starts at the first position and ← at the last. */
export function stepIndex(key: string, index: number | null, count: number) {
	if (count === 0) return null;
	const last = count - 1;
	switch (key) {
		case 'ArrowRight':
			return index === null ? 0 : Math.min(last, index + 1);
		case 'ArrowLeft':
			return index === null ? last : Math.max(0, index - 1);
		case 'Home':
			return 0;
		case 'End':
			return last;
		default:
			return null;
	}
}

/** The card sits beside the crosshair, on whichever side has room. */
export const readoutSide = (x: number) => (x > PLOT * 0.6 ? 'left' : 'right');

/** A marker where each series meets the crosshair. */
export type InspectMarker = { key: string; color: string; y: number };

/** The markers at `index` for series drawn as lines: `tops` is each series'
 *  y per position, null where it has no measurement. */
export const lineMarkers = (
	series: readonly {
		key: string;
		color: string;
		tops: readonly (number | null)[];
	}[],
	index: number
): InspectMarker[] =>
	series.flatMap((entry) => {
		const y = entry.tops[index];
		return y === null || y === undefined ? [] : [{ key: entry.key, color: entry.color, y }];
	});

src/lib/components/charts/_shared/ChartInspector.svelte

<script lang="ts">
	import { VisuallyHidden } from '$lib/components/utility/visually-hidden';
	import {
		nearestIndex,
		readoutSide,
		stepIndex,
		type InspectMarker,
		type Readout
	} from '../_kernel/inspect';
	import { PLOT } from '../_kernel/scale';
	import styles from './chart.module.css';

	/* Reads a chart position by position. The pointer shows the nearest one;
	   Tab in and the arrow keys step through them, Home and End jump, Escape
	   lets go — and each step is announced. The card and crosshair are for the
	   eye only: the announcement and the data table say the same. */
	let {
		label,
		positions,
		readout,
		markers,
		band
	}: {
		label: string;
		/** Each position's x, in plot units. */
		positions: readonly number[];
		readout: (index: number) => Readout;
		/** Where each series meets the crosshair. */
		markers?: (index: number) => InspectMarker[];
		/** Highlight a band this wide, in plot units, instead of a line. */
		band?: number;
	} = $props();

	const hint = $props.id();
	let active = $state<number | null>(null);
	let said = $state('');
	const index = $derived(active !== null && active < positions.length ? active : null);
	const shown = $derived(index === null ? null : readout(index));
	const x = $derived(index === null ? 0 : positions[index]);
	const at = (units: number) => `${units / (PLOT / 100)}%`;

	const point = (event: PointerEvent & { currentTarget: HTMLDivElement }) => {
		const box = event.currentTarget.getBoundingClientRect();
		if (box.width === 0) return;
		active = nearestIndex(((event.clientX - box.left) / box.width) * PLOT, positions);
	};
	const key = (event: KeyboardEvent) => {
		if (event.key === 'Escape' && index !== null) {
			event.preventDefault();
			active = null;
			return;
		}
		const next = stepIndex(event.key, index, positions.length);
		if (next === null) return;
		event.preventDefault();
		active = next;
		said = readout(next).sentence;
	};
</script>

<!-- The plot is one tab stop whose arrow keys move a reading, announced by the
     status region: a focusable group, which the rule below does not model. -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
<div
	class={styles.inspect}
	tabindex="0"
	role="group"
	aria-label="{label}: values by position"
	aria-describedby={hint}
	onpointermove={point}
	onpointerdown={point}
	onpointerleave={() => (active = null)}
	onblur={() => (active = null)}
	onkeydown={key}
>
	<VisuallyHidden id={hint}>Use the left and right arrow keys to read each position.</VisuallyHidden
	>
	<VisuallyHidden role="status">{said}</VisuallyHidden>
	{#if shown && index !== null}
		<div aria-hidden="true">
			{#if band}
				<span class={styles.inspectBand} style:left={at(x - band / 2)} style:width={at(band)}
				></span>
			{:else}
				<span class={styles.crosshair} style:left={at(x)}></span>
			{/if}
			{#each markers?.(index) ?? [] as marker (marker.key)}
				<span
					class={styles.marker}
					style:left={at(x)}
					style:top={at(marker.y)}
					style:--series={marker.color}
				></span>
			{/each}
			<div class={styles.readout} data-side={readoutSide(x)} style:--x={at(x)}>
				<p class={styles.readoutTitle}>{shown.title}</p>
				{#each shown.rows as row (row.key)}
					<p
						class={styles.readoutRow}
						data-missing={row.missing || undefined}
						style:--series={row.color}
					>
						<span class={styles.readoutLabel}>{row.label}</span>
						<span class={styles.readoutValue}>{row.text}</span>
					</p>
				{/each}
				{#if shown.total !== null}
					<p class={styles.readoutRow} data-total>
						<span class={styles.readoutLabel}>Total</span>
						<span class={styles.readoutValue}>{shown.total}</span>
					</p>
				{/if}
				{#if shown.note !== null}
					<p class={styles.readoutNote}>{shown.note}</p>
				{/if}
			</div>
		</div>
	{/if}
</div>

src/lib/components/charts/line-chart/doc.ts

/**
 * LineChart / AreaChart — values over ordered positions.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label        REQUIRED, the chart's name
 *     data         { label, values: { [series key]: number | null } }[]
 *     series       { key, label, color?, line? }[]
 *     formatValue? exact values (table, legend) — default: every digit
 *     formatTick?  axis ticks — default: compact (1.2K)
 *     height?      the plot's height
 *     legend?      default: when there is more than one series
 *     points?      mark each measurement; default when 24 or fewer
 *     animation?   default: each series wipes in, when scrolled into view
 *     inspect?     read values by pointer or keyboard; default true
 *     LineChart    zero? — keep zero on the axis, default true
 *     curve?       "linear" (default) | "smooth" | "step"
 *     AreaChart    stacked? — true into a total, "percent" into shares
 *
 * # Behaviour
 *
 * R1  A null breaks the line; the stretches either side are separate. A lone
 *     measurement between gaps is always marked, or it would not show.
 * R2  The axis includes zero unless `zero` is false. An area is always filled
 *     to zero (or, stacked, to the series below), so it always includes zero.
 * R3  Stacked areas need non-negative values: others are left out of the
 *     picture and kept in the table.
 * R4  One position is drawn at the centre; a constant series is a flat line.
 * R5  The first and last x labels align to the plot's edges; the rest are
 *     centred on their position and thinned to fit.
 * R6  New data tweens: lines, areas, and the axis move to it together.
 * R7  "smooth" is monotone: between two points it never goes above the
 *     higher or below the lower, so it cannot invent a peak. "step" holds
 *     each value until the next.
 * R8  Percent stacking draws each position's parts as shares of its total,
 *     on a 0–100% axis; the table keeps the raw values.
 * R9  Inspection (inspect, default on): the pointer shows the nearest
 *     position — a crosshair or band, a marker on each line, and a card with
 *     every series' value (missing ones say "Not measured"; stacks add the
 *     total, percent stacks each share). The plot is one tab stop: ← → step,
 *     Home and End jump, Escape lets go, and each step is announced as one
 *     sentence. The card is for the eye; the table stays the full record.
 */
export {};

src/lib/components/charts/line-chart/Lines.svelte

<script lang="ts">
	import type { AnimationProp } from '$lib/motion';
	import { chartMotion, Tweened } from '$lib/motion/svelte.svelte';
	import { cn } from '$lib/utils/cn';
	import type { CartesianDatum } from '../_kernel/cartesian';
	import type { ChartSeries } from '../_kernel/encode';
	import type { Curve } from '../_kernel/curves';
	import { formatExact, formatPercentTick, formatTick } from '../_kernel/format';
	import { lineMarkers, readout } from '../_kernel/inspect';
	import { linesGeometry, linesTarget, type LinesStack } from '../_kernel/lines';
	import CartesianPlot from '../_shared/CartesianPlot.svelte';
	import ChartInspector from '../_shared/ChartInspector.svelte';
	import chart from '../_shared/chart.module.css';
	import SeriesLegend from '../_shared/SeriesLegend.svelte';
	import SeriesTable from '../_shared/SeriesTable.svelte';

	/* LineChart and AreaChart's shared body. */
	let {
		kind,
		stacked = false,
		zero = true,
		label,
		data,
		series,
		formatValue = formatExact,
		formatTick: tickFormat = formatTick,
		height,
		legend = series.length > 1,
		points,
		curve = 'linear',
		animation,
		inspect = true,
		class: className = ''
	}: {
		kind: 'line' | 'area';
		stacked?: boolean | 'percent';
		zero?: boolean;
		label: string;
		data: readonly CartesianDatum[];
		series: readonly ChartSeries[];
		formatValue?: (value: number) => string;
		formatTick?: (value: number) => string;
		height?: string;
		legend?: boolean;
		points?: boolean;
		/** How points join: straight, a smooth curve that never overshoots the
		 *  data, or steps (a value that holds until it changes). */
		curve?: Curve;
		animation?: AnimationProp;
		/** Read values position by position, by pointer or keyboard (default). */
		inspect?: boolean;
		class?: string;
	} = $props();

	const motion = chartMotion(() => animation, { enter: 'wipe', axis: 'x' });
	const mode: LinesStack = $derived(
		stacked === 'percent' ? 'percent' : stacked ? 'stacked' : 'none'
	);
	const model = $derived(linesTarget(data, series, { mode, zero: zero || kind === 'area' }));
	const shown = new Tweened(
		() => model.target,
		() => motion.update
	);
	const plot = $derived(
		linesGeometry(shown.current, data, series, {
			area: kind === 'area',
			stacked: mode !== 'none',
			// In percent the top series is always 100%: marking it says nothing.
			points: points ?? (data.length <= 24 && mode !== 'percent'),
			step: model.step,
			format: mode === 'percent' ? formatPercentTick : tickFormat,
			curve
		})
	);
</script>

{#snippet inspector()}
	<ChartInspector
		{label}
		positions={plot.positions}
		readout={(index) =>
			readout(data[index], series, {
				format: formatValue,
				total: mode !== 'none',
				share: mode === 'percent'
			})}
		markers={(index) => lineMarkers(plot.series, index)}
	/>
{/snippet}

{#if data.length === 0 || series.length === 0}
	<div class={cn(chart.root, className)}>
		<p class={chart.empty}>No data to display.</p>
	</div>
{:else}
	<div {...motion.pending} {@attach motion.attach} class={cn(chart.root, className)}>
		{#if legend}<SeriesLegend {series} lines />{/if}
		{#if model.measured}
			<CartesianPlot
				{label}
				ticks={plot.ticks}
				{height}
				zeroAt={plot.zeroAt}
				xLabels={plot.xLabels}
				overlay={inspect ? inspector : undefined}
			>
				{#each plot.series as entry (entry.key)}
					<g data-mark style:--series={entry.color}>
						{#if entry.area !== null}
							<path class={chart.area} data-stacked={mode !== 'none' || undefined} d={entry.area} />
						{/if}
						<path class={chart.line} data-line={entry.line} d={entry.path} />
						{#each entry.points as point (point.key)}
							<path class={chart.point} d={point.d} />
						{/each}
					</g>
				{/each}
			</CartesianPlot>
		{:else}
			<p class={chart.empty}>Measurements unavailable.</p>
		{/if}
		<SeriesTable {label} {data} {series} {formatValue} />
	</div>
{/if}

src/lib/components/charts/line-chart/LineChart.svelte

<script lang="ts">
	import type { ComponentProps } from 'svelte';
	import Lines from './Lines.svelte';

	/* Values over ordered positions, one line per series. A missing value
	   breaks the line rather than being joined across or treated as zero.
	   `zero` (default true) keeps zero on the axis; turn it off only when the
	   variation, not the size, is the point — and say so. */
	let props: Omit<ComponentProps<typeof Lines>, 'kind' | 'stacked'> = $props();
</script>

<Lines {...props} kind="line" />

src/lib/components/charts/line-chart/AreaChart.svelte

<script lang="ts">
	import type { ComponentProps } from 'svelte';
	import Lines from './Lines.svelte';

	/* A line chart whose area is filled to zero — or, `stacked`, to the series
	   below, so the top edge is the total. */
	let props: Omit<ComponentProps<typeof Lines>, 'kind' | 'zero'> = $props();
</script>

<Lines {...props} kind="area" />

src/lib/components/charts/_kernel/lines.ts

import { xLabels, yAxis, type CartesianDatum } from './cartesian';
import { trace, traceBack, type Curve } from './curves';
import { seriesColor, type ChartSeries, type LineStyle } from './encode';
import { isValue, niceDomain, PLOT, px } from './scale';
import { stack, toPercent } from './stack';

/* LineChart and AreaChart's maths. */

const cell = (series: string, index: number) => `${series}|${index}`;

/** How series combine: each on its own, stacked into a total, or stacked
 *  as shares of each position's total. */
export type LinesStack = 'none' | 'stacked' | 'percent';

/** The data a chart draws: as given, or as percentages of each row. */
export function linesData(
	data: readonly CartesianDatum[],
	series: readonly ChartSeries[],
	mode: LinesStack
) {
	return mode === 'percent'
		? toPercent(
				data,
				series.map((entry) => entry.key)
			)
		: data;
}

export function linesTarget(
	data: readonly CartesianDatum[],
	series: readonly ChartSeries[],
	{ mode, zero }: { mode: LinesStack; zero: boolean }
) {
	const keys = series.map((entry) => entry.key);
	const rows = linesData(data, series, mode);
	const stacked = mode !== 'none';
	const target: Record<string, number> = {};
	rows.forEach((row, index) =>
		keys.forEach((key) => {
			const value = row.values[key];
			if (isValue(value) && (!stacked || value >= 0)) target[cell(key, index)] = value;
		})
	);
	const measured = Object.keys(target).length > 0;
	const { domain, step } =
		mode === 'percent'
			? niceDomain([100])
			: niceDomain(
					stacked
						? stack(
								rows.map((row) => row.values),
								keys
							).map((entry) => entry.total)
						: Object.values(target),
					{ zero }
				);
	target.__lo = domain[0];
	target.__hi = domain[1];
	return { target, step, measured };
}

type Point = { index: number; top: number; bottom: number };

/** Contiguous stretches of measured positions: a gap ends a run. */
function runs(count: number, at: (index: number) => Omit<Point, 'index'> | null) {
	const out: Point[][] = [];
	let run: Point[] = [];
	for (let index = 0; index < count; index++) {
		const point = at(index);
		if (point) run.push({ index, ...point });
		else if (run.length) {
			out.push(run);
			run = [];
		}
	}
	if (run.length) out.push(run);
	return out;
}

export type LineSeriesGeometry = {
	key: string;
	color: string;
	line?: LineStyle;
	path: string;
	area: string | null;
	points: { key: string; d: string }[];
	tops: (number | null)[];
};

export function linesGeometry(
	shown: Readonly<Record<string, number>>,
	data: readonly CartesianDatum[],
	series: readonly ChartSeries[],
	{
		area,
		stacked,
		points,
		step,
		format,
		curve = 'linear',
		positions
	}: {
		area: boolean;
		stacked: boolean;
		points: boolean;
		step: number;
		format?: (value: number) => string;
		curve?: Curve;
		/** Where each position sits on x; default edge to edge. A combo chart
		 *  puts lines at the centre of each column's band. */
		positions?: (index: number) => number;
	}
) {
	const keys = series.map((entry) => entry.key);
	const { y, ticks } = yAxis([shown.__lo, shown.__hi], step, format);
	const n = data.length;
	const x = positions ?? ((index: number) => (n === 1 ? PLOT / 2 : (index / (n - 1)) * PLOT));
	const base = y(Math.max(shown.__lo, Math.min(0, shown.__hi)));
	const stacks = stacked
		? stack(
				data.map((_, index) =>
					Object.fromEntries(keys.map((key) => [key, shown[cell(key, index)]]))
				),
				keys
			)
		: null;
	const at = (p: Point, edge: 'top' | 'bottom') => [x(p.index), p[edge]] as const;

	return {
		ticks,
		zeroAt: shown.__lo < 0 && shown.__hi > 0 ? px(y(0)) : undefined,
		positions: data.map((_, index) => px(x(index))),
		xLabels: xLabels(
			data.map((row) => row.label),
			data.map((_, index) => x(index)),
			{ edges: !positions }
		),
		series: series.map((entry, s): LineSeriesGeometry => {
			const pointAt = (index: number) => {
				if (stacks) {
					const span = stacks[index].spans[entry.key];
					return span ? { top: y(span[1]), bottom: y(span[0]) } : null;
				}
				const value = shown[cell(entry.key, index)];
				return value === undefined ? null : { top: y(value), bottom: base };
			};
			const stretches = runs(n, pointAt);
			return {
				key: entry.key,
				color: seriesColor(entry, s),
				line: entry.line,
				path: stretches
					.map((run) =>
						trace(
							run.map((p) => at(p, 'top')),
							curve
						)
					)
					.join(''),
				area: area
					? stretches
							.map(
								(run) =>
									trace(
										run.map((p) => at(p, 'top')),
										curve
									) +
									traceBack(
										run.map((p) => at(p, 'bottom')),
										curve
									) +
									'Z'
							)
							.join('')
					: null,
				// A lone measurement between gaps is always marked, or it would not
				// show at all.
				points: stretches.flatMap((run) =>
					points || run.length === 1
						? run.map((p) => {
								const [px0, py0] = at(p, 'top');
								return { key: String(p.index), d: `M${px(px0)},${px(py0)}h0` };
							})
						: []
				),
				// Where the series meets each position, for the inspector.
				tops: data.map((_, index) => {
					const point = pointAt(index);
					return point ? px(point.top) : null;
				})
			};
		})
	};
}

AreaChart

stacked · single

Requests by product

Stacked: the top edge is the total.

  • API
  • Storage
  • Compute
Use the left and right arrow keys to read each position.
View data for Weekly requests by product, thousands
Weekly requests by product, thousands
Label APIStorageCompute
W27 42k18k9k
W28 45k19k11k
W29 44k21k10k
W30 51k21k14k
W31 55k22k13k
W32 53k24k16k
W33 58k25k18k
W34 61k25k17k
W35 66k27k21k
W36 64k29k24k
W37 70k30k23k
W38 74k31k27k
API requests

One series, filled to zero.

Use the left and right arrow keys to read each position.
View data for Weekly API requests, thousands
Weekly API requests, thousands
Label API
W27 42k
W28 45k
W29 44k
W30 51k
W31 55k
W32 53k
W33 58k
W34 61k
W35 66k
W36 64k
W37 70k
W38 74k
Source src/lib/components/charts/line-chart/doc.ts · src/lib/components/charts/line-chart/Lines.svelte · src/lib/components/charts/line-chart/LineChart.svelte · src/lib/components/charts/line-chart/AreaChart.svelte · src/lib/components/charts/_kernel/lines.ts

src/lib/components/charts/line-chart/doc.ts

/**
 * LineChart / AreaChart — values over ordered positions.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label        REQUIRED, the chart's name
 *     data         { label, values: { [series key]: number | null } }[]
 *     series       { key, label, color?, line? }[]
 *     formatValue? exact values (table, legend) — default: every digit
 *     formatTick?  axis ticks — default: compact (1.2K)
 *     height?      the plot's height
 *     legend?      default: when there is more than one series
 *     points?      mark each measurement; default when 24 or fewer
 *     animation?   default: each series wipes in, when scrolled into view
 *     inspect?     read values by pointer or keyboard; default true
 *     LineChart    zero? — keep zero on the axis, default true
 *     curve?       "linear" (default) | "smooth" | "step"
 *     AreaChart    stacked? — true into a total, "percent" into shares
 *
 * # Behaviour
 *
 * R1  A null breaks the line; the stretches either side are separate. A lone
 *     measurement between gaps is always marked, or it would not show.
 * R2  The axis includes zero unless `zero` is false. An area is always filled
 *     to zero (or, stacked, to the series below), so it always includes zero.
 * R3  Stacked areas need non-negative values: others are left out of the
 *     picture and kept in the table.
 * R4  One position is drawn at the centre; a constant series is a flat line.
 * R5  The first and last x labels align to the plot's edges; the rest are
 *     centred on their position and thinned to fit.
 * R6  New data tweens: lines, areas, and the axis move to it together.
 * R7  "smooth" is monotone: between two points it never goes above the
 *     higher or below the lower, so it cannot invent a peak. "step" holds
 *     each value until the next.
 * R8  Percent stacking draws each position's parts as shares of its total,
 *     on a 0–100% axis; the table keeps the raw values.
 * R9  Inspection (inspect, default on): the pointer shows the nearest
 *     position — a crosshair or band, a marker on each line, and a card with
 *     every series' value (missing ones say "Not measured"; stacks add the
 *     total, percent stacks each share). The plot is one tab stop: ← → step,
 *     Home and End jump, Escape lets go, and each step is announced as one
 *     sentence. The card is for the eye; the table stays the full record.
 */
export {};

src/lib/components/charts/line-chart/Lines.svelte

<script lang="ts">
	import type { AnimationProp } from '$lib/motion';
	import { chartMotion, Tweened } from '$lib/motion/svelte.svelte';
	import { cn } from '$lib/utils/cn';
	import type { CartesianDatum } from '../_kernel/cartesian';
	import type { ChartSeries } from '../_kernel/encode';
	import type { Curve } from '../_kernel/curves';
	import { formatExact, formatPercentTick, formatTick } from '../_kernel/format';
	import { lineMarkers, readout } from '../_kernel/inspect';
	import { linesGeometry, linesTarget, type LinesStack } from '../_kernel/lines';
	import CartesianPlot from '../_shared/CartesianPlot.svelte';
	import ChartInspector from '../_shared/ChartInspector.svelte';
	import chart from '../_shared/chart.module.css';
	import SeriesLegend from '../_shared/SeriesLegend.svelte';
	import SeriesTable from '../_shared/SeriesTable.svelte';

	/* LineChart and AreaChart's shared body. */
	let {
		kind,
		stacked = false,
		zero = true,
		label,
		data,
		series,
		formatValue = formatExact,
		formatTick: tickFormat = formatTick,
		height,
		legend = series.length > 1,
		points,
		curve = 'linear',
		animation,
		inspect = true,
		class: className = ''
	}: {
		kind: 'line' | 'area';
		stacked?: boolean | 'percent';
		zero?: boolean;
		label: string;
		data: readonly CartesianDatum[];
		series: readonly ChartSeries[];
		formatValue?: (value: number) => string;
		formatTick?: (value: number) => string;
		height?: string;
		legend?: boolean;
		points?: boolean;
		/** How points join: straight, a smooth curve that never overshoots the
		 *  data, or steps (a value that holds until it changes). */
		curve?: Curve;
		animation?: AnimationProp;
		/** Read values position by position, by pointer or keyboard (default). */
		inspect?: boolean;
		class?: string;
	} = $props();

	const motion = chartMotion(() => animation, { enter: 'wipe', axis: 'x' });
	const mode: LinesStack = $derived(
		stacked === 'percent' ? 'percent' : stacked ? 'stacked' : 'none'
	);
	const model = $derived(linesTarget(data, series, { mode, zero: zero || kind === 'area' }));
	const shown = new Tweened(
		() => model.target,
		() => motion.update
	);
	const plot = $derived(
		linesGeometry(shown.current, data, series, {
			area: kind === 'area',
			stacked: mode !== 'none',
			// In percent the top series is always 100%: marking it says nothing.
			points: points ?? (data.length <= 24 && mode !== 'percent'),
			step: model.step,
			format: mode === 'percent' ? formatPercentTick : tickFormat,
			curve
		})
	);
</script>

{#snippet inspector()}
	<ChartInspector
		{label}
		positions={plot.positions}
		readout={(index) =>
			readout(data[index], series, {
				format: formatValue,
				total: mode !== 'none',
				share: mode === 'percent'
			})}
		markers={(index) => lineMarkers(plot.series, index)}
	/>
{/snippet}

{#if data.length === 0 || series.length === 0}
	<div class={cn(chart.root, className)}>
		<p class={chart.empty}>No data to display.</p>
	</div>
{:else}
	<div {...motion.pending} {@attach motion.attach} class={cn(chart.root, className)}>
		{#if legend}<SeriesLegend {series} lines />{/if}
		{#if model.measured}
			<CartesianPlot
				{label}
				ticks={plot.ticks}
				{height}
				zeroAt={plot.zeroAt}
				xLabels={plot.xLabels}
				overlay={inspect ? inspector : undefined}
			>
				{#each plot.series as entry (entry.key)}
					<g data-mark style:--series={entry.color}>
						{#if entry.area !== null}
							<path class={chart.area} data-stacked={mode !== 'none' || undefined} d={entry.area} />
						{/if}
						<path class={chart.line} data-line={entry.line} d={entry.path} />
						{#each entry.points as point (point.key)}
							<path class={chart.point} d={point.d} />
						{/each}
					</g>
				{/each}
			</CartesianPlot>
		{:else}
			<p class={chart.empty}>Measurements unavailable.</p>
		{/if}
		<SeriesTable {label} {data} {series} {formatValue} />
	</div>
{/if}

src/lib/components/charts/line-chart/LineChart.svelte

<script lang="ts">
	import type { ComponentProps } from 'svelte';
	import Lines from './Lines.svelte';

	/* Values over ordered positions, one line per series. A missing value
	   breaks the line rather than being joined across or treated as zero.
	   `zero` (default true) keeps zero on the axis; turn it off only when the
	   variation, not the size, is the point — and say so. */
	let props: Omit<ComponentProps<typeof Lines>, 'kind' | 'stacked'> = $props();
</script>

<Lines {...props} kind="line" />

src/lib/components/charts/line-chart/AreaChart.svelte

<script lang="ts">
	import type { ComponentProps } from 'svelte';
	import Lines from './Lines.svelte';

	/* A line chart whose area is filled to zero — or, `stacked`, to the series
	   below, so the top edge is the total. */
	let props: Omit<ComponentProps<typeof Lines>, 'kind' | 'zero'> = $props();
</script>

<Lines {...props} kind="area" />

src/lib/components/charts/_kernel/lines.ts

import { xLabels, yAxis, type CartesianDatum } from './cartesian';
import { trace, traceBack, type Curve } from './curves';
import { seriesColor, type ChartSeries, type LineStyle } from './encode';
import { isValue, niceDomain, PLOT, px } from './scale';
import { stack, toPercent } from './stack';

/* LineChart and AreaChart's maths. */

const cell = (series: string, index: number) => `${series}|${index}`;

/** How series combine: each on its own, stacked into a total, or stacked
 *  as shares of each position's total. */
export type LinesStack = 'none' | 'stacked' | 'percent';

/** The data a chart draws: as given, or as percentages of each row. */
export function linesData(
	data: readonly CartesianDatum[],
	series: readonly ChartSeries[],
	mode: LinesStack
) {
	return mode === 'percent'
		? toPercent(
				data,
				series.map((entry) => entry.key)
			)
		: data;
}

export function linesTarget(
	data: readonly CartesianDatum[],
	series: readonly ChartSeries[],
	{ mode, zero }: { mode: LinesStack; zero: boolean }
) {
	const keys = series.map((entry) => entry.key);
	const rows = linesData(data, series, mode);
	const stacked = mode !== 'none';
	const target: Record<string, number> = {};
	rows.forEach((row, index) =>
		keys.forEach((key) => {
			const value = row.values[key];
			if (isValue(value) && (!stacked || value >= 0)) target[cell(key, index)] = value;
		})
	);
	const measured = Object.keys(target).length > 0;
	const { domain, step } =
		mode === 'percent'
			? niceDomain([100])
			: niceDomain(
					stacked
						? stack(
								rows.map((row) => row.values),
								keys
							).map((entry) => entry.total)
						: Object.values(target),
					{ zero }
				);
	target.__lo = domain[0];
	target.__hi = domain[1];
	return { target, step, measured };
}

type Point = { index: number; top: number; bottom: number };

/** Contiguous stretches of measured positions: a gap ends a run. */
function runs(count: number, at: (index: number) => Omit<Point, 'index'> | null) {
	const out: Point[][] = [];
	let run: Point[] = [];
	for (let index = 0; index < count; index++) {
		const point = at(index);
		if (point) run.push({ index, ...point });
		else if (run.length) {
			out.push(run);
			run = [];
		}
	}
	if (run.length) out.push(run);
	return out;
}

export type LineSeriesGeometry = {
	key: string;
	color: string;
	line?: LineStyle;
	path: string;
	area: string | null;
	points: { key: string; d: string }[];
	tops: (number | null)[];
};

export function linesGeometry(
	shown: Readonly<Record<string, number>>,
	data: readonly CartesianDatum[],
	series: readonly ChartSeries[],
	{
		area,
		stacked,
		points,
		step,
		format,
		curve = 'linear',
		positions
	}: {
		area: boolean;
		stacked: boolean;
		points: boolean;
		step: number;
		format?: (value: number) => string;
		curve?: Curve;
		/** Where each position sits on x; default edge to edge. A combo chart
		 *  puts lines at the centre of each column's band. */
		positions?: (index: number) => number;
	}
) {
	const keys = series.map((entry) => entry.key);
	const { y, ticks } = yAxis([shown.__lo, shown.__hi], step, format);
	const n = data.length;
	const x = positions ?? ((index: number) => (n === 1 ? PLOT / 2 : (index / (n - 1)) * PLOT));
	const base = y(Math.max(shown.__lo, Math.min(0, shown.__hi)));
	const stacks = stacked
		? stack(
				data.map((_, index) =>
					Object.fromEntries(keys.map((key) => [key, shown[cell(key, index)]]))
				),
				keys
			)
		: null;
	const at = (p: Point, edge: 'top' | 'bottom') => [x(p.index), p[edge]] as const;

	return {
		ticks,
		zeroAt: shown.__lo < 0 && shown.__hi > 0 ? px(y(0)) : undefined,
		positions: data.map((_, index) => px(x(index))),
		xLabels: xLabels(
			data.map((row) => row.label),
			data.map((_, index) => x(index)),
			{ edges: !positions }
		),
		series: series.map((entry, s): LineSeriesGeometry => {
			const pointAt = (index: number) => {
				if (stacks) {
					const span = stacks[index].spans[entry.key];
					return span ? { top: y(span[1]), bottom: y(span[0]) } : null;
				}
				const value = shown[cell(entry.key, index)];
				return value === undefined ? null : { top: y(value), bottom: base };
			};
			const stretches = runs(n, pointAt);
			return {
				key: entry.key,
				color: seriesColor(entry, s),
				line: entry.line,
				path: stretches
					.map((run) =>
						trace(
							run.map((p) => at(p, 'top')),
							curve
						)
					)
					.join(''),
				area: area
					? stretches
							.map(
								(run) =>
									trace(
										run.map((p) => at(p, 'top')),
										curve
									) +
									traceBack(
										run.map((p) => at(p, 'bottom')),
										curve
									) +
									'Z'
							)
							.join('')
					: null,
				// A lone measurement between gaps is always marked, or it would not
				// show at all.
				points: stretches.flatMap((run) =>
					points || run.length === 1
						? run.map((p) => {
								const [px0, py0] = at(p, 'top');
								return { key: String(p.index), d: `M${px(px0)},${px(py0)}h0` };
							})
						: []
				),
				// Where the series meets each position, for the inspector.
				tops: data.map((_, index) => {
					const point = pointAt(index);
					return point ? px(point.top) : null;
				})
			};
		})
	};
}

ColumnChart

grouped · stacked · negative · missing

Signups and activations

Grouped.

  • Signups
  • Activated
Use the left and right arrow keys to read each position.
View data for Signups and activations by month
Signups and activations by month
Label SignupsActivated
Apr 420251
May 468290
Jun 455268
Jul 512331
Aug 540362
Sep 601410
Recurring revenue by plan

Stacked.

  • Starter
  • Team
  • Enterprise
Use the left and right arrow keys to read each position.
View data for Monthly recurring revenue by plan
Monthly recurring revenue by plan
Label StarterTeamEnterprise
Apr $8,200$21,400$34,000
May $8,450$22,800$34,000
Jun $8,610$23,900$41,500
Jul $8,900$25,100$41,500
Aug $9,120$26,700$43,800
Sep $9,480$28,300$49,200
Net member change

Below zero grows downward. W36 was not measured.

Use the left and right arrow keys to read each position.
View data for Net member change by week
Net member change by week
Label Net members
W33 12
W34 7
W35 -4
W36 Unavailable
W37 9
W38 -11
W39 3

Stacking needs parts of a whole. Negative values cannot stack, so a stacked chart leaves them out of the picture; the data table still has them.

Source src/lib/components/charts/column-chart/doc.ts · src/lib/components/charts/column-chart/ColumnChart.svelte · src/lib/components/charts/_kernel/column.ts

src/lib/components/charts/column-chart/doc.ts

/**
 * ColumnChart — vertical columns over labelled positions.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label, data, series, formatValue?, formatTick?, height?, legend?  as
 *                  LineChart
 *     layout?      "grouped" (default) | "stacked" | "percent"
 *     animation?   default: columns grow from zero, when scrolled into view
 *     inspect?     read values by pointer or keyboard; default true
 *
 * # Behaviour
 *
 * R1  Columns measure from zero; the axis always includes it. Negative
 *     columns hang below a stronger zero line and grow downward.
 * R2  A null draws no column and leaves its slot empty.
 * R3  Stacked columns need non-negative values: others are left out of the
 *     picture and kept in the table. The stack's top is the total.
 * R4  Percent draws each column's parts as shares of its total, on a
 *     0–100% axis; the table keeps the raw values.
 * R5  New data tweens: columns, stacks, and the axis move together; a column
 *     for a newly measured value grows from zero.
 * R6  Inspection (inspect, default on): the pointer shows the nearest
 *     position — a crosshair or band, a marker on each line, and a card with
 *     every series' value (missing ones say "Not measured"; stacks add the
 *     total, percent stacks each share). The plot is one tab stop: ← → step,
 *     Home and End jump, Escape lets go, and each step is announced as one
 *     sentence. The card is for the eye; the table stays the full record.
 */
export {};

src/lib/components/charts/column-chart/ColumnChart.svelte

<script lang="ts">
	import type { AnimationProp } from '$lib/motion';
	import { chartMotion, Tweened } from '$lib/motion/svelte.svelte';
	import { cn } from '$lib/utils/cn';
	import type { CartesianDatum } from '../_kernel/cartesian';
	import { columnGeometry, columnTarget, type ColumnLayout } from '../_kernel/column';
	import type { ChartSeries } from '../_kernel/encode';
	import { formatExact, formatPercentTick, formatTick } from '../_kernel/format';
	import { readout } from '../_kernel/inspect';
	import CartesianPlot from '../_shared/CartesianPlot.svelte';
	import ChartInspector from '../_shared/ChartInspector.svelte';
	import chart from '../_shared/chart.module.css';
	import SeriesLegend from '../_shared/SeriesLegend.svelte';
	import SeriesTable from '../_shared/SeriesTable.svelte';

	/* Vertical columns over labelled positions, grouped or stacked. */
	let {
		label,
		data,
		series,
		layout = 'grouped',
		formatValue = formatExact,
		formatTick: tickFormat = formatTick,
		height,
		legend = series.length > 1,
		animation,
		inspect = true,
		class: className = ''
	}: {
		/** Names the chart. */
		label: string;
		data: readonly CartesianDatum[];
		series: readonly ChartSeries[];
		/** Series side by side, stacked into a total, or stacked as each
		 *  column's shares (percent). Stacking needs non-negative values; others
		 *  are left out of the picture, not zeroed. */
		layout?: ColumnLayout;
		formatValue?: (value: number) => string;
		formatTick?: (value: number) => string;
		/** The plot's height, as a CSS length. */
		height?: string;
		/** Show the legend; defaults to when there is more than one series. */
		legend?: boolean;
		/** Default: columns grow from zero, when scrolled into view. */
		animation?: AnimationProp;
		/** Read values position by position, by pointer or keyboard (default). */
		inspect?: boolean;
		class?: string;
	} = $props();

	const motion = chartMotion(() => animation, { enter: 'grow', axis: 'y' });
	const stacked = $derived(layout !== 'grouped');
	const model = $derived(columnTarget(data, series, layout));
	const shown = new Tweened(
		() => model.target,
		() => motion.update
	);
	const plot = $derived(
		columnGeometry(
			shown.current,
			data,
			series,
			stacked,
			model.step,
			layout === 'percent' ? formatPercentTick : tickFormat
		)
	);
</script>

{#snippet inspector()}
	<ChartInspector
		{label}
		positions={plot.positions}
		band={plot.bandWidth}
		readout={(index) =>
			readout(data[index], series, {
				format: formatValue,
				total: stacked,
				share: layout === 'percent'
			})}
	/>
{/snippet}

{#if data.length === 0 || series.length === 0}
	<div class={cn(chart.root, className)}>
		<p class={chart.empty}>No data to display.</p>
	</div>
{:else}
	<div {...motion.pending} {@attach motion.attach} class={cn(chart.root, className)}>
		{#if legend}<SeriesLegend {series} lines={false} />{/if}
		{#if model.measured}
			<CartesianPlot
				{label}
				ticks={plot.ticks}
				{height}
				zeroAt={plot.zeroAt}
				xLabels={plot.xLabels}
				overlay={inspect ? inspector : undefined}
			>
				{#each plot.series as entry (entry.key)}
					<g style:--series={entry.color}>
						{#each entry.rects as rect (rect.key)}
							<rect
								data-mark
								data-negative={rect.negative || undefined}
								class={chart.column}
								x={rect.x}
								y={rect.y}
								width={rect.width}
								height={rect.height}
							/>
						{/each}
					</g>
				{/each}
			</CartesianPlot>
		{:else}
			<p class={chart.empty}>Measurements unavailable.</p>
		{/if}
		<SeriesTable {label} {data} {series} {formatValue} />
	</div>
{/if}

src/lib/components/charts/_kernel/column.ts

import { xLabels, yAxis, type CartesianDatum } from './cartesian';
import { seriesColor, type ChartSeries } from './encode';
import { band, isValue, niceDomain, px } from './scale';
import { stack, toPercent } from './stack';

/* ColumnChart's maths: what to tween, and what to draw from the tween. */

const cell = (series: string, index: number) => `${series}|${index}`;

export type ColumnLayout = 'grouped' | 'stacked' | 'percent';

/** The data a column chart draws: as given, or as percentages of each row. */
export function columnData(
	data: readonly CartesianDatum[],
	series: readonly ChartSeries[],
	layout: ColumnLayout
) {
	return layout === 'percent'
		? toPercent(
				data,
				series.map((entry) => entry.key)
			)
		: data;
}

/** The numbers a column chart tweens: every drawable value, plus the domain
 *  (as __lo and __hi) so the axis moves with the columns. */
export function columnTarget(
	data: readonly CartesianDatum[],
	series: readonly ChartSeries[],
	layout: ColumnLayout
) {
	const keys = series.map((entry) => entry.key);
	const stacked = layout !== 'grouped';
	const rows = columnData(data, series, layout);
	const target: Record<string, number> = {};
	rows.forEach((row, index) =>
		keys.forEach((key) => {
			const value = row.values[key];
			if (isValue(value) && (!stacked || value >= 0)) target[cell(key, index)] = value;
		})
	);
	const measured = Object.keys(target).length > 0;
	const { domain, step } =
		layout === 'percent'
			? niceDomain([100])
			: niceDomain(
					stacked
						? stack(
								rows.map((row) => row.values),
								keys
							).map((entry) => entry.total)
						: Object.values(target)
				);
	target.__lo = domain[0];
	target.__hi = domain[1];
	return { target, step, measured };
}

export type ColumnRect = {
	key: string;
	x: number;
	y: number;
	width: number;
	height: number;
	negative: boolean;
};

/** What to draw for the tweened values `shown`. */
export function columnGeometry(
	shown: Readonly<Record<string, number>>,
	data: readonly CartesianDatum[],
	series: readonly ChartSeries[],
	stacked: boolean,
	step: number,
	format?: (value: number) => string
) {
	const keys = series.map((entry) => entry.key);
	const { y, ticks } = yAxis([shown.__lo, shown.__hi], step, format);
	const outer = band(data.length, 0.3);
	const inner = band(series.length, 0.12, outer.width);
	const zero = y(0);
	const valueAt = (key: string, index: number) => shown[cell(key, index)] as number | undefined;
	const stacks = stacked
		? stack(
				data.map((_, index) => Object.fromEntries(keys.map((key) => [key, valueAt(key, index)]))),
				keys
			)
		: null;

	return {
		ticks,
		zeroAt: shown.__lo < 0 ? px(zero) : undefined,
		/** Each column's centre, and the width of its band: for the inspector. */
		positions: data.map((_, index) => px(outer.centre(index))),
		bandWidth: px(outer.step),
		xLabels: xLabels(
			data.map((row) => row.label),
			data.map((_, index) => outer.centre(index))
		),
		series: series.map((entry, s) => ({
			key: entry.key,
			color: seriesColor(entry, s),
			rects: data.flatMap((row, index): ColumnRect[] => {
				let top: number;
				let bottom: number;
				let x = outer.start(index);
				let width = outer.width;
				const value = valueAt(entry.key, index);
				if (stacks) {
					const span = stacks[index].spans[entry.key];
					if (!span) return [];
					top = y(span[1]);
					bottom = y(span[0]);
				} else {
					if (value === undefined) return [];
					top = Math.min(y(value), zero);
					bottom = Math.max(y(value), zero);
					x += inner.start(s);
					width = inner.width;
				}
				return [
					{
						key: `${index}-${row.label}`,
						x: px(x),
						y: px(top),
						width: px(width),
						height: px(Math.max(0, bottom - top)),
						negative: !stacks && (value ?? 0) < 0
					}
				];
			})
		}))
	};
}

Curves

linear · smooth · step

Linear

The default.

Use the left and right arrow keys to read each position.
View data for Active workspaces, linear
Active workspaces, linear
Label This year
Feb 1,398
Mar 1,467
Apr 1,520
May 1,604
Jun 1,688
Jul 1,731
Aug 1,840
Sep 1,962
Smooth

Monotone: never overshoots a point.

Use the left and right arrow keys to read each position.
View data for Active workspaces, smooth
Active workspaces, smooth
Label This year
Feb 1,398
Mar 1,467
Apr 1,520
May 1,604
Jun 1,688
Jul 1,731
Aug 1,840
Sep 1,962
Step

A value that holds until it changes.

Use the left and right arrow keys to read each position.
View data for Active workspaces, stepped
Active workspaces, stepped
Label This year
Feb 1,398
Mar 1,467
Apr 1,520
May 1,604
Jun 1,688
Jul 1,731
Aug 1,840
Sep 1,962

Smooth never invents a peak. The curve is monotone: between two points it stays between their values, so it cannot suggest a high or low the data does not have.

Source src/lib/components/charts/line-chart/doc.ts · src/lib/components/charts/line-chart/Lines.svelte · src/lib/components/charts/line-chart/LineChart.svelte · src/lib/components/charts/line-chart/AreaChart.svelte · src/lib/components/charts/_kernel/lines.ts

src/lib/components/charts/line-chart/doc.ts

/**
 * LineChart / AreaChart — values over ordered positions.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label        REQUIRED, the chart's name
 *     data         { label, values: { [series key]: number | null } }[]
 *     series       { key, label, color?, line? }[]
 *     formatValue? exact values (table, legend) — default: every digit
 *     formatTick?  axis ticks — default: compact (1.2K)
 *     height?      the plot's height
 *     legend?      default: when there is more than one series
 *     points?      mark each measurement; default when 24 or fewer
 *     animation?   default: each series wipes in, when scrolled into view
 *     inspect?     read values by pointer or keyboard; default true
 *     LineChart    zero? — keep zero on the axis, default true
 *     curve?       "linear" (default) | "smooth" | "step"
 *     AreaChart    stacked? — true into a total, "percent" into shares
 *
 * # Behaviour
 *
 * R1  A null breaks the line; the stretches either side are separate. A lone
 *     measurement between gaps is always marked, or it would not show.
 * R2  The axis includes zero unless `zero` is false. An area is always filled
 *     to zero (or, stacked, to the series below), so it always includes zero.
 * R3  Stacked areas need non-negative values: others are left out of the
 *     picture and kept in the table.
 * R4  One position is drawn at the centre; a constant series is a flat line.
 * R5  The first and last x labels align to the plot's edges; the rest are
 *     centred on their position and thinned to fit.
 * R6  New data tweens: lines, areas, and the axis move to it together.
 * R7  "smooth" is monotone: between two points it never goes above the
 *     higher or below the lower, so it cannot invent a peak. "step" holds
 *     each value until the next.
 * R8  Percent stacking draws each position's parts as shares of its total,
 *     on a 0–100% axis; the table keeps the raw values.
 * R9  Inspection (inspect, default on): the pointer shows the nearest
 *     position — a crosshair or band, a marker on each line, and a card with
 *     every series' value (missing ones say "Not measured"; stacks add the
 *     total, percent stacks each share). The plot is one tab stop: ← → step,
 *     Home and End jump, Escape lets go, and each step is announced as one
 *     sentence. The card is for the eye; the table stays the full record.
 */
export {};

src/lib/components/charts/line-chart/Lines.svelte

<script lang="ts">
	import type { AnimationProp } from '$lib/motion';
	import { chartMotion, Tweened } from '$lib/motion/svelte.svelte';
	import { cn } from '$lib/utils/cn';
	import type { CartesianDatum } from '../_kernel/cartesian';
	import type { ChartSeries } from '../_kernel/encode';
	import type { Curve } from '../_kernel/curves';
	import { formatExact, formatPercentTick, formatTick } from '../_kernel/format';
	import { lineMarkers, readout } from '../_kernel/inspect';
	import { linesGeometry, linesTarget, type LinesStack } from '../_kernel/lines';
	import CartesianPlot from '../_shared/CartesianPlot.svelte';
	import ChartInspector from '../_shared/ChartInspector.svelte';
	import chart from '../_shared/chart.module.css';
	import SeriesLegend from '../_shared/SeriesLegend.svelte';
	import SeriesTable from '../_shared/SeriesTable.svelte';

	/* LineChart and AreaChart's shared body. */
	let {
		kind,
		stacked = false,
		zero = true,
		label,
		data,
		series,
		formatValue = formatExact,
		formatTick: tickFormat = formatTick,
		height,
		legend = series.length > 1,
		points,
		curve = 'linear',
		animation,
		inspect = true,
		class: className = ''
	}: {
		kind: 'line' | 'area';
		stacked?: boolean | 'percent';
		zero?: boolean;
		label: string;
		data: readonly CartesianDatum[];
		series: readonly ChartSeries[];
		formatValue?: (value: number) => string;
		formatTick?: (value: number) => string;
		height?: string;
		legend?: boolean;
		points?: boolean;
		/** How points join: straight, a smooth curve that never overshoots the
		 *  data, or steps (a value that holds until it changes). */
		curve?: Curve;
		animation?: AnimationProp;
		/** Read values position by position, by pointer or keyboard (default). */
		inspect?: boolean;
		class?: string;
	} = $props();

	const motion = chartMotion(() => animation, { enter: 'wipe', axis: 'x' });
	const mode: LinesStack = $derived(
		stacked === 'percent' ? 'percent' : stacked ? 'stacked' : 'none'
	);
	const model = $derived(linesTarget(data, series, { mode, zero: zero || kind === 'area' }));
	const shown = new Tweened(
		() => model.target,
		() => motion.update
	);
	const plot = $derived(
		linesGeometry(shown.current, data, series, {
			area: kind === 'area',
			stacked: mode !== 'none',
			// In percent the top series is always 100%: marking it says nothing.
			points: points ?? (data.length <= 24 && mode !== 'percent'),
			step: model.step,
			format: mode === 'percent' ? formatPercentTick : tickFormat,
			curve
		})
	);
</script>

{#snippet inspector()}
	<ChartInspector
		{label}
		positions={plot.positions}
		readout={(index) =>
			readout(data[index], series, {
				format: formatValue,
				total: mode !== 'none',
				share: mode === 'percent'
			})}
		markers={(index) => lineMarkers(plot.series, index)}
	/>
{/snippet}

{#if data.length === 0 || series.length === 0}
	<div class={cn(chart.root, className)}>
		<p class={chart.empty}>No data to display.</p>
	</div>
{:else}
	<div {...motion.pending} {@attach motion.attach} class={cn(chart.root, className)}>
		{#if legend}<SeriesLegend {series} lines />{/if}
		{#if model.measured}
			<CartesianPlot
				{label}
				ticks={plot.ticks}
				{height}
				zeroAt={plot.zeroAt}
				xLabels={plot.xLabels}
				overlay={inspect ? inspector : undefined}
			>
				{#each plot.series as entry (entry.key)}
					<g data-mark style:--series={entry.color}>
						{#if entry.area !== null}
							<path class={chart.area} data-stacked={mode !== 'none' || undefined} d={entry.area} />
						{/if}
						<path class={chart.line} data-line={entry.line} d={entry.path} />
						{#each entry.points as point (point.key)}
							<path class={chart.point} d={point.d} />
						{/each}
					</g>
				{/each}
			</CartesianPlot>
		{:else}
			<p class={chart.empty}>Measurements unavailable.</p>
		{/if}
		<SeriesTable {label} {data} {series} {formatValue} />
	</div>
{/if}

src/lib/components/charts/line-chart/LineChart.svelte

<script lang="ts">
	import type { ComponentProps } from 'svelte';
	import Lines from './Lines.svelte';

	/* Values over ordered positions, one line per series. A missing value
	   breaks the line rather than being joined across or treated as zero.
	   `zero` (default true) keeps zero on the axis; turn it off only when the
	   variation, not the size, is the point — and say so. */
	let props: Omit<ComponentProps<typeof Lines>, 'kind' | 'stacked'> = $props();
</script>

<Lines {...props} kind="line" />

src/lib/components/charts/line-chart/AreaChart.svelte

<script lang="ts">
	import type { ComponentProps } from 'svelte';
	import Lines from './Lines.svelte';

	/* A line chart whose area is filled to zero — or, `stacked`, to the series
	   below, so the top edge is the total. */
	let props: Omit<ComponentProps<typeof Lines>, 'kind' | 'zero'> = $props();
</script>

<Lines {...props} kind="area" />

src/lib/components/charts/_kernel/lines.ts

import { xLabels, yAxis, type CartesianDatum } from './cartesian';
import { trace, traceBack, type Curve } from './curves';
import { seriesColor, type ChartSeries, type LineStyle } from './encode';
import { isValue, niceDomain, PLOT, px } from './scale';
import { stack, toPercent } from './stack';

/* LineChart and AreaChart's maths. */

const cell = (series: string, index: number) => `${series}|${index}`;

/** How series combine: each on its own, stacked into a total, or stacked
 *  as shares of each position's total. */
export type LinesStack = 'none' | 'stacked' | 'percent';

/** The data a chart draws: as given, or as percentages of each row. */
export function linesData(
	data: readonly CartesianDatum[],
	series: readonly ChartSeries[],
	mode: LinesStack
) {
	return mode === 'percent'
		? toPercent(
				data,
				series.map((entry) => entry.key)
			)
		: data;
}

export function linesTarget(
	data: readonly CartesianDatum[],
	series: readonly ChartSeries[],
	{ mode, zero }: { mode: LinesStack; zero: boolean }
) {
	const keys = series.map((entry) => entry.key);
	const rows = linesData(data, series, mode);
	const stacked = mode !== 'none';
	const target: Record<string, number> = {};
	rows.forEach((row, index) =>
		keys.forEach((key) => {
			const value = row.values[key];
			if (isValue(value) && (!stacked || value >= 0)) target[cell(key, index)] = value;
		})
	);
	const measured = Object.keys(target).length > 0;
	const { domain, step } =
		mode === 'percent'
			? niceDomain([100])
			: niceDomain(
					stacked
						? stack(
								rows.map((row) => row.values),
								keys
							).map((entry) => entry.total)
						: Object.values(target),
					{ zero }
				);
	target.__lo = domain[0];
	target.__hi = domain[1];
	return { target, step, measured };
}

type Point = { index: number; top: number; bottom: number };

/** Contiguous stretches of measured positions: a gap ends a run. */
function runs(count: number, at: (index: number) => Omit<Point, 'index'> | null) {
	const out: Point[][] = [];
	let run: Point[] = [];
	for (let index = 0; index < count; index++) {
		const point = at(index);
		if (point) run.push({ index, ...point });
		else if (run.length) {
			out.push(run);
			run = [];
		}
	}
	if (run.length) out.push(run);
	return out;
}

export type LineSeriesGeometry = {
	key: string;
	color: string;
	line?: LineStyle;
	path: string;
	area: string | null;
	points: { key: string; d: string }[];
	tops: (number | null)[];
};

export function linesGeometry(
	shown: Readonly<Record<string, number>>,
	data: readonly CartesianDatum[],
	series: readonly ChartSeries[],
	{
		area,
		stacked,
		points,
		step,
		format,
		curve = 'linear',
		positions
	}: {
		area: boolean;
		stacked: boolean;
		points: boolean;
		step: number;
		format?: (value: number) => string;
		curve?: Curve;
		/** Where each position sits on x; default edge to edge. A combo chart
		 *  puts lines at the centre of each column's band. */
		positions?: (index: number) => number;
	}
) {
	const keys = series.map((entry) => entry.key);
	const { y, ticks } = yAxis([shown.__lo, shown.__hi], step, format);
	const n = data.length;
	const x = positions ?? ((index: number) => (n === 1 ? PLOT / 2 : (index / (n - 1)) * PLOT));
	const base = y(Math.max(shown.__lo, Math.min(0, shown.__hi)));
	const stacks = stacked
		? stack(
				data.map((_, index) =>
					Object.fromEntries(keys.map((key) => [key, shown[cell(key, index)]]))
				),
				keys
			)
		: null;
	const at = (p: Point, edge: 'top' | 'bottom') => [x(p.index), p[edge]] as const;

	return {
		ticks,
		zeroAt: shown.__lo < 0 && shown.__hi > 0 ? px(y(0)) : undefined,
		positions: data.map((_, index) => px(x(index))),
		xLabels: xLabels(
			data.map((row) => row.label),
			data.map((_, index) => x(index)),
			{ edges: !positions }
		),
		series: series.map((entry, s): LineSeriesGeometry => {
			const pointAt = (index: number) => {
				if (stacks) {
					const span = stacks[index].spans[entry.key];
					return span ? { top: y(span[1]), bottom: y(span[0]) } : null;
				}
				const value = shown[cell(entry.key, index)];
				return value === undefined ? null : { top: y(value), bottom: base };
			};
			const stretches = runs(n, pointAt);
			return {
				key: entry.key,
				color: seriesColor(entry, s),
				line: entry.line,
				path: stretches
					.map((run) =>
						trace(
							run.map((p) => at(p, 'top')),
							curve
						)
					)
					.join(''),
				area: area
					? stretches
							.map(
								(run) =>
									trace(
										run.map((p) => at(p, 'top')),
										curve
									) +
									traceBack(
										run.map((p) => at(p, 'bottom')),
										curve
									) +
									'Z'
							)
							.join('')
					: null,
				// A lone measurement between gaps is always marked, or it would not
				// show at all.
				points: stretches.flatMap((run) =>
					points || run.length === 1
						? run.map((p) => {
								const [px0, py0] = at(p, 'top');
								return { key: String(p.index), d: `M${px(px0)},${px(py0)}h0` };
							})
						: []
				),
				// Where the series meets each position, for the inspector.
				tops: data.map((_, index) => {
					const point = pointAt(index);
					return point ? px(point.top) : null;
				})
			};
		})
	};
}

Percent layouts

columns · area

Revenue mix by plan

Percent columns: each month's shares.

  • Starter
  • Team
  • Enterprise
Use the left and right arrow keys to read each position.
View data for Share of recurring revenue by plan
Share of recurring revenue by plan
Label StarterTeamEnterprise
Apr $8,200$21,400$34,000
May $8,450$22,800$34,000
Jun $8,610$23,900$41,500
Jul $8,900$25,100$41,500
Aug $9,120$26,700$43,800
Sep $9,480$28,300$49,200
Request mix by product

Percent area, smoothed.

  • API
  • Storage
  • Compute
Use the left and right arrow keys to read each position.
View data for Share of weekly requests by product
Share of weekly requests by product
Label APIStorageCompute
W27 42k18k9k
W28 45k19k11k
W29 44k21k10k
W30 51k21k14k
W31 55k22k13k
W32 53k24k16k
W33 58k25k18k
W34 61k25k17k
W35 66k27k21k
W36 64k29k24k
W37 70k30k23k
W38 74k31k27k

Shares, not sizes. Each position fills to 100%, so the mix is comparable when totals differ. The tables keep the raw values.

Source src/lib/components/charts/column-chart/doc.ts · src/lib/components/charts/column-chart/ColumnChart.svelte · src/lib/components/charts/_kernel/column.ts

src/lib/components/charts/column-chart/doc.ts

/**
 * ColumnChart — vertical columns over labelled positions.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label, data, series, formatValue?, formatTick?, height?, legend?  as
 *                  LineChart
 *     layout?      "grouped" (default) | "stacked" | "percent"
 *     animation?   default: columns grow from zero, when scrolled into view
 *     inspect?     read values by pointer or keyboard; default true
 *
 * # Behaviour
 *
 * R1  Columns measure from zero; the axis always includes it. Negative
 *     columns hang below a stronger zero line and grow downward.
 * R2  A null draws no column and leaves its slot empty.
 * R3  Stacked columns need non-negative values: others are left out of the
 *     picture and kept in the table. The stack's top is the total.
 * R4  Percent draws each column's parts as shares of its total, on a
 *     0–100% axis; the table keeps the raw values.
 * R5  New data tweens: columns, stacks, and the axis move together; a column
 *     for a newly measured value grows from zero.
 * R6  Inspection (inspect, default on): the pointer shows the nearest
 *     position — a crosshair or band, a marker on each line, and a card with
 *     every series' value (missing ones say "Not measured"; stacks add the
 *     total, percent stacks each share). The plot is one tab stop: ← → step,
 *     Home and End jump, Escape lets go, and each step is announced as one
 *     sentence. The card is for the eye; the table stays the full record.
 */
export {};

src/lib/components/charts/column-chart/ColumnChart.svelte

<script lang="ts">
	import type { AnimationProp } from '$lib/motion';
	import { chartMotion, Tweened } from '$lib/motion/svelte.svelte';
	import { cn } from '$lib/utils/cn';
	import type { CartesianDatum } from '../_kernel/cartesian';
	import { columnGeometry, columnTarget, type ColumnLayout } from '../_kernel/column';
	import type { ChartSeries } from '../_kernel/encode';
	import { formatExact, formatPercentTick, formatTick } from '../_kernel/format';
	import { readout } from '../_kernel/inspect';
	import CartesianPlot from '../_shared/CartesianPlot.svelte';
	import ChartInspector from '../_shared/ChartInspector.svelte';
	import chart from '../_shared/chart.module.css';
	import SeriesLegend from '../_shared/SeriesLegend.svelte';
	import SeriesTable from '../_shared/SeriesTable.svelte';

	/* Vertical columns over labelled positions, grouped or stacked. */
	let {
		label,
		data,
		series,
		layout = 'grouped',
		formatValue = formatExact,
		formatTick: tickFormat = formatTick,
		height,
		legend = series.length > 1,
		animation,
		inspect = true,
		class: className = ''
	}: {
		/** Names the chart. */
		label: string;
		data: readonly CartesianDatum[];
		series: readonly ChartSeries[];
		/** Series side by side, stacked into a total, or stacked as each
		 *  column's shares (percent). Stacking needs non-negative values; others
		 *  are left out of the picture, not zeroed. */
		layout?: ColumnLayout;
		formatValue?: (value: number) => string;
		formatTick?: (value: number) => string;
		/** The plot's height, as a CSS length. */
		height?: string;
		/** Show the legend; defaults to when there is more than one series. */
		legend?: boolean;
		/** Default: columns grow from zero, when scrolled into view. */
		animation?: AnimationProp;
		/** Read values position by position, by pointer or keyboard (default). */
		inspect?: boolean;
		class?: string;
	} = $props();

	const motion = chartMotion(() => animation, { enter: 'grow', axis: 'y' });
	const stacked = $derived(layout !== 'grouped');
	const model = $derived(columnTarget(data, series, layout));
	const shown = new Tweened(
		() => model.target,
		() => motion.update
	);
	const plot = $derived(
		columnGeometry(
			shown.current,
			data,
			series,
			stacked,
			model.step,
			layout === 'percent' ? formatPercentTick : tickFormat
		)
	);
</script>

{#snippet inspector()}
	<ChartInspector
		{label}
		positions={plot.positions}
		band={plot.bandWidth}
		readout={(index) =>
			readout(data[index], series, {
				format: formatValue,
				total: stacked,
				share: layout === 'percent'
			})}
	/>
{/snippet}

{#if data.length === 0 || series.length === 0}
	<div class={cn(chart.root, className)}>
		<p class={chart.empty}>No data to display.</p>
	</div>
{:else}
	<div {...motion.pending} {@attach motion.attach} class={cn(chart.root, className)}>
		{#if legend}<SeriesLegend {series} lines={false} />{/if}
		{#if model.measured}
			<CartesianPlot
				{label}
				ticks={plot.ticks}
				{height}
				zeroAt={plot.zeroAt}
				xLabels={plot.xLabels}
				overlay={inspect ? inspector : undefined}
			>
				{#each plot.series as entry (entry.key)}
					<g style:--series={entry.color}>
						{#each entry.rects as rect (rect.key)}
							<rect
								data-mark
								data-negative={rect.negative || undefined}
								class={chart.column}
								x={rect.x}
								y={rect.y}
								width={rect.width}
								height={rect.height}
							/>
						{/each}
					</g>
				{/each}
			</CartesianPlot>
		{:else}
			<p class={chart.empty}>Measurements unavailable.</p>
		{/if}
		<SeriesTable {label} {data} {series} {formatValue} />
	</div>
{/if}

src/lib/components/charts/_kernel/column.ts

import { xLabels, yAxis, type CartesianDatum } from './cartesian';
import { seriesColor, type ChartSeries } from './encode';
import { band, isValue, niceDomain, px } from './scale';
import { stack, toPercent } from './stack';

/* ColumnChart's maths: what to tween, and what to draw from the tween. */

const cell = (series: string, index: number) => `${series}|${index}`;

export type ColumnLayout = 'grouped' | 'stacked' | 'percent';

/** The data a column chart draws: as given, or as percentages of each row. */
export function columnData(
	data: readonly CartesianDatum[],
	series: readonly ChartSeries[],
	layout: ColumnLayout
) {
	return layout === 'percent'
		? toPercent(
				data,
				series.map((entry) => entry.key)
			)
		: data;
}

/** The numbers a column chart tweens: every drawable value, plus the domain
 *  (as __lo and __hi) so the axis moves with the columns. */
export function columnTarget(
	data: readonly CartesianDatum[],
	series: readonly ChartSeries[],
	layout: ColumnLayout
) {
	const keys = series.map((entry) => entry.key);
	const stacked = layout !== 'grouped';
	const rows = columnData(data, series, layout);
	const target: Record<string, number> = {};
	rows.forEach((row, index) =>
		keys.forEach((key) => {
			const value = row.values[key];
			if (isValue(value) && (!stacked || value >= 0)) target[cell(key, index)] = value;
		})
	);
	const measured = Object.keys(target).length > 0;
	const { domain, step } =
		layout === 'percent'
			? niceDomain([100])
			: niceDomain(
					stacked
						? stack(
								rows.map((row) => row.values),
								keys
							).map((entry) => entry.total)
						: Object.values(target)
				);
	target.__lo = domain[0];
	target.__hi = domain[1];
	return { target, step, measured };
}

export type ColumnRect = {
	key: string;
	x: number;
	y: number;
	width: number;
	height: number;
	negative: boolean;
};

/** What to draw for the tweened values `shown`. */
export function columnGeometry(
	shown: Readonly<Record<string, number>>,
	data: readonly CartesianDatum[],
	series: readonly ChartSeries[],
	stacked: boolean,
	step: number,
	format?: (value: number) => string
) {
	const keys = series.map((entry) => entry.key);
	const { y, ticks } = yAxis([shown.__lo, shown.__hi], step, format);
	const outer = band(data.length, 0.3);
	const inner = band(series.length, 0.12, outer.width);
	const zero = y(0);
	const valueAt = (key: string, index: number) => shown[cell(key, index)] as number | undefined;
	const stacks = stacked
		? stack(
				data.map((_, index) => Object.fromEntries(keys.map((key) => [key, valueAt(key, index)]))),
				keys
			)
		: null;

	return {
		ticks,
		zeroAt: shown.__lo < 0 ? px(zero) : undefined,
		/** Each column's centre, and the width of its band: for the inspector. */
		positions: data.map((_, index) => px(outer.centre(index))),
		bandWidth: px(outer.step),
		xLabels: xLabels(
			data.map((row) => row.label),
			data.map((_, index) => outer.centre(index))
		),
		series: series.map((entry, s) => ({
			key: entry.key,
			color: seriesColor(entry, s),
			rects: data.flatMap((row, index): ColumnRect[] => {
				let top: number;
				let bottom: number;
				let x = outer.start(index);
				let width = outer.width;
				const value = valueAt(entry.key, index);
				if (stacks) {
					const span = stacks[index].spans[entry.key];
					if (!span) return [];
					top = y(span[1]);
					bottom = y(span[0]);
				} else {
					if (value === undefined) return [];
					top = Math.min(y(value), zero);
					bottom = Math.max(y(value), zero);
					x += inner.start(s);
					width = inner.width;
				}
				return [
					{
						key: `${index}-${row.label}`,
						x: px(x),
						y: px(top),
						width: px(width),
						height: px(Math.max(0, bottom - top)),
						negative: !stacks && (value ?? 0) < 0
					}
				];
			})
		}))
	};
}

BarChart

ranked · zero · unavailable · quota

Seats by team

Support has zero seats; Finance was not reported.

  • Engineering 48
  • Design 14
  • Sales 22
  • Support 0
  • Finance Unavailable
Storage against quota

A fixed maximum: a full bar is the 1 TB quota.

  • Northstar 871 GB
  • Atlas 412 GB
  • Juniper 96 GB

The values are printed, so a bar chart is its own data table. Zero is a measurement and draws an empty track; unavailable says so.

Source src/lib/components/charts/bar-chart/doc.ts · src/lib/components/charts/bar-chart/BarChart.svelte · src/lib/components/charts/bar-chart/bar-chart.module.css

src/lib/components/charts/bar-chart/doc.ts

/**
 * BarChart — labelled horizontal bars with their values printed.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label        REQUIRED, names the list
 *     data         { key?, label, value: number | null }[]
 *     color?       a categorical slot, default 1
 *     max?         the value a full bar stands for; default the largest
 *     formatValue? default: every digit
 *     animation?   default: bars grow from the left, when scrolled into view
 *
 * # Behaviour
 *
 * R1  A list of rows: label, bar, value. The values are text, so the chart is
 *     its own data table.
 * R2  Bars measure non-negative amounts from zero. Zero draws an empty track;
 *     null, negative, or invalid reads "Unavailable" and draws no bar.
 * R3  Rows keep the caller's order: sort by value when the ranking is the
 *     point.
 * R4  With `max`, bars are shares of it (a quota); values over it fill the
 *     track and still print exactly.
 */
export {};

src/lib/components/charts/bar-chart/BarChart.svelte

<script lang="ts">
	import type { AnimationProp } from '$lib/motion';
	import { chartMotion, Tweened } from '$lib/motion/svelte.svelte';
	import { cn } from '$lib/utils/cn';
	import { barKey, barShare, barTarget, measuredBar, type BarDatum } from '../_kernel/bar';
	import { colorVar, type ChartColor } from '../_kernel/encode';
	import { formatExact } from '../_kernel/format';
	import chart from '../_shared/chart.module.css';
	import styles from './bar-chart.module.css';

	/* Labelled horizontal bars with their values printed: a ranking, a
	   breakdown. The values are text, so the chart is its own data table. */
	let {
		label,
		data,
		color = 1,
		max,
		formatValue = formatExact,
		animation,
		class: className = ''
	}: {
		/** Names the chart. */
		label: string;
		data: readonly BarDatum[];
		color?: ChartColor;
		/** The value a full bar stands for; defaults to the largest. Set it to
		 *  compare charts, or for a quota. */
		max?: number;
		formatValue?: (value: number) => string;
		/** Default: bars grow from zero, when scrolled into view. */
		animation?: AnimationProp;
		class?: string;
	} = $props();

	const motion = chartMotion(() => animation, { enter: 'grow', axis: 'x' });
	const shown = new Tweened(
		() => barTarget(data, max),
		() => motion.update
	);
</script>

<div
	{...motion.pending}
	{@attach motion.attach}
	class={cn(chart.root, className)}
	style:--series={colorVar(color)}
>
	{#if data.length === 0}
		<p class={chart.empty}>No data to display.</p>
	{:else}
		<ul aria-label={label} class={styles.list}>
			{#each data as item, index (barKey(item, index))}
				{@const key = barKey(item, index)}
				<li class={styles.row}>
					<span class={styles.label}>{item.label}</span>
					<span class={styles.track} aria-hidden="true">
						{#if measuredBar(item.value)}
							<span
								data-mark
								class={styles.fill}
								style:width="{barShare(shown.current, key) * 100}%"
							></span>
						{/if}
					</span>
					<span class={cn(styles.value, !measuredBar(item.value) && styles.unavailable)}>
						{measuredBar(item.value) ? formatValue(item.value) : 'Unavailable'}
					</span>
				</li>
			{/each}
		</ul>
	{/if}
</div>

src/lib/components/charts/bar-chart/bar-chart.module.css

@layer primitive {
	/* Rows share the list's columns (subgrid), so every track starts and ends
     at the same place whatever its label or value. */
	.list {
		display: grid;
		grid-template-columns: minmax(5rem, 1fr) minmax(4rem, 3fr) auto;
		gap: var(--space-4) var(--space-5);
		margin: 0;
		padding: 0;
		list-style: none;
	}
	.row {
		display: grid;
		grid-column: 1 / -1;
		grid-template-columns: subgrid;
		align-items: center;
		font-size: var(--text-12);
	}
	.label {
		min-width: 0;
		color: var(--ink-2);
		overflow-wrap: anywhere;
	}
	.track {
		height: 0.625rem;
		overflow: hidden;
		border-radius: var(--radius-1);
		background: var(--chart-absent, var(--surface-hover));
	}
	.fill {
		display: block;
		height: 100%;
		border-radius: inherit;
		background: var(--series);
		transform-origin: 0 50%;
	}
	.value {
		min-width: 4ch;
		color: var(--ink);
		font-variant-numeric: tabular-nums;
		text-align: end;
	}
	.unavailable {
		color: var(--ink-3);
	}
}

Sparkline

stat cards · table cells · gaps

Active users 68 +119% in 30 days
Error rate 2.3% −45% in 30 days
Deploys 147 Two days not recorded
Workspaces
WorkspaceRequestsLast 30 days
Northstar 1.4M
Atlas 812K
Juniper 96K

A sparkline shows shape, not size. Its scale runs from the lowest value to the highest, so always pair it with the number it summarises. Its accessible name carries the first, last, lowest, and highest values.

Source src/lib/components/charts/sparkline/doc.ts · src/lib/components/charts/sparkline/Sparkline.svelte · src/lib/components/charts/sparkline/sparkline.module.css

src/lib/components/charts/sparkline/doc.ts

/**
 * Sparkline — a trend in the space of a word.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label        REQUIRED, what the values are
 *     values       (number | null)[], in order
 *     color?       a categorical slot, default 1
 *     area?        fill under the line
 *     formatValue? for the summary
 *     animation?   default: wipes in, when scrolled into view
 *
 * # Behaviour
 *
 * R1  An image whose name is the label plus a summary: first, last, lowest,
 *     and highest values — or "no measurements".
 * R2  The scale runs from the lowest value to the highest, not from zero: a
 *     sparkline shows shape. Pair it with the number it summarises.
 * R3  A null is a gap. The last measurement is marked with a dot.
 * R4  Sized by its container, or by --sparkline-w and --sparkline-h.
 */
export {};

src/lib/components/charts/sparkline/Sparkline.svelte

<script lang="ts">
	import type { AnimationProp } from '$lib/motion';
	import { chartMotion, Tweened } from '$lib/motion/svelte.svelte';
	import { cn } from '$lib/utils/cn';
	import { colorVar, type ChartColor } from '../_kernel/encode';
	import { formatExact } from '../_kernel/format';
	import { PLOT } from '../_kernel/scale';
	import { sparklineGeometry, sparklineSummary, sparklineTarget } from '../_kernel/sparkline';
	import chart from '../_shared/chart.module.css';
	import styles from './sparkline.module.css';

	/* A trend in the space of a word: no axes, no labels. Its scale runs from
	   the lowest value to the highest, not from zero — it shows shape, so pair
	   it with the number it summarises. */
	let {
		label,
		values,
		color = 1,
		area = false,
		formatValue = formatExact,
		animation,
		class: className = '',
		style
	}: {
		/** What the values are; the summary is added to it. */
		label: string;
		/** In order. Null is a gap. */
		values: readonly (number | null)[];
		color?: ChartColor;
		/** Fill under the line. */
		area?: boolean;
		formatValue?: (value: number) => string;
		/** Default: wipes in from the left, when scrolled into view. */
		animation?: AnimationProp;
		class?: string;
		style?: string;
	} = $props();

	const motion = chartMotion(() => animation, { enter: 'wipe', axis: 'x' });
	const shown = new Tweened(
		() => sparklineTarget(values),
		() => motion.update
	);
	const shape = $derived(sparklineGeometry(shown.current, values));
</script>

<span
	{...motion.pending}
	{@attach motion.attach}
	class={cn(chart.root, styles.root, className)}
	{style}
	style:--series={colorVar(color)}
>
	<svg
		class={styles.svg}
		viewBox="0 0 {PLOT} {PLOT}"
		preserveAspectRatio="none"
		role="img"
		aria-label="{label}: {sparklineSummary(values, formatValue)}"
	>
		<g data-mark>
			{#if area}<path class={styles.area} d={shape.fill} />{/if}
			<path class={styles.line} d={shape.line} />
			{#if shape.end}<path class={styles.end} d={shape.end} />{/if}
		</g>
	</svg>
</span>

src/lib/components/charts/sparkline/sparkline.module.css

@layer primitive {
	.root {
		display: inline-block;
		width: var(--sparkline-w, 100%);
		height: var(--sparkline-h, 2rem);
		vertical-align: middle;
	}
	.svg {
		display: block;
		width: 100%;
		height: 100%;
		overflow: visible;
	}
	.line {
		fill: none;
		stroke: var(--series);
		stroke-linecap: round;
		stroke-linejoin: round;
		stroke-width: 1.5;
		vector-effect: non-scaling-stroke;
	}
	.area {
		fill: color-mix(in oklab, var(--series) 14%, transparent);
	}
	.end {
		stroke: var(--series);
		stroke-linecap: round;
		stroke-width: 5;
		vector-effect: non-scaling-stroke;
	}
}

DonutChart

share of a whole · folds past four

Accounts by plan
  • Free 1,840 · 63.9%
  • Starter 612 · 21.2%
  • Team 388 · 13.5%
  • Enterprise 41 · 1.4%
Storage by type

Seven parts: the three smallest fold into Other.

  • Video 412 GB · 47.3%
  • Images 238 GB · 27.3%
  • Documents 121 GB · 13.9%
  • Archives 64 GB · 7.3%
  • Other (3) 36 GB · 4.1%

Four hues, then Other. The palette never cycles, so past four parts the rest fold into a neutral Other. The legend carries every exact value and share.

Source src/lib/components/charts/donut-chart/doc.ts · src/lib/components/charts/donut-chart/DonutChart.svelte · src/lib/components/charts/donut-chart/donut-chart.module.css

src/lib/components/charts/donut-chart/doc.ts

/**
 * DonutChart — shares of one whole.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label        REQUIRED, names the chart
 *     data         { key, label, value: number | null }[]
 *     totalLabel?  under the total in the centre, default "Total"
 *     formatValue? default: every digit
 *     thickness?   the ring's width as a share of its radius; 1 is a pie
 *     PieChart     DonutChart with thickness 1
 *     animation?   default: segments trace round in turn, when scrolled
 *                  into view
 *
 * # Behaviour
 *
 * R1  A group named by the label; the legend lists every part with its exact
 *     value and share. The ring itself is decorative.
 * R2  At most four hues: with more than five parts, the fifth onward fold
 *     into "Other (n)", drawn neutral. Five parts draw the fifth neutral.
 * R3  Zero is a measured part with no segment; null, negative, or invalid is
 *     "Unavailable" and excluded from the total.
 * R4  A total of zero draws the empty track and a dash in the centre.
 * R5  Parts keep the caller's order, clockwise from twelve o'clock.
 * R6  A pie (thickness over 0.6) has no centre total; the legend's name
 *     carries it.
 */
export {};

src/lib/components/charts/donut-chart/DonutChart.svelte

<script lang="ts">
	import type { AnimationProp } from '$lib/motion';
	import { chartMotion, Tweened } from '$lib/motion/svelte.svelte';
	import { cn } from '$lib/utils/cn';
	import ChartLegend from '../chart-legend/ChartLegend.svelte';
	import {
		donutRing,
		donutArcs,
		donutParts,
		donutTarget,
		measuredPart,
		type DonutDatum
	} from '../_kernel/donut';
	import { formatExact, formatPercent } from '../_kernel/format';
	import chart from '../_shared/chart.module.css';
	import styles from './donut-chart.module.css';

	/* Shares of one whole, with the exact values and percentages beside it.
	   For a handful of parts; past four, the rest fold into "Other", because a
	   fifth hue would repeat one and small slices are unreadable anyway. */
	let {
		label,
		data,
		totalLabel = 'Total',
		formatValue = formatExact,
		thickness = 0.28,
		animation,
		class: className = ''
	}: {
		/** Names the chart. */
		label: string;
		/** Parts of one whole. Past the fourth, parts fold into "Other". */
		data: readonly DonutDatum[];
		/** Under the total in the middle. */
		totalLabel?: string;
		formatValue?: (value: number) => string;
		/** The ring's width as a share of its radius: 1 is a pie. */
		thickness?: number;
		/** Default: each segment traces round in turn, when scrolled into view. */
		animation?: AnimationProp;
		class?: string;
	} = $props();

	const motion = chartMotion(() => animation, { enter: 'trace', axis: 'x' });
	const parts = $derived(donutParts(data));
	const target = $derived(donutTarget(parts));
	const total = $derived(Object.values(target).reduce((a, b) => a + b, 0));
	const shown = new Tweened(
		() => target,
		() => motion.update
	);
	const arcs = $derived(donutArcs(shown.current, parts, thickness));
	const ring = $derived(donutRing(thickness));
	// A pie has no hole to print the total in; the legend's name carries it.
	const pie = $derived(thickness > 0.6);
</script>

<div {...motion.pending} {@attach motion.attach} class={cn(chart.root, styles.wrap, className)}>
	{#if data.length === 0}
		<p class={chart.empty}>No data to display.</p>
	{:else}
		<div class={styles.layout} role="group" aria-label={label}>
			<div class={styles.ring}>
				<svg class={styles.svg} viewBox="0 0 100 100" aria-hidden="true">
					<circle class={styles.track} cx="50" cy="50" r={ring.radius} stroke-width={ring.width} />
					{#each arcs as part (part.key)}
						<path
							data-mark
							class={styles.arc}
							d={part.d}
							pathLength={1}
							stroke-width={ring.width}
							style:--series={part.color}
						/>
					{/each}
				</svg>
				{#if !pie}
					<div class={styles.centre} aria-hidden="true">
						<span class={styles.total}>{total > 0 ? formatValue(total) : '—'}</span>
						<span class={styles.caption}>{totalLabel}</span>
					</div>
				{/if}
			</div>
			<ChartLegend
				class={styles.legend}
				layout="list"
				label="{label}: {totalLabel.toLowerCase()} {formatValue(total)}"
				items={parts.map((part) => ({
					key: part.key,
					label: part.label,
					color: part.color,
					value: measuredPart(part.value) ? formatValue(part.value) : 'Unavailable',
					note:
						measuredPart(part.value) && total > 0 ? formatPercent(part.value / total) : undefined
				}))}
			/>
		</div>
	{/if}
</div>

src/lib/components/charts/donut-chart/donut-chart.module.css

@layer primitive {
	.layout {
		display: grid;
		grid-template-columns: minmax(7rem, 11rem) minmax(12rem, 1fr);
		align-items: center;
		gap: var(--space-8);
	}
	@container (max-width: 26rem) {
		.layout {
			grid-template-columns: 1fr;
			justify-items: center;
		}
	}
	.wrap {
		container-type: inline-size;
	}
	.ring {
		position: relative;
		width: 100%;
		max-width: 11rem;
		aspect-ratio: 1;
	}
	.svg {
		display: block;
		width: 100%;
		height: 100%;
		overflow: visible;
		transform: rotate(-90deg);
	}
	.track {
		fill: none;
		stroke: var(--chart-absent, var(--surface-hover));
	}
	.arc {
		fill: none;
		stroke: var(--series);
		stroke-dasharray: 1 1;
	}
	.centre {
		position: absolute;
		inset: 0;
		display: grid;
		align-content: center;
		justify-items: center;
		gap: var(--space-1);
		text-align: center;
	}
	.total {
		color: var(--ink);
		font-size: var(--text-18, 18px);
		font-variant-numeric: tabular-nums;
		font-weight: var(--weight-strong);
		line-height: 1.1;
	}
	.caption {
		color: var(--ink-3);
		font-size: var(--text-11);
	}
	.legend {
		width: 100%;
	}
}

Motion

presets · custom spring · replay · new data

Columns
  • Signups
  • Activated
Use the left and right arrow keys to read each position.
View data for Signups and activations by month
Signups and activations by month
Label SignupsActivated
Apr 420251
May 468290
Jun 455268
Jul 512331
Aug 540362
Sep 601410
Lines
  • This year
  • Last year
Use the left and right arrow keys to read each position.
View data for Active workspaces by month
Active workspaces by month
Label This yearLast year
Oct 1,180840
Nov 1,242876
Dec 1,204910
Jan 1,310902
Feb 1,398954
Mar 1,4671,003
Apr 1,5201,041
May 1,6041,066
Jun 1,6881,102
Jul 1,7311,150
Aug 1,8401,162
Sep 1,9621,175
Bars
  • Engineering 48
  • Design 14
  • Sales 22
  • Support 0
  • Finance Unavailable
Donut
  • Free 1,840 · 63.9%
  • Starter 612 · 21.2%
  • Team 388 · 13.5%
  • Enterprise 41 · 1.4%

An animation is data: keyframes and timing, run by motion’s framework-free animate(). The same spec behaves the same in the React and Svelte apps.

Enter and update are separate. enter animates the marks in once (on mount, or when first scrolled into view); update is how they move to new data.

Reduced motion always wins: marks show at once and new data lands without tweening. Nothing is hidden when scripts do not run.

Source src/lib/motion/doc.ts · src/lib/motion/specs.ts · src/lib/motion/run.ts · src/lib/motion/svelte.svelte.ts · src/lib/components/charts/_shared/chart.module.css

src/lib/motion/doc.ts

/**
 * motion — animation as data, run the same way in both apps.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     Timing       tween { duration, ease, delay } | spring { visualDuration,
 *                  bounce, delay } — seconds
 *     EnterSpec    { keyframes, timing?, stagger? } — each property from its
 *                  first keyframe to its last
 *     EnterPreset  fade | rise | grow | wipe | trace | pop
 *     animation    preset | { enter?, update?, trigger? } | false
 *                    enter    preset | EnterSpec | false
 *                    update   Timing | false — how marks move to new data
 *                    trigger  "visible" (default) | "mount"
 *
 * # Behaviour
 *
 * R1  A spec is plain data: no functions, no framework. The same spec gives
 *     the same animation in the React and the Svelte app.
 * R2  With reduced motion preferred, nothing animates: an entrance shows its
 *     end state at once and new data lands without tweening.
 * R3  Before its entrance, a component's animated parts are hidden only when
 *     scripts run and motion is allowed. Without scripts they are visible;
 *     they never flash at full size and then animate in.
 * R4  An entrance runs once per mount. "visible" waits until a quarter of the
 *     component has scrolled into view; replay by remounting.
 * R5  An update tween interrupted by newer data continues from wherever it
 *     had got to, never from the start.
 * R6  Server and client render the same final state; motion begins after
 *     hydration.
 *
 * # The animate helper — any element
 *
 *     enter?    preset | EnterSpec — once per element
 *     trigger?  "visible" (default) | "mount"
 *     targets?  a selector: animate these descendants in, staggered
 *     hover?    lift | grow | squish | { to, timing } — while pointed at
 *     press?    lift | grow | squish | { to, timing } — while pressed
 *     change?   { on, animation? } — pulse (default) | bump | flash | shake |
 *               { keyframes, timing }, played each time `on` changes
 *
 * R7  Hover and press move to a state and back to rest; pressing wins over
 *     hovering. Hover ignores touch. Press works from the keyboard (Enter,
 *     Space) on a focusable element.
 * R8  A change animation ends where it began, and never plays on first
 *     render. `on` is compared by identity: pass a primitive.
 * R9  Re-rendering never replays an entrance.
 * R10 Under reduced motion, gestures and change animations do nothing.
 *
 * # Presets
 *
 *     fade   opacity                         any mark
 *     rise   opacity + a short upward move   any mark
 *     grow   scale from the baseline         bars and columns
 *     wipe   revealed left to right          lines, areas, sparklines
 *     trace  stroke drawn along its path     donut segments
 *     pop    scale from the centre, springy  points and small marks
 *
 *     lift   up 3px            hover        pulse  scale 1 → 1.08 → 1   change
 *     grow   scale 1.03        hover        bump   up 6px and back      change
 *     squish scale 0.96        press        flash  opacity dips         change
 *                                           shake  side to side         change
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — how this implementation meets the contract.                 │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * specs.ts holds the types and presets; run.ts turns them into calls to
 * motion's framework-free animate(), stagger(), and inView(). svelte.svelte.ts
 * (react.ts in the Next app) only wires those to a component's lifecycle: an
 * attachment for the entrance, a Tweened class for updates.
 *
 * The helper: lib/motion/svelte.svelte.ts: animate(options) returns an object to spread —
 * the pending attribute plus an attachment keyed with createAttachmentKey(),
 * so it passes through components that forward rest props. The pre-entrance rule for it is lib/motion/motion.css,
 * in the override layer, which hides the whole element while it is pending.
 *
 * R3: components render data-motion-pending; a stylesheet hides their
 * [data-mark] elements under @media (scripting: enabled) and
 * (prefers-reduced-motion: no-preference). runEnter removes the attribute in
 * the same task that starts the animation, so no frame paints in between.
 *
 * Updates tween the data, not the geometry: a record of numbers is mixed
 * frame by frame (a new key starts from zero), and the component redraws
 * from the mix. So stacks, arcs, and axes move together with no per-shape
 * interpolation.
 */
export {};

src/lib/motion/specs.ts

import type { DOMKeyframesDefinition } from 'motion';

/* Animation as data. Nothing here imports a framework: the same specs run in
   the React and the Svelte app, through the same runner, so an effect defined
   once looks the same in both. */

/** A cubic-bezier, or one of motion's named curves. */
export type Ease =
	| readonly [number, number, number, number]
	| 'linear'
	| 'easeIn'
	| 'easeOut'
	| 'easeInOut'
	| 'backOut'
	| 'circOut';

/** How long and how. Seconds throughout. */
export type Timing =
	| { type?: 'tween'; duration?: number; ease?: Ease; delay?: number }
	| {
			type: 'spring';
			/** How long the spring appears to take; the tail settles after. */
			visualDuration?: number;
			/** 0 is no overshoot; 0.5 is very bouncy. */
			bounce?: number;
			delay?: number;
	  };

/** An entrance: every mark goes from its first keyframe to its last. */
export type EnterSpec = {
	keyframes: DOMKeyframesDefinition;
	timing?: Timing;
	/** Seconds between one mark's start and the next's. */
	stagger?: number;
};

/** Built-in entrances. Each chart picks a default that suits its marks. */
export type EnterPreset = 'fade' | 'rise' | 'grow' | 'wipe' | 'trace' | 'pop';

export type ChartAnimation = {
	/** The marks' entrance; false shows them at once. */
	enter?: EnterPreset | EnterSpec | false;
	/** How marks move to new data; false jumps. */
	update?: Timing | false;
	/** Enter on mount, or the first time the chart scrolls into view. */
	trigger?: 'mount' | 'visible';
};

/** What a chart's `animation` prop takes: a preset name, a full spec, or
 *  false for none. Omitted means the chart's defaults. */
export type AnimationProp = EnterPreset | ChartAnimation | false;

/** The axis a chart's marks grow along, which is what `grow` needs to know. */
export type GrowAxis = 'x' | 'y';

export const DEFAULT_TIMING = {
	duration: 0.6,
	ease: [0.22, 1, 0.36, 1]
} satisfies Timing;

export const DEFAULT_UPDATE: Timing = {
	duration: 0.45,
	ease: [0.22, 1, 0.36, 1]
};

/** The presets as keyframes. `grow` and `wipe` depend on the chart's axis; the
 *  CSS sets each mark's transform origin at its baseline. */
export function presetSpec(preset: EnterPreset, axis: GrowAxis): EnterSpec {
	switch (preset) {
		case 'fade':
			return { keyframes: { opacity: [0, 1] }, stagger: 0.03 };
		case 'rise':
			return {
				keyframes: {
					opacity: [0, 1],
					transform: ['translateY(12px)', 'translateY(0px)']
				},
				stagger: 0.04
			};
		case 'grow':
			return {
				keyframes: {
					transform: axis === 'y' ? ['scaleY(0)', 'scaleY(1)'] : ['scaleX(0)', 'scaleX(1)']
				},
				stagger: 0.04
			};
		case 'wipe':
			return {
				keyframes: {
					clipPath: ['inset(0 100% 0 0)', 'inset(0 0% 0 0)']
				},
				timing: { duration: 0.9, ease: [0.45, 0, 0.2, 1] },
				stagger: 0.12
			};
		case 'trace':
			// Marks carry pathLength="1" and a dash of 1, so an offset of 1 hides
			// the whole stroke and 0 shows it.
			return {
				keyframes: { strokeDashoffset: [1, 0] },
				timing: { duration: 0.5, ease: 'easeInOut' },
				stagger: 0.5
			};
		case 'pop':
			return {
				keyframes: {
					opacity: [0, 1],
					transform: ['scale(0)', 'scale(1)']
				},
				timing: { type: 'spring', visualDuration: 0.4, bounce: 0.35 },
				stagger: 0.02
			};
	}
}

/** A chart's `animation` prop, resolved against the chart's defaults. */
export function resolveAnimation(
	prop: AnimationProp | undefined,
	defaults: { enter: EnterPreset; axis: GrowAxis }
): {
	enter: EnterSpec | null;
	update: Timing | null;
	trigger: 'mount' | 'visible';
} {
	if (prop === false) return { enter: null, update: null, trigger: 'mount' };
	const options: ChartAnimation = typeof prop === 'string' ? { enter: prop } : (prop ?? {});
	const enter = options.enter ?? defaults.enter;
	return {
		enter:
			enter === false ? null : typeof enter === 'string' ? presetSpec(enter, defaults.axis) : enter,
		update: options.update === false ? null : (options.update ?? DEFAULT_UPDATE),
		trigger: options.trigger ?? 'visible'
	};
}

/* ── The general helper: any element, not only charts ── */

/** Target values, in motion's shorthand: x, y, scale, rotate, opacity… */
export type MotionTarget = Readonly<Record<string, number | string>>;

/** A state an element moves to while hovered or pressed, and back. */
export type GestureSpec = { to: MotionTarget; timing?: Timing };
export type GesturePreset = 'lift' | 'grow' | 'squish';

/** Keyframes played when a watched value changes, ending where they began. */
export type ChangeSpec = { keyframes: DOMKeyframesDefinition; timing?: Timing };
export type ChangePreset = 'pulse' | 'bump' | 'flash' | 'shake';

export type AnimateOptions = {
	/** Animate in: a preset or keyframes; false or omitted, no entrance. */
	enter?: EnterPreset | EnterSpec | false;
	/** When the entrance runs. Default "visible": first scrolled into view. */
	trigger?: 'mount' | 'visible';
	/** Animate these descendants in, staggered, instead of the element. */
	targets?: string;
	/** While the pointer is over it. Ignored on touch. */
	hover?: GesturePreset | GestureSpec;
	/** While it is pressed: pointer, or Enter/Space when focused. */
	press?: GesturePreset | GestureSpec;
	/** Play `animation` each time `on` changes (not on first render). */
	change?: { on: unknown; animation?: ChangePreset | ChangeSpec };
};

const SNAPPY: Timing = { type: 'spring', visualDuration: 0.25, bounce: 0.3 };

export const GESTURES: Record<GesturePreset, GestureSpec> = {
	lift: { to: { y: -3 }, timing: SNAPPY },
	grow: { to: { scale: 1.03 }, timing: SNAPPY },
	squish: { to: { scale: 0.96 }, timing: { duration: 0.1, ease: 'easeOut' } }
};

export const CHANGES: Record<ChangePreset, ChangeSpec> = {
	pulse: { keyframes: { scale: [1, 1.08, 1] }, timing: { duration: 0.35 } },
	bump: { keyframes: { y: [0, -6, 0] }, timing: { duration: 0.35 } },
	flash: { keyframes: { opacity: [1, 0.35, 1] }, timing: { duration: 0.5 } },
	shake: {
		keyframes: { x: [0, -6, 6, -4, 4, 0] },
		timing: { duration: 0.4, ease: 'easeInOut' }
	}
};

export const gestureSpec = (value: GesturePreset | GestureSpec | undefined) =>
	typeof value === 'string' ? GESTURES[value] : value;
export const changeSpec = (value: ChangePreset | ChangeSpec | undefined) =>
	typeof value === 'string' ? CHANGES[value] : (value ?? CHANGES.pulse);
export const enterSpec = (value: AnimateOptions['enter']) =>
	!value ? null : typeof value === 'string' ? presetSpec(value, 'y') : value;

/* ── Exits: an element leaving before it is removed ── */

/** Keyframes from the element's resting state to gone. Most overlays exit
 *  in CSS (their libraries wait for it); this is for lists a component
 *  manages itself — toasts. */
export type ExitSpec = { keyframes: DOMKeyframesDefinition; timing?: Timing };
export type ExitPreset = 'fade' | 'slide-right' | 'slide-down' | 'shrink';

export const EXITS: Record<ExitPreset, ExitSpec> = {
	fade: {
		keyframes: { opacity: [1, 0] },
		timing: { duration: 0.18, ease: 'easeIn' }
	},
	'slide-right': {
		keyframes: {
			opacity: [1, 0],
			transform: ['translateX(0px)', 'translateX(24px)']
		},
		timing: { duration: 0.2, ease: 'easeIn' }
	},
	'slide-down': {
		keyframes: {
			opacity: [1, 0],
			transform: ['translateY(0px)', 'translateY(12px)']
		},
		timing: { duration: 0.2, ease: 'easeIn' }
	},
	shrink: {
		keyframes: { opacity: [1, 0], transform: ['scale(1)', 'scale(0.94)'] },
		timing: { duration: 0.16, ease: 'easeIn' }
	}
};

export const exitSpec = (value: ExitPreset | ExitSpec | undefined) =>
	typeof value === 'string' ? EXITS[value] : (value ?? EXITS.fade);

src/lib/motion/run.ts

import {
	animate,
	hover,
	inView,
	press,
	stagger,
	type DOMKeyframesDefinition,
	type Easing
} from 'motion';
import {
	changeSpec,
	exitSpec,
	DEFAULT_TIMING,
	gestureSpec,
	type AnimateOptions,
	type EnterSpec,
	type ExitPreset,
	type ExitSpec,
	type GestureSpec,
	type Timing
} from './specs';

/* The runner: turns specs into motion calls. Framework-free, so both apps
   call exactly this. */

export function prefersReducedMotion() {
	return (
		typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
	);
}

const DEFAULT_DURATION = 0.6;

/** Timing as motion's options. */
export function toOptions(timing: Timing = DEFAULT_TIMING) {
	if (timing.type === 'spring') {
		return {
			type: 'spring' as const,
			visualDuration: timing.visualDuration ?? 0.5,
			bounce: timing.bounce ?? 0.25,
			delay: timing.delay ?? 0
		};
	}
	return {
		duration: timing.duration ?? DEFAULT_DURATION,
		// Motion's type wants a mutable tuple; the spec's is readonly data.
		ease: (timing.ease ?? 'easeOut') as Easing,
		delay: timing.delay ?? 0
	};
}

/** The attribute a chart renders while its entrance has not run. CSS hides
 *  the marks under it — only when scripting is on and motion is allowed — so
 *  they do not flash at full size before animating in, and never stay hidden
 *  without JavaScript. */
export const PENDING = 'data-motion-pending';

const TRANSFORMS = new Set([
	'x',
	'y',
	'z',
	'scale',
	'scaleX',
	'scaleY',
	'rotate',
	'rotateX',
	'rotateY',
	'skew',
	'skewX',
	'skewY'
]);

/** Remove what an entrance of `keyframes` left behind: its finished Web
 *  Animations, which motion keeps filling forwards (they would override any
 *  later animation of the same property — a hover lift, a change pulse), and
 *  the inline styles it committed. */
function clearStyles(mark: Element, keyframes: object) {
	for (const animation of mark.getAnimations())
		if (animation.playState === 'finished') animation.cancel();
	const style = (mark as HTMLElement | SVGElement).style;
	for (const key of Object.keys(keyframes)) {
		const property = TRANSFORMS.has(key)
			? 'transform'
			: key.startsWith('--')
				? key
				: key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
		style.removeProperty(property);
	}
}

/** The longest a stagger may spread an entrance, in seconds. */
const MAX_STAGGER = 2;

/** Selects the marks an entrance animates. */
export const MARK = '[data-mark]';

/** Elements whose entrance has started, so a re-attach never replays it. */
const started = new WeakSet<Element>();

/**
 * Run an entrance on `root`'s marks (descendants matching `targets`, or the
 * root itself when `targets` is null), now or when it first scrolls into
 * view. Returns a cleanup that stops it.
 */
export function runEnter(
	root: Element,
	spec: EnterSpec | null,
	trigger: 'mount' | 'visible',
	targets: string | null = MARK
): () => void {
	let controls: { stop: () => void } | undefined;
	const reveal = () => root.removeAttribute(PENDING);

	const start = () => {
		started.add(root);
		const marks = targets ? [...root.querySelectorAll(targets)] : [root];
		if (!spec || marks.length === 0 || prefersReducedMotion()) {
			reveal();
			return;
		}
		const options = toOptions(spec.timing);
		const animation = animate(marks, spec.keyframes, {
			...options,
			// However many marks, the stagger never adds more than two seconds: a
			// few hundred points must not take minutes to arrive.
			delay: spec.stagger
				? stagger(Math.min(spec.stagger, MAX_STAGGER / marks.length), {
						startDelay: options.delay
					})
				: options.delay
		});
		controls = animation;
		// Same task as the animation's first frame, so nothing paints between.
		reveal();
		// An entrance ends at the mark's natural state, so its inline styles are
		// cleared once it finishes: a leftover clip-path or transform would
		// otherwise keep clipping strokes or fight the stylesheet.
		// Motion commits each element's final style as it finishes, which can
		// land after `finished` settles; clearing a frame later runs after it.
		animation.finished.then(
			() => requestAnimationFrame(() => marks.forEach((mark) => clearStyles(mark, spec.keyframes))),
			() => {}
		);
	};

	if (trigger === 'mount' || !spec) {
		start();
		return () => controls?.stop();
	}
	const stopWatching = inView(
		root,
		() => {
			start();
			stopWatching();
		},
		{ amount: 0.25 }
	);
	return () => {
		stopWatching();
		controls?.stop();
	};
}

export type NumberRecord = Readonly<Record<string, number>>;

/** Where a key new in the target starts: from zero (a bar growing from its
 *  baseline) or already at its target (a node placed where it belongs). */
export type Fresh = 'zero' | 'target';

/** Mix two records by key. A key new in `to` starts from zero or at its
 *  target (see Fresh); a key missing from `to` is dropped. */
export function mixRecord(
	from: NumberRecord,
	to: NumberRecord,
	t: number,
	fresh: Fresh = 'zero'
): Record<string, number> {
	const out: Record<string, number> = {};
	for (const key in to) {
		const start = from[key] ?? (fresh === 'target' ? to[key] : 0);
		out[key] = start + (to[key] - start) * t;
	}
	return out;
}

/**
 * Tween from one record of numbers to another, calling `onFrame` with the mix
 * each frame. Returns a stop function. With no timing, or reduced motion, it
 * lands on `to` at once.
 */
export function tweenRecord(
	from: NumberRecord,
	to: NumberRecord,
	timing: Timing | null,
	onFrame: (value: Record<string, number>) => void,
	fresh: Fresh = 'zero'
): () => void {
	if (!timing || prefersReducedMotion()) {
		onFrame({ ...to });
		return () => {};
	}
	const controls = animate(0, 1, {
		...toOptions(timing),
		onUpdate: (t: number) => onFrame(mixRecord(from, to, t, fresh))
	});
	return () => controls.stop();
}

/**
 * An entrance that runs at most once per element, however often it is
 * attached (a re-render, a strict-mode double effect). The cleanup stops
 * waiting for visibility but lets a started animation finish.
 */
export function enterOnce(
	element: Element,
	spec: EnterSpec | null,
	trigger: 'mount' | 'visible',
	targets: string | null
): () => void {
	if (started.has(element)) {
		element.removeAttribute(PENDING);
		return () => {};
	}
	let stopWatching = () => {};
	const run = () => {
		runEnter(element, spec, 'mount', targets);
	};
	if (trigger === 'mount' || !spec) run();
	else
		stopWatching = inView(
			element,
			() => {
				run();
				stopWatching();
			},
			{ amount: 0.25 }
		);
	return () => stopWatching();
}

/* Motion keeps one `transform` value per element. An entrance that animates
   the `transform` string would win over later shorthand keys (y, scale), so
   gestures and changes turn their shorthands into a full transform string
   too: every animation on an element then moves the same value. */
const SHORTHAND = {
	x: 0,
	y: 0,
	scale: 1,
	scaleX: 1,
	scaleY: 1,
	rotate: 0
} as const;
type Shorthand = keyof typeof SHORTHAND;
const isShorthand = (key: string): key is Shorthand => key in SHORTHAND;

function transformOf(values: Partial<Record<Shorthand, number>>) {
	const at = (key: Shorthand) => values[key] ?? SHORTHAND[key];
	const scale = values.scale ?? 1;
	return `translate(${at('x')}px, ${at('y')}px) scale(${scale * at('scaleX')}, ${scale * at('scaleY')}) rotate(${at('rotate')}deg)`;
}

/** `keyframes` with any x/y/scale/rotate folded into one `transform`. Values
 *  may be single or arrays of keyframes; arrays are read index by index. */
export function withTransform(
	keyframes: Readonly<Record<string, unknown>>
): Record<string, unknown> {
	const out: Record<string, unknown> = {};
	const shorthands: [Shorthand, number | number[]][] = [];
	for (const [key, value] of Object.entries(keyframes)) {
		if (isShorthand(key)) shorthands.push([key, value as number | number[]]);
		else out[key] = value;
	}
	if (!shorthands.length) return out;
	const frames = Math.max(...shorthands.map(([, v]) => (Array.isArray(v) ? v.length : 1)));
	const frame = (index: number) =>
		transformOf(
			Object.fromEntries(
				shorthands.map(([key, v]) => [key, Array.isArray(v) ? v[Math.min(index, v.length - 1)] : v])
			)
		);
	out.transform = frames === 1 ? frame(0) : Array.from({ length: frames }, (_, i) => frame(i));
	return out;
}

/** Play keyframes that end where they began: a change, drawn attention to. */
export function playChange(
	element: Element,
	animation: NonNullable<AnimateOptions['change']>['animation']
): () => void {
	if (prefersReducedMotion()) return () => {};
	const spec = changeSpec(animation);
	const controls = animate(
		element,
		withTransform(spec.keyframes as Record<string, unknown>) as DOMKeyframesDefinition,
		toOptions(spec.timing)
	);
	return () => controls.stop();
}

/** The resting value of a property a gesture moves. */
const REST: Record<string, number> = {
	x: 0,
	y: 0,
	z: 0,
	rotate: 0,
	rotateX: 0,
	rotateY: 0,
	skewX: 0,
	skewY: 0,
	scale: 1,
	scaleX: 1,
	scaleY: 1,
	opacity: 1
};

/**
 * Move to `hover` while the pointer is over the element and to `press` while
 * it is pressed (pressing wins), and back to rest after. Nothing under
 * reduced motion. Returns a cleanup that unbinds.
 */
export function bindGestures(
	element: Element,
	hoverSpec: AnimateOptions['hover'],
	pressSpec: AnimateOptions['press']
): () => void {
	const onHover = gestureSpec(hoverSpec);
	const onPress = gestureSpec(pressSpec);
	if ((!onHover && !onPress) || prefersReducedMotion()) return () => {};

	const rest: Record<string, number | string> = {};
	const style = getComputedStyle(element);
	for (const spec of [onHover, onPress])
		for (const key of Object.keys(spec?.to ?? {}))
			rest[key] = REST[key] ?? style.getPropertyValue(key);

	let hovered = false;
	let pressed = false;
	// Motion reads a value it has never animated from the computed style, and
	// reads a computed `none` as a zeroed transform — scale 0. The first move
	// therefore starts explicitly from rest.
	let first = true;
	const restFrame = withTransform(rest).transform;
	const settle = (via: GestureSpec | undefined) => {
		const target = withTransform({
			...rest,
			...(hovered ? onHover?.to : {}),
			...(pressed ? onPress?.to : {})
		});
		if (first && restFrame !== undefined && target.transform !== undefined)
			target.transform = [restFrame, target.transform];
		first = false;
		animate(element, target as DOMKeyframesDefinition, toOptions(via?.timing));
	};

	const cleanups: (() => void)[] = [];
	if (onHover)
		cleanups.push(
			hover(element, () => {
				hovered = true;
				settle(onHover);
				return () => {
					hovered = false;
					settle(onHover);
				};
			})
		);
	if (onPress)
		cleanups.push(
			press(element, () => {
				pressed = true;
				settle(onPress);
				return () => {
					pressed = false;
					settle(onPress);
				};
			})
		);
	return () => cleanups.forEach((cleanup) => cleanup());
}

/**
 * Animate an element out, resolving when it has gone (at once under reduced
 * motion). The caller removes it after: `await exitElement(el); remove()`.
 */
export async function exitElement(element: Element, exit?: ExitPreset | ExitSpec): Promise<void> {
	if (prefersReducedMotion()) return;
	const spec = exitSpec(exit);
	await animate(element, spec.keyframes, toOptions(spec.timing)).finished.catch(() => {});
}

src/lib/motion/svelte.svelte.ts

import { untrack } from 'svelte';
import { createAttachmentKey, type Attachment } from 'svelte/attachments';

import {
	bindGestures,
	enterOnce,
	PENDING,
	playChange,
	runEnter,
	tweenRecord,
	type Fresh,
	type NumberRecord
} from './run';
import {
	enterSpec,
	resolveAnimation,
	type AnimateOptions,
	type AnimationProp,
	type EnterPreset,
	type EnterSpec,
	type GrowAxis,
	type Timing
} from './specs';

/** An attachment that runs an entrance on the element it is attached to.
 *  Remount (a keyed block) to replay it. */
export function enter(spec: EnterSpec | null, trigger: 'mount' | 'visible'): Attachment<Element> {
	return (node) => runEnter(node, spec, trigger);
}

/** A record of numbers that moves to each new target over `timing`. Starts at
 *  the first target, so server and client agree; a new target mid-tween
 *  starts from wherever the last had got to. Construct during component
 *  initialisation. */
export class Tweened {
	current = $state.raw<NumberRecord>({});
	#stop = () => {};

	constructor(target: () => NumberRecord, timing: () => Timing | null, fresh: Fresh = 'zero') {
		const initial = target();
		this.current = initial;
		let last = JSON.stringify(initial);

		$effect(() => {
			const to = target();
			const key = JSON.stringify(to);
			if (key === last) return;
			last = key;
			const how = timing();
			const from = untrack(() => this.current);
			this.#stop();
			this.#stop = tweenRecord(from, to, how, (value) => (this.current = value), fresh);
		});
		$effect(() => () => this.#stop());
	}
}

/** A chart's motion wiring: its resolved animation, the attachment for its
 *  root, and the attribute that hides its marks until the entrance runs. */
export function chartMotion(
	animation: () => AnimationProp | undefined,
	defaults: { enter: EnterPreset; axis: GrowAxis }
) {
	const resolved = $derived(resolveAnimation(animation(), defaults));
	return {
		get update() {
			return resolved.update;
		},
		get attach() {
			return enter(resolved.enter, resolved.trigger);
		},
		get pending() {
			return { [PENDING]: resolved.enter ? '' : undefined };
		}
	};
}

/** The last `change.on` seen per element, so only a real change plays. */
const seen = new WeakMap<Element, { on: unknown }>();

/**
 * Animate any element: an entrance, hover and press states, and a flourish
 * when a value changes. Spread the result on an element, or on a component
 * that forwards its rest props:
 *
 *     <Card {...animate({ enter: 'rise', hover: 'lift' })}>
 *
 * It carries the pre-entrance attribute (rendered on the server too, so
 * nothing flashes) and an attachment. The attachment re-runs when the options
 * change, but an entrance plays once per element; `change.on` is compared by
 * identity, so pass a primitive.
 */
export function animate(options: AnimateOptions) {
	const attachment: Attachment<Element> = (element) => {
		const cleanups = [
			enterOnce(
				element,
				enterSpec(options.enter),
				options.trigger ?? 'visible',
				options.targets ?? null
			),
			bindGestures(element, options.hover, options.press)
		];
		if (options.change) {
			const previous = seen.get(element);
			seen.set(element, { on: options.change.on });
			if (previous && !Object.is(previous.on, options.change.on))
				cleanups.push(playChange(element, options.change.animation));
		}
		return () => cleanups.forEach((cleanup) => cleanup());
	};
	return {
		[PENDING]: options.enter ? 'enter' : undefined,
		[createAttachmentKey()]: attachment
	};
}

src/lib/components/charts/_shared/chart.module.css

@layer primitive {
	.root {
		display: grid;
		min-width: 0;
		gap: var(--space-5);
		color: var(--ink);
		font-size: var(--text-13);
	}

	/* Before its entrance runs, a chart's marks are hidden — only when scripts
     run and motion is allowed, so they never flash at full size first and
     never stay hidden without JavaScript. The runner removes the attribute. */
	@media (scripting: enabled) and (prefers-reduced-motion: no-preference) {
		.root[data-motion-pending] [data-mark] {
			opacity: 0;
		}
	}

	/* Transforms on SVG marks resolve against the mark's own box, so `grow`
     and `pop` scale from its baseline or centre, not the SVG's corner. */
	.root [data-mark] {
		transform-box: fill-box;
	}

	/* ── Cartesian plot: y labels | stretched SVG, then x labels ── */
	.cartesian {
		display: grid;
		grid-template-columns: calc(var(--axis-ch, 3) * 1ch) minmax(0, 1fr);
		grid-template-rows: var(--plot-h, 13rem) auto;
		column-gap: var(--space-4);
		container-type: inline-size;
		font-family: var(--font-mono);
		font-size: var(--text-11);
		font-variant-numeric: tabular-nums;
	}
	.yAxis {
		position: relative;
		color: var(--chart-label, var(--ink-3));
	}
	.yAxis span {
		position: absolute;
		right: 0;
		line-height: 1;
		transform: translateY(-50%);
		white-space: nowrap;
	}
	.area {
		position: relative;
		min-width: 0;
	}
	.svg {
		position: absolute;
		inset: 0;
		display: block;
		width: 100%;
		height: 100%;
		overflow: visible;
	}
	/* Printed values over the plot, placed by percentage. */
	.notes {
		position: absolute;
		inset: 0;
		pointer-events: none;
	}
	.notes span {
		position: absolute;
		color: var(--chart-label, var(--ink-3));
		line-height: 1;
		transform: translate(-50%, calc(-100% - 4px));
		white-space: nowrap;
	}
	.xAxis {
		position: relative;
		height: 1.4em;
		grid-column: 2;
		margin-top: var(--space-3);
		color: var(--chart-label, var(--ink-3));
	}
	.xAxis span {
		position: absolute;
		top: 0;
		line-height: 1.4;
		transform: translateX(-50%);
		white-space: nowrap;
	}
	.xAxis span[data-align='start'] {
		transform: none;
	}
	.xAxis span[data-align='end'] {
		transform: translateX(-100%);
	}
	/* Wrapped: every label, as wide as its column, on up to three lines. */
	.xAxis[data-wrap] {
		height: 3.6em;
	}
	.xAxis[data-wrap] span {
		line-height: 1.2;
		text-align: center;
		white-space: normal;
	}
	/* Angled: every label, rotated about its end so it hangs under its bar. */
	.xAxis[data-angled] {
		height: calc(var(--label-ch, 4) * 0.7ch + 1em);
	}
	.xAxis[data-angled] span {
		transform: translateX(-100%) rotate(-40deg);
		transform-origin: right top;
	}
	@container (max-width: 34rem) {
		.xAxis span[data-tier='0'] {
			display: none;
		}
	}
	@container (max-width: 20rem) {
		.xAxis span:not([data-tier='2']) {
			display: none;
		}
	}
	.grid {
		stroke: var(--chart-grid, var(--line));
		stroke-width: 1;
		vector-effect: non-scaling-stroke;
	}
	.zero {
		stroke: var(--chart-axis, var(--line-strong));
		stroke-width: 1;
		vector-effect: non-scaling-stroke;
	}

	/* ── Marks. A series sets --series; its marks paint with it. ── */
	.column {
		fill: var(--series);
		transform-origin: 50% 100%;
	}
	.column[data-negative] {
		transform-origin: 50% 0;
	}
	.line {
		fill: none;
		stroke: var(--series);
		stroke-linecap: round;
		stroke-linejoin: round;
		stroke-width: 2;
		vector-effect: non-scaling-stroke;
	}
	.line[data-line='dashed'] {
		stroke-dasharray: 6 4;
	}
	.line[data-line='dotted'] {
		stroke-dasharray: 0.5 4.5;
	}
	.area {
		fill: color-mix(in oklab, var(--series) 16%, transparent);
		stroke: none;
	}
	.area[data-stacked] {
		fill: color-mix(in oklab, var(--series) 34%, transparent);
	}
	/* A zero-length stroke with round caps: a circle that stays round when the
     SVG stretches. */
	.point {
		stroke: var(--series);
		stroke-linecap: round;
		stroke-width: 7;
		vector-effect: non-scaling-stroke;
	}

	.empty {
		display: grid;
		min-height: 8rem;
		place-items: center;
		padding: var(--space-7);
		border: 1px dashed var(--line);
		border-radius: var(--radius-2);
		color: var(--ink-3);
		font-size: var(--text-13);
		text-align: center;
	}

	/* ── The exact values, as a table behind a disclosure ── */
	.data {
		color: var(--ink-3);
		font-size: var(--text-12);
	}
	.data > summary {
		width: fit-content;
		padding-block: var(--space-2);
		border-radius: var(--radius-1);
		cursor: pointer;
	}
	.data > summary:focus-visible {
		outline: 2px solid var(--accent);
		outline-offset: 2px;
	}
	.data[open] > summary {
		margin-bottom: var(--space-4);
	}
}

@layer primitive {
	/* ── Scatter marks: the plot keeps its aspect, so these stay round ── */
	.dot {
		fill: var(--chart-neutral);
		transform-origin: center;
	}
	.dot[data-state='up'] {
		fill: var(--chart-pos);
	}
	.dot[data-state='down'] {
		fill: var(--chart-neg);
	}
	.bubble {
		fill: var(--series);
		fill-opacity: 0.6;
		stroke: var(--surface-panel);
		stroke-width: 1.5;
		vector-effect: non-scaling-stroke;
		transform-origin: center;
	}
}

@layer primitive {
	.shape {
		fill: var(--series);
		fill-opacity: 0.85;
		stroke: var(--surface-panel);
		stroke-width: 1;
		vector-effect: non-scaling-stroke;
	}
}

@layer primitive {
	/* ── Inspector: over the plot, reading one position at a time ── */
	.inspect {
		position: absolute;
		inset: 0;
		border-radius: var(--radius-1);
		cursor: crosshair;
		/* A horizontal drag scrubs; a vertical one still scrolls the page. */
		touch-action: pan-y;
	}
	.inspect:focus-visible {
		outline: 2px solid var(--accent);
		outline-offset: 4px;
	}
	.crosshair,
	.inspectBand,
	.marker,
	.readout {
		position: absolute;
		pointer-events: none;
	}
	.crosshair {
		top: 0;
		bottom: 0;
		width: 0;
		border-left: 1px dashed var(--chart-axis, var(--line-strong));
	}
	.inspectBand {
		top: 0;
		bottom: 0;
		background: color-mix(in oklab, var(--ink) 6%, transparent);
	}
	.marker {
		width: 9px;
		height: 9px;
		border: 2px solid var(--surface-panel);
		border-radius: 50%;
		background: var(--series);
		box-shadow: 0 0 0 1px var(--series);
		transform: translate(-50%, -50%);
	}
	/* Beside the crosshair (at --x) on the side with room, and clamped inside
     the plot: on a narrow plot it slides to the edge rather than off it. */
	.readout {
		--readout-w: min(15rem, 100%);
		--gap: var(--space-5);
		top: 0;
		left: clamp(0px, calc(var(--x) + var(--gap)), calc(100% - var(--readout-w)));
		z-index: 1;
		display: grid;
		width: var(--readout-w);
		gap: var(--space-2);
		padding: var(--space-4) var(--space-5);
		border: 1px solid var(--line);
		border-radius: var(--radius-2);
		background: var(--surface-panel);
		box-shadow: var(--shadow);
		font-family: var(--font-sans);
		font-size: var(--text-12);
	}
	.readout[data-side='left'] {
		left: clamp(0px, calc(var(--x) - var(--gap) - var(--readout-w)), calc(100% - var(--readout-w)));
	}
	@media (prefers-reduced-motion: no-preference) {
		.crosshair,
		.inspectBand,
		.marker,
		.readout {
			transition:
				left var(--dur-1) var(--ease),
				top var(--dur-1) var(--ease);
		}
	}
	.readoutTitle {
		margin: 0;
		color: var(--ink);
		font-weight: var(--weight-strong);
	}
	.readoutRow {
		display: flex;
		align-items: baseline;
		gap: var(--space-4);
		margin: 0;
		color: var(--ink-2);
	}
	.readoutRow::before {
		width: 8px;
		height: 8px;
		flex: none;
		align-self: center;
		border-radius: 2px;
		background: var(--series);
		content: '';
	}
	.readoutRow[data-total] {
		padding-top: var(--space-2);
		border-top: 1px solid var(--line);
		color: var(--ink);
	}
	.readoutRow[data-total]::before {
		background: none;
	}
	.readoutLabel {
		flex: 1;
		min-width: 0;
	}
	.readoutValue {
		font-family: var(--font-mono);
		font-variant-numeric: tabular-nums;
		white-space: nowrap;
	}
	.readoutNote {
		margin: 0;
		color: var(--ink-3);
		font-size: var(--text-11);
	}
	.readoutRow[data-missing] .readoutValue {
		color: var(--ink-3);
		font-family: var(--font-sans);
		font-style: italic;
	}
}

Edge cases

empty · unmeasured · one point · constant · gaps

No rows

No data to display.

Nothing measured

Measurements unavailable.

View data for Nothing measured
Nothing measured
Label Balance
Mon Unavailable
Tue Unavailable
One measurement
Use the left and right arrow keys to read each position.
View data for One measurement
One measurement
Label Balance
Today 42
Constant zero
Use the left and right arrow keys to read each position.
View data for Constant zero
Constant zero
Label Balance
Mon 0
Tue 0
Wed 0
Thu 0
Gaps and a lone point
Use the left and right arrow keys to read each position.
View data for Gaps and a lone point
Gaps and a lone point
Label Balance
D1 4
D2 6
D3 Unavailable
D4 5
D5 Unavailable
D6 8
D7 9
D8 7
Nothing to share
  • Used 0
  • Free Unavailable