Skip to examples
Bento / Kitchen sink
Bento / compositions

Business charts

The shapes product dashboards reach for: progress against a limit, shares, funnels, breakdowns per row, and actuals against a target. Same rules and motion as every chart.

ProgressRing

ring · gauge · over quota · unmeasured

A meter, not a picture. Each ring is announced with its name, value, and range. The arc stops at full; the number does not — 130% of a quota says 130%.

Colour is the caller's call. The ring never guesses thresholds; the storage example picks warn and crit itself.

Source src/lib/components/charts/progress-ring/doc.ts · src/lib/components/charts/progress-ring/ProgressRing.svelte · src/lib/components/charts/progress-ring/progress-ring.module.css · src/lib/components/charts/_kernel/ring.ts

src/lib/components/charts/progress-ring/doc.ts

/**
 * ProgressRing — one value against a total, as a ring or a gauge.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label         REQUIRED, what is measured
 *     value         number | null
 *     max?          default 100
 *     kind?         "ring" (full turn from 12 o'clock) | "gauge" (half turn,
 *                   left to right over the top)
 *     color?        a chart slot, or accent | warn | crit
 *     formatValue?  the centre number; default the share of max, in percent
 *     caption?      under the number
 *     size?, animation?  (default entrance: trace)
 *
 * # Behaviour
 *
 * R1  A meter: announced with its name, value, and range.
 * R2  The arc is clamped to full; the printed number never is — 130% of a
 *     quota says 130%.
 * R3  A null value draws the empty track and a dash, and is announced as
 *     not measured; it is never drawn as zero.
 * R4  Colour is the caller's decision: the ring does not guess thresholds.
 */
export {};

src/lib/components/charts/progress-ring/ProgressRing.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 { ringGeometry, ringShare, type RingKind } from '../_kernel/ring';
	import { isValue } from '../_kernel/scale';
	import chart from '../_shared/chart.module.css';
	import styles from './progress-ring.module.css';

	/* One value against a total. It is a meter: announced with its value, and
	   its printed number is never clamped even when the arc is full. */
	let {
		label,
		value,
		max = 100,
		kind = 'ring',
		color = 1,
		formatValue = (v: number, m: number) => `${Math.round((v / m) * 100)}%`,
		caption,
		size,
		animation,
		class: className = ''
	}: {
		/** What is measured: the meter's name. */
		label: string;
		/** Null is not measured, drawn as an empty track and a dash. */
		value: number | null;
		max?: number;
		/** A ring, or a half-ring gauge. */
		kind?: RingKind;
		/** A chart slot, or a status colour for a threshold the caller decided. */
		color?: ChartColor | 'accent' | 'warn' | 'crit';
		/** The number in the middle; default the share of max as a percentage. */
		formatValue?: (value: number, max: number) => string;
		/** Under the number: "of seats", "used". */
		caption?: string;
		/** The ring's width as a CSS length. */
		size?: string;
		/** Default: the arc traces round, when scrolled into view. */
		animation?: AnimationProp;
		class?: string;
	} = $props();

	const motion = chartMotion(() => animation, { enter: 'trace', axis: 'x' });
	const measured = $derived(isValue(value));
	const shown = new Tweened(
		() => ({ share: ringShare(value, max) }),
		() => motion.update
	);
	const ring = $derived(ringGeometry(shown.current.share, kind));
	const text = $derived(measured ? formatValue(value as number, max) : '—');
	const tone = $derived(
		color === 'accent' || color === 'warn' || color === 'crit' ? `var(--${color})` : colorVar(color)
	);
</script>

<div
	{...motion.pending}
	{@attach motion.attach}
	role="meter"
	aria-label={label}
	aria-valuemin={0}
	aria-valuemax={max}
	aria-valuenow={measured ? (value as number) : undefined}
	aria-valuetext={measured ? `${text}${caption ? ` ${caption}` : ''}` : 'Not measured'}
	data-kind={kind}
	class={cn(chart.root, styles.root, className)}
	style:--series={tone}
	style:--ring-size={size}
>
	<svg
		class={styles.svg}
		viewBox={ring.viewBox}
		data-rotate={ring.rotate || undefined}
		aria-hidden="true"
	>
		<path class={styles.track} d={ring.track} stroke-width={ring.width} />
		{#if ring.value}
			<path
				data-mark
				class={styles.value}
				d={ring.value}
				pathLength={1}
				stroke-width={ring.width}
			/>
		{/if}
	</svg>
	<div class={styles.centre} aria-hidden="true">
		<span class={styles.number}>{text}</span>
		{#if caption}<span class={styles.caption}>{caption}</span>{/if}
	</div>
</div>

src/lib/components/charts/progress-ring/progress-ring.module.css

@layer primitive {
	.root {
		position: relative;
		display: inline-grid;
		width: var(--ring-size, 7rem);
		justify-items: center;
	}
	.svg {
		display: block;
		width: 100%;
		height: auto;
		overflow: visible;
	}
	.svg[data-rotate] {
		transform: rotate(-90deg);
	}
	.track {
		fill: none;
		stroke: var(--chart-absent, var(--surface-hover));
		stroke-linecap: round;
	}
	.value {
		fill: none;
		stroke: var(--series);
		stroke-dasharray: 1 1;
		stroke-linecap: round;
	}
	.centre {
		position: absolute;
		inset: 0;
		display: grid;
		align-content: center;
		justify-items: center;
		gap: 2px;
		text-align: center;
	}
	.root[data-kind='gauge'] .centre {
		top: auto;
		bottom: 0;
	}
	.number {
		color: var(--ink);
		font-size: calc(var(--ring-size, 7rem) * 0.2);
		font-variant-numeric: tabular-nums;
		font-weight: var(--weight-strong);
		line-height: 1;
	}
	.caption {
		max-width: 80%;
		color: var(--ink-3);
		font-size: var(--text-11);
		line-height: 1.2;
	}
}

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

import { arcPath } from './donut';
import { isValue } from './scale';

/* ProgressRing: one value against a total, as a ring or a half-ring gauge. */

export type RingKind = 'ring' | 'gauge';

/** The share of the total, clamped to 0–1 for drawing; the printed value is
 *  never clamped. */
export function ringShare(value: number | null, max: number) {
	if (!isValue(value) || !(max > 0)) return 0;
	return Math.max(0, Math.min(1, value / max));
}

/** Geometry for a ring (a full turn from 12 o'clock) or a gauge (a half
 *  turn over the top, 9 to 3 o'clock), in a 100-wide viewBox. */
export function ringGeometry(share: number, kind: RingKind, thickness = 0.16) {
	const width = 100 * thickness * 0.5;
	if (kind === 'gauge') {
		// Unrotated: 0.5 turns is 9 o'clock, going clockwise over the top.
		const r = 50 - width / 2 - 1;
		const cy = 52;
		return {
			viewBox: `0 0 100 ${cy + width / 2 + 1}`,
			width,
			track: arcPath(0.5, 0.99999, r, 50, cy),
			value: share > 0 ? arcPath(0.5, 0.5 + share / 2, r, 50, cy) : '',
			rotate: false
		};
	}
	const r = 50 - width / 2 - 1;
	return {
		viewBox: '0 0 100 100',
		width,
		track: arcPath(0, 0.99999, r),
		value: share > 0 ? arcPath(0, share, r) : '',
		// A full ring starts at 12: the SVG is turned a quarter.
		rotate: true
	};
}

PieChart

a donut with thickness 1

Accounts by plan

A pie is a donut filled to the centre.

  • Free 1,840 · 63.9%
  • Starter 612 · 21.2%
  • Team 388 · 13.5%
  • Enterprise 41 · 1.4%

For two to four parts. Angles are harder to compare than lengths; past four, prefer a bar chart.

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

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/PieChart.svelte

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

	/* A donut filled to the centre. For two to four parts; past that, prefer a
	   bar chart — angles are harder to compare than lengths. */
	let props: Omit<ComponentProps<typeof DonutChart>, 'thickness'> = $props();
</script>

<DonutChart {...props} thickness={1} />

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>

FunnelChart

conversion from previous and first

Signup funnel

Each stage with its conversion from the stage before and from the first.

  1. Visited pricing 12,400
  2. Signed up 3,100 25% of previous · 25% of first
  3. Activated 1,480 47.7% of previous · 11.9% of first
  4. Subscribed 410 27.7% of previous · 3.3% of first
  5. Retained 90 days 290 70.7% of previous · 2.3% of first
Source src/lib/components/charts/funnel-chart/doc.ts · src/lib/components/charts/funnel-chart/FunnelChart.svelte · src/lib/components/charts/funnel-chart/funnel-chart.module.css · src/lib/components/charts/_kernel/funnel.ts

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

/**
 * FunnelChart — stages that each keep part of the one before.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label         REQUIRED
 *     steps         { key, label, value: number | null }[], in order
 *     color?, formatValue?, animation?  (default entrance: grow from centre)
 *
 * # Behaviour
 *
 * R1  An ordered list; every stage prints its value, and every stage after
 *     the first prints its conversion from the stage before and from the
 *     first.
 * R2  Bars are centred and measured against the largest stage.
 * R3  An unmeasured stage reads "Unavailable", draws no bar, and breaks the
 *     conversions that would need it: a gap never invents a rate.
 * R4  Stages are not reordered: the order is the process.
 */
export {};

src/lib/components/charts/funnel-chart/FunnelChart.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, formatPercent } from '../_kernel/format';
	import { funnelMax, funnelRows, type FunnelStep } from '../_kernel/funnel';
	import chart from '../_shared/chart.module.css';
	import styles from './funnel-chart.module.css';

	/* Stages that each keep part of the one before. Every stage prints its
	   value and its conversion from the stage before and from the first; an
	   unmeasured stage breaks the conversions next to it rather than
	   inventing them. */
	let {
		label,
		steps,
		color = 1,
		formatValue = formatExact,
		animation,
		class: className = ''
	}: {
		/** Names the chart. */
		label: string;
		/** In order: each stage is part of the one before. */
		steps: readonly FunnelStep[];
		color?: ChartColor;
		formatValue?: (value: number) => string;
		/** Default: stages grow out from the centre, when scrolled into view. */
		animation?: AnimationProp;
		class?: string;
	} = $props();

	const motion = chartMotion(() => animation, { enter: 'grow', axis: 'x' });
	const rows = $derived(funnelRows(steps));
	const shown = new Tweened(
		() => {
			const target: Record<string, number> = { __max: funnelMax(steps) };
			for (const row of rows) if (row.measured) target[row.key] = row.value as number;
			return target;
		},
		() => motion.update
	);
</script>

<div
	{...motion.pending}
	{@attach motion.attach}
	class={cn(chart.root, className)}
	style:--series={colorVar(color)}
>
	{#if steps.length === 0}
		<p class={chart.empty}>No data to display.</p>
	{:else}
		<ol aria-label={label} class={styles.list}>
			{#each rows as row, index (row.key)}
				{@const width =
					row.measured && shown.current.__max > 0
						? Math.min(1, (shown.current[row.key] ?? 0) / shown.current.__max)
						: 0}
				<li class={styles.row}>
					<span class={styles.label}>{row.label}</span>
					<span class={styles.track} aria-hidden="true">
						{#if row.measured}
							<span data-mark class={styles.fill} style:width="{width * 100}%"></span>
						{/if}
					</span>
					<span class={styles.figures}>
						<span class={styles.value}
							>{row.measured ? formatValue(row.value as number) : 'Unavailable'}</span
						>
						{#if index > 0}
							<span class={styles.rate}
								>{row.ofPrevious !== null
									? `${formatPercent(row.ofPrevious)} of previous`
									: '—'}{row.ofFirst !== null
									? ` · ${formatPercent(row.ofFirst)} of first`
									: ''}</span
							>
						{/if}
					</span>
				</li>
			{/each}
		</ol>
	{/if}
</div>

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

@layer primitive {
	.list {
		display: grid;
		grid-template-columns: minmax(6rem, 1fr) minmax(8rem, 3fr) auto;
		gap: var(--space-3) var(--space-5);
		margin: 0;
		padding: 0;
		list-style: none;
		font-size: var(--text-12);
	}
	.row {
		display: grid;
		grid-column: 1 / -1;
		grid-template-columns: subgrid;
		align-items: center;
	}
	.label {
		color: var(--ink-2);
	}
	/* Centred, so each stage reads as what is left of the one above. */
	.track {
		display: flex;
		height: 2rem;
		justify-content: center;
		border-radius: var(--radius-1);
		background: var(--chart-absent, var(--surface-hover));
	}
	.fill {
		height: 100%;
		border-radius: inherit;
		background: var(--series);
		transform-origin: 50% 50%;
	}
	.figures {
		display: grid;
		justify-items: end;
		font-variant-numeric: tabular-nums;
	}
	.value {
		color: var(--ink);
	}
	.rate {
		color: var(--ink-3);
		font-size: var(--text-11);
	}
}

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

import { isValue } from './scale';

/* FunnelChart: stages that each keep part of the one before. */

export type FunnelStep = { key: string; label: string; value: number | null };

export type FunnelRow = FunnelStep & {
	measured: boolean;
	/** Share of the first stage, 0–1; null when either is unmeasured. */
	ofFirst: number | null;
	/** Share of the stage before, 0–1; null for the first stage or when
	 *  either is unmeasured — a gap never invents a conversion. */
	ofPrevious: number | null;
};

const measured = (v: number | null): v is number => isValue(v) && v >= 0;

export function funnelRows(steps: readonly FunnelStep[]): FunnelRow[] {
	const first = steps[0]?.value ?? null;
	return steps.map((step, index) => {
		const previous = index > 0 ? steps[index - 1].value : null;
		const ok = measured(step.value);
		return {
			...step,
			measured: ok,
			ofFirst: ok && measured(first) && first > 0 ? (step.value as number) / first : null,
			ofPrevious:
				index > 0 && ok && measured(previous) && previous > 0
					? (step.value as number) / previous
					: null
		};
	});
}

/** The value a full-width bar stands for: the largest stage. */
export function funnelMax(steps: readonly FunnelStep[]) {
	return Math.max(0, ...steps.map((s) => s.value).filter(measured));
}

StackedBarChart

stacked · percent · diverging

Hours by team

Stacked: totals compare across rows.

  • New work
  • Fixes
  • Review
  • Platform 200 h
  • Growth 128 h
  • Support 116 h
  • Design 96 h
View data for Hours by team and kind of work
Hours by team and kind of work
Label New workFixesReview
Platform 124 h46 h30 h
Growth 88 h22 h18 h
Support 12 h96 h8 h
Design 64 h6 h26 h
Where the time goes

Percent: each team's own split.

  • New work
  • Fixes
  • Review
  • Platform 200 h
  • Growth 128 h
  • Support 116 h
  • Design 96 h
View data for Share of hours by kind of work, per team
Share of hours by kind of work, per team
Label New workFixesReview
Platform 124 h46 h30 h
Growth 88 h22 h18 h
Support 12 h96 h8 h
Design 64 h6 h26 h
Customer survey

Diverging: disagreement left of centre, agreement right.

  • Strongly disagree
  • Disagree
  • Agree
  • Strongly agree
  • Easy to set up
  • Fast enough
  • Good value
  • Would recommend
View data for Survey answers per question, % of responses
Survey answers per question, % of responses
Label Strongly disagreeDisagreeAgreeStrongly agree
Easy to set up 4%9%48%39%
Fast enough 8%21%44%27%
Good value 12%26%40%22%
Would recommend 5%11%45%39%

Percent hides the base, so it prints it. Every row fills the track, and its total is still shown beside it.

Diverging shares one scale. Both sides are measured against the larger side of the largest row, so left and right compare directly.

Source src/lib/components/charts/stacked-bar-chart/doc.ts · src/lib/components/charts/stacked-bar-chart/StackedBarChart.svelte · src/lib/components/charts/stacked-bar-chart/stacked-bar-chart.module.css · src/lib/components/charts/_kernel/hbars.ts

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

/**
 * StackedBarChart — horizontal bars of several parts per row.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label         REQUIRED
 *     data          { label, values: { [series key]: number | null } }[]
 *     series        { key, label, color?, side?: "negative" | "positive" }[]
 *     layout?       "stacked" (default) | "percent" | "diverging"
 *     formatValue?, animation?  (default entrance: grow)
 *
 * # Behaviour
 *
 * R1  stacked: parts laid end to end; every row shares one scale, so row
 *     totals compare. The total is printed.
 * R2  percent: every row fills the track; parts are shares of their row.
 *     The row's total is still printed, so the base is never hidden.
 * R3  diverging: negative-side series run left from a centre line and
 *     positive-side series run right, on one scale for both sides. List the
 *     negative series first, the one nearest neutral last.
 * R4  Only finite, non-negative values are drawn; the table lists every
 *     value, "Unavailable" for the rest.
 * R5  A legend names the series; the data table holds every exact value.
 */
export {};

src/lib/components/charts/stacked-bar-chart/StackedBarChart.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 { formatExact } from '../_kernel/format';
	import {
		stackedBarRows,
		stackedBarTarget,
		type SidedSeries,
		type StackedBarLayout
	} from '../_kernel/hbars';
	import chart from '../_shared/chart.module.css';
	import SeriesLegend from '../_shared/SeriesLegend.svelte';
	import SeriesTable from '../_shared/SeriesTable.svelte';
	import styles from './stacked-bar-chart.module.css';

	/* Horizontal bars of several parts per row: a breakdown per team, a
	   survey's answers. Values are in the table; totals are printed. */
	let {
		label,
		data,
		series,
		layout = 'stacked',
		formatValue = formatExact,
		animation,
		class: className = ''
	}: {
		/** Names the chart. */
		label: string;
		data: readonly CartesianDatum[];
		/** For "diverging", give each series a side; negative ones are listed
		 *  first, the one nearest neutral last. */
		series: readonly SidedSeries[];
		/** Parts of each row's total; each row as 100%; or two sides of a centre. */
		layout?: StackedBarLayout;
		formatValue?: (value: number) => string;
		/** Default: segments grow out, when scrolled into view. */
		animation?: AnimationProp;
		class?: string;
	} = $props();

	const motion = chartMotion(() => animation, { enter: 'grow', axis: 'x' });
	const shown = new Tweened(
		() => stackedBarTarget(data, series, layout),
		() => motion.update
	);
	const rows = $derived(stackedBarRows(shown.current, data, series, layout));
</script>

{#if !data.length || !series.length}
	<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)}>
		<SeriesLegend {series} lines={false} />
		<ul aria-label={label} class={styles.list}>
			{#each rows as row, index (`${index}-${row.label}`)}
				<li class={styles.row}>
					<span class={styles.label}>{row.label}</span>
					<span class={styles.track} aria-hidden="true">
						{#each row.segments as segment (segment.key)}
							<span
								data-mark
								data-side={segment.side}
								class={styles.segment}
								style:--series={segment.color}
								style:left="{segment.left}%"
								style:width="{segment.width}%"
							></span>
						{/each}
						{#if layout === 'diverging'}<span class={styles.centre}></span>{/if}
					</span>
					<span class={styles.total}>{layout === 'diverging' ? '' : formatValue(row.total)}</span>
				</li>
			{/each}
		</ul>
		<SeriesTable {label} {data} {series} {formatValue} />
	</div>
{/if}

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

@layer primitive {
	.list {
		display: grid;
		grid-template-columns: minmax(5rem, 1fr) minmax(8rem, 4fr) auto;
		gap: var(--space-4) var(--space-5);
		margin: 0;
		padding: 0;
		list-style: none;
		font-size: var(--text-12);
	}
	.row {
		display: grid;
		grid-column: 1 / -1;
		grid-template-columns: subgrid;
		align-items: center;
	}
	.label {
		min-width: 0;
		color: var(--ink-2);
		overflow-wrap: anywhere;
	}
	.track {
		position: relative;
		height: 0.875rem;
		overflow: hidden;
		border-radius: var(--radius-1);
		background: var(--chart-absent, var(--surface-hover));
	}
	.segment {
		position: absolute;
		top: 0;
		bottom: 0;
		background: var(--series);
		transform-origin: 0 50%;
	}
	/* A hairline of the surface between segments, so neighbours read apart. */
	.segment + .segment {
		box-shadow: inset 1px 0 0 var(--surface-panel);
	}
	.segment[data-side='negative'] {
		transform-origin: 100% 50%;
	}
	.centre {
		position: absolute;
		top: -2px;
		bottom: -2px;
		left: 50%;
		width: 1px;
		background: var(--chart-axis, var(--line-strong));
	}
	.total {
		min-width: 3ch;
		color: var(--ink);
		font-variant-numeric: tabular-nums;
		text-align: end;
	}
}

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

import type { CartesianDatum } from './cartesian';
import { seriesColor, type ChartSeries } from './encode';
import { isValue } from './scale';

/* StackedBarChart: horizontal bars of several series per row. */

export type StackedBarLayout = 'stacked' | 'percent' | 'diverging';

/** A series, and (diverging only) which side of the centre it sits on. */
export type SidedSeries = ChartSeries & { side?: 'negative' | 'positive' };

const cell = (key: string, index: number) => `${key}|${index}`;
const measured = (v: number | null | undefined): v is number => isValue(v) && v >= 0;

/** The numbers to tween: every drawable value, and the scale's end (__max):
 *  the largest row total; 100 for percent; for diverging, the larger side of
 *  the largest row, so both halves share one scale. */
export function stackedBarTarget(
	data: readonly CartesianDatum[],
	series: readonly SidedSeries[],
	layout: StackedBarLayout
) {
	const target: Record<string, number> = {};
	let max = 0;
	data.forEach((row, index) => {
		let total = 0;
		let negative = 0;
		let positive = 0;
		for (const entry of series) {
			const v = row.values[entry.key];
			if (!measured(v)) continue;
			total += v;
			if (entry.side === 'negative') negative += v;
			else positive += v;
		}
		for (const entry of series) {
			const v = row.values[entry.key];
			if (!measured(v)) continue;
			target[cell(entry.key, index)] =
				layout === 'percent' ? (total > 0 ? (v / total) * 100 : 0) : v;
		}
		max = Math.max(max, layout === 'diverging' ? Math.max(negative, positive) : total);
	});
	target.__max = layout === 'percent' ? 100 : max;
	return target;
}

export type Segment = {
	key: string;
	color: string;
	/** Left edge and width, in percent of the track. */
	left: number;
	width: number;
	side: 'negative' | 'positive';
};

/** Each row's segments. Stacked and percent run left to right; diverging
 *  runs outward from the centre, the last negative series nearest it. */
export function stackedBarRows(
	shown: Readonly<Record<string, number>>,
	data: readonly CartesianDatum[],
	series: readonly SidedSeries[],
	layout: StackedBarLayout
) {
	const max = shown.__max || 1;
	const colors = new Map(series.map((entry, i) => [entry.key, seriesColor(entry, i)]));
	return data.map((row, index) => {
		const segments: Segment[] = [];
		const at = (key: string) => shown[cell(key, index)];
		if (layout === 'diverging') {
			const scale = 50 / max;
			let left = 50;
			for (const entry of series.filter((e) => e.side === 'negative').toReversed()) {
				const v = at(entry.key);
				if (v === undefined) continue;
				left -= v * scale;
				segments.push({
					key: entry.key,
					color: colors.get(entry.key)!,
					left,
					width: v * scale,
					side: 'negative'
				});
			}
			let right = 50;
			for (const entry of series.filter((e) => e.side !== 'negative')) {
				const v = at(entry.key);
				if (v === undefined) continue;
				segments.push({
					key: entry.key,
					color: colors.get(entry.key)!,
					left: right,
					width: v * scale,
					side: 'positive'
				});
				right += v * scale;
			}
		} else {
			let left = 0;
			for (const entry of series) {
				const v = at(entry.key);
				if (v === undefined) continue;
				const width = (v / max) * 100;
				segments.push({
					key: entry.key,
					color: colors.get(entry.key)!,
					left,
					width,
					side: 'positive'
				});
				left += width;
			}
		}
		let total = 0;
		for (const entry of series) {
			const v = row.values[entry.key];
			if (measured(v)) total += v;
		}
		return { label: row.label, segments, total };
	});
}
Revenue against target

Columns and a line on one axis.

  • Revenue
  • Target
Use the left and right arrow keys to read each position.
View data for Monthly revenue against target, thousands
Monthly revenue against target, thousands
Label RevenueTarget
Apr $63.6k$60k
May $65.2k$65k
Jun $74k$70k
Jul $75.5k$75k
Aug $79.6k$80k
Sep $87k$85k
Tickets by cause

Pareto: shares and the running total, both in percent.

  • Share of total
  • Running total
Use the left and right arrow keys to read each position.
View data for Support tickets by cause, this quarter
Support tickets by cause, this quarter
ItemTicketsShareRunning total
Login and SSO 412 38.5% 38.5%
Billing questions 268 25% 63.5%
Failed deploys 190 17.7% 81.2%
Usage limits 96 9% 90.2%
API errors 61 5.7% 95.9%
Other 44 4.1% 100%

No second axis. Columns and lines share one. Measures in different units belong in two charts — which is why the Pareto draws both its columns and its line in percent, with the counts in the table.

Source src/lib/components/charts/combo-chart/doc.ts · src/lib/components/charts/combo-chart/ComboChart.svelte · src/lib/components/charts/combo-chart/ParetoChart.svelte · src/lib/components/charts/_kernel/combo.ts

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

/**
 * ComboChart / ParetoChart — columns and lines on one axis.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     ComboChart    label, data, bars (series), lines (series), layout?
 *                   ("grouped" | "stacked"), formatValue?, formatTick?,
 *                   height?, table?, animation?, inspect?
 *     ParetoChart   label, data: { id, label, value }[], valueTitle?,
 *                   formatValue?, height?, animation?, inspect?
 *
 * # Behaviour
 *
 * R1  One y axis, shared by columns and lines. Measures in different units
 *     are not combined; they belong in two charts.
 * R2  Line points sit at the centre of their columns.
 * R3  Series keys are distinct across bars and lines.
 * R4  Pareto ranks items largest first, draws each as its share of the
 *     total, and the running total as a line — both in percent, so one axis
 *     is honest. The table has the counts, shares, and running totals.
 * R5  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/combo-chart/ComboChart.svelte

<script lang="ts">
	import type { Snippet } from 'svelte';
	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 type { CartesianDatum } from '../_kernel/cartesian';
	import { columnGeometry } from '../_kernel/column';
	import { comboTarget } from '../_kernel/combo';
	import { seriesColor, type ChartSeries } from '../_kernel/encode';
	import { formatExact, formatTick } from '../_kernel/format';
	import { lineMarkers, readout } from '../_kernel/inspect';
	import { linesGeometry } from '../_kernel/lines';
	import { band, px } from '../_kernel/scale';
	import CartesianPlot from '../_shared/CartesianPlot.svelte';
	import ChartInspector from '../_shared/ChartInspector.svelte';
	import chart from '../_shared/chart.module.css';
	import SeriesTable from '../_shared/SeriesTable.svelte';

	/* Columns with lines over them, on ONE axis: actual against target,
	   volume against its moving average. Measures in different units belong
	   in two charts. */
	let {
		label,
		data,
		bars,
		lines,
		layout = 'grouped',
		formatValue = formatExact,
		formatTick: tickFormat = formatTick,
		height,
		table,
		angled = false,
		animation,
		inspect = true,
		class: className = ''
	}: {
		/** Names the chart. */
		label: string;
		data: readonly CartesianDatum[];
		/** Drawn as columns. */
		bars: readonly ChartSeries[];
		/** Drawn as lines over the columns, on the same axis. */
		lines: readonly ChartSeries[];
		layout?: 'grouped' | 'stacked';
		formatValue?: (value: number) => string;
		formatTick?: (value: number) => string;
		height?: string;
		/** Replace the data table (a chart that knows more than its series). */
		table?: Snippet;
		/** Show every x label, angled: for categories that must all be read. */
		angled?: boolean;
		/** Default: columns grow, 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 model = $derived(comboTarget(data, bars, lines, layout));
	const shown = new Tweened(
		() => model.target,
		() => motion.update
	);
	const slots = $derived(band(data.length, 0.3));
	const columns = $derived(
		columnGeometry(shown.current, data, bars, layout === 'stacked', model.step, tickFormat)
	);
	const trend = $derived(
		linesGeometry(shown.current, data, lines, {
			area: false,
			stacked: false,
			points: true,
			step: model.step,
			positions: (index) => slots.centre(index)
		})
	);
	const legend = $derived([
		...bars.map((entry, index) => ({
			key: entry.key,
			label: entry.label,
			color: seriesColor(entry, index)
		})),
		...lines.map((entry, index) => ({
			key: entry.key,
			label: entry.label,
			color: seriesColor(entry, bars.length + index),
			line: entry.line ?? ('solid' as const)
		}))
	]);
	const all = $derived([...bars, ...lines]);
	const colors = $derived(all.map((entry, index) => seriesColor(entry, index)));
	const trendSeries = $derived(
		trend.series.map((entry, index) => ({ ...entry, color: colors[bars.length + index] }))
	);
	const xLabels = $derived(
		angled
			? data.map((row, index) => ({
					key: `${index}-${row.label}`,
					at: px(slots.centre(index)),
					label: row.label,
					align: 'middle' as const,
					tier: 2 as const
				}))
			: columns.xLabels
	);
</script>

{#snippet inspector()}
	<ChartInspector
		{label}
		positions={columns.positions}
		band={columns.bandWidth}
		readout={(index) => readout(data[index], all, { format: formatValue, colors })}
		markers={(index) => lineMarkers(trendSeries, index)}
	/>
{/snippet}

{#if !data.length || (!bars.length && !lines.length)}
	<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)}>
		<ChartLegend items={legend} />
		{#if model.measured}
			<CartesianPlot
				{label}
				ticks={columns.ticks}
				{height}
				zeroAt={columns.zeroAt}
				{xLabels}
				{angled}
				overlay={inspect ? inspector : undefined}
			>
				{#each columns.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}
				{#each trend.series as entry, index (entry.key)}
					<g style:--series={seriesColor(lines[index], bars.length + index)}>
						<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}
		{#if table}
			{@render table()}
		{:else}
			<SeriesTable {label} {data} series={all} {formatValue} />
		{/if}
	</div>
{/if}

src/lib/components/charts/combo-chart/ParetoChart.svelte

<script lang="ts">
	import { TBody, Td, Th, THead, Tr } from '$lib/components/display/table';
	import type { AnimationProp } from '$lib/motion';
	import { formatExact, formatPercent, formatPercentTick } from '../_kernel/format';
	import { paretoRows, rank, type RankedDatum } from '../_kernel/ranked';
	import ChartData from '../_shared/ChartData.svelte';
	import ComboChart from './ComboChart.svelte';

	/* Causes ranked largest first, each column its share of the total and the
	   line the running total — both in percent, so one axis is honest. The
	   counts are in the table. */
	let {
		label,
		data,
		valueTitle = 'Count',
		formatValue = formatExact,
		height,
		animation,
		inspect,
		class: className = ''
	}: {
		/** Names the chart. */
		label: string;
		data: readonly RankedDatum[];
		/** What the values count, for the table. */
		valueTitle?: string;
		formatValue?: (value: number) => string;
		height?: string;
		animation?: AnimationProp;
		inspect?: boolean;
		class?: string;
	} = $props();

	const ranked = $derived(rank(data));
	const rows = $derived(paretoRows(ranked.measured));
</script>

<ComboChart
	{label}
	class={className}
	{height}
	{animation}
	{inspect}
	formatValue={(value) => formatPercent(value / 100)}
	data={rows.map((row) => ({
		label: row.item.label,
		values: { share: row.share, cumulative: row.cumulative }
	}))}
	bars={[{ key: 'share', label: 'Share of total' }]}
	lines={[{ key: 'cumulative', label: 'Running total' }]}
	formatTick={formatPercentTick}
	angled
>
	{#snippet table()}
		<ChartData {label}>
			<THead>
				<Tr>
					<Th>Item</Th><Th numeric>{valueTitle}</Th><Th numeric>Share</Th><Th numeric
						>Running total</Th
					>
				</Tr>
			</THead>
			<TBody>
				{#each rows as row (row.item.id)}
					<Tr>
						<Th scope="row">{row.item.label}</Th>
						<Td numeric>{formatValue(row.item.value)}</Td>
						<Td numeric>{formatPercent(row.share / 100)}</Td>
						<Td numeric>{formatPercent(row.cumulative / 100)}</Td>
					</Tr>
				{/each}
				{#each ranked.unmeasured as item (item.id)}
					<Tr>
						<Th scope="row">{item.label}</Th>
						<Td numeric>Unavailable</Td>
						<Td numeric>—</Td>
						<Td numeric>—</Td>
					</Tr>
				{/each}
			</TBody>
		</ChartData>
	{/snippet}
</ComboChart>

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

import type { CartesianDatum } from './cartesian';
import { columnTarget, type ColumnLayout } from './column';
import type { ChartSeries } from './encode';
import { linesTarget } from './lines';
import { niceDomain } from './scale';

/* ComboChart: columns and lines on ONE axis. Series keys must be distinct
   across the two, since both tween in one record. */

export function comboTarget(
	data: readonly CartesianDatum[],
	bars: readonly ChartSeries[],
	lines: readonly ChartSeries[],
	layout: Exclude<ColumnLayout, 'percent'>
) {
	const columns = columnTarget(data, bars, layout);
	const trend = linesTarget(data, lines, { mode: 'none', zero: true });
	const { domain, step } = niceDomain([
		columns.target.__lo,
		columns.target.__hi,
		trend.target.__lo,
		trend.target.__hi
	]);
	return {
		target: {
			...columns.target,
			...trend.target,
			__lo: domain[0],
			__hi: domain[1]
		},
		step,
		measured: columns.measured || trend.measured
	};
}

RadarChart

one scale · a broken outline

How we compare

Six qualities, one 0–10 scale.

  • Bento
  • Alternative
View data for Product qualities, Bento against an alternative, 0 to 10
Product qualities, Bento against an alternative, 0 to 10
Attribute BentoAlternative
Speed 8.46.1
Reliability 9.17.4
Support 7.28.3
Integrations 6.58.8
Value 85.9
Ease of use 8.86.6
One quality unmeasured

The outline breaks; nothing is guessed.

View data for Product qualities with support unmeasured
Product qualities with support unmeasured
Attribute Bento
Speed 8.4
Reliability 9.1
Support Unavailable
Integrations 6.5
Value 8
Ease of use 8.8

One scale for every spoke. A radar only compares when every axis runs from the same zero to the same maximum.

Read the shape, not the area. The area depends on the order of the axes, so two identical profiles can look different sizes if their axes are ordered differently.

Source src/lib/components/charts/radar-chart/doc.ts · src/lib/components/charts/radar-chart/RadarChart.svelte · src/lib/components/charts/radar-chart/radar-chart.module.css · src/lib/components/charts/_kernel/radar.ts

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

/**
 * RadarChart — profiles across a handful of attributes.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label         REQUIRED
 *     axes          { key, label, values: { [series key]: number | null } }[]
 *                   — one per spoke, 3–8
 *     series        one to three { key, label, color?, line? }
 *     max?          the value at the rim; default a round number above the
 *                   largest value
 *     formatValue?, formatTick?, animation?  (default: grow from the centre)
 *
 * # Behaviour
 *
 * R1  One scale for every axis, from zero at the centre to max at the rim.
 *     A radar only compares when the scale is common.
 * R2  An unmeasured axis breaks a series' outline: the measured neighbours
 *     are joined, the shape is not filled, and no vertex is guessed.
 * R3  Fewer than three axes draws nothing and says so.
 * R4  Values above max sit on the rim; the table has them exactly.
 * R5  Read the shape, not the area: the area depends on the order of axes.
 */
export {};

src/lib/components/charts/radar-chart/RadarChart.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 { ChartSeries } from '../_kernel/encode';
	import { formatExact, formatTick } from '../_kernel/format';
	import { radarGeometry, radarTarget, type RadarAxis } from '../_kernel/radar';
	import chart from '../_shared/chart.module.css';
	import SeriesLegend from '../_shared/SeriesLegend.svelte';
	import SeriesTable from '../_shared/SeriesTable.svelte';
	import styles from './radar-chart.module.css';

	/* Profiles across a handful of attributes on one common scale. Read the
	   shape, not the area — the area depends on the order of the axes. */
	let {
		label,
		axes,
		series,
		max,
		formatValue = formatExact,
		formatTick: tickFormat = formatTick,
		animation,
		class: className = ''
	}: {
		/** Names the chart. */
		label: string;
		/** One per spoke, 3–8, each with every series' value. */
		axes: readonly RadarAxis[];
		/** One to three series; more overlap into noise. */
		series: readonly ChartSeries[];
		/** The value at the rim; every axis shares it. */
		max?: number;
		formatValue?: (value: number) => string;
		formatTick?: (value: number) => string;
		/** Default: shapes grow out from the centre, when scrolled into view. */
		animation?: AnimationProp;
		class?: string;
	} = $props();

	const motion = chartMotion(() => animation, { enter: 'pop', axis: 'y' });
	const model = $derived(radarTarget(axes, series, max));
	const shown = new Tweened(
		() => model.target,
		() => motion.update
	);
	const geometry = $derived(
		axes.length >= 3 ? radarGeometry(shown.current, axes, series, model.step, tickFormat) : null
	);
</script>

{#if !geometry || !series.length}
	<div class={cn(chart.root, className)}>
		<p class={chart.empty}>
			{axes.length && axes.length < 3
				? 'A radar needs at least three axes.'
				: 'No data to display.'}
		</p>
	</div>
{:else}
	<div {...motion.pending} {@attach motion.attach} class={cn(chart.root, className)}>
		{#if series.length > 1}<SeriesLegend {series} lines />{/if}
		{#if model.measured}
			<div class={styles.frame}>
				<div class={styles.plot}>
					<svg
						class={styles.svg}
						viewBox="0 0 100 100"
						role="img"
						aria-label="{label}. Exact values are in the data table."
					>
						{#each geometry.rings as ring (ring.value)}
							<path class={styles.ring} d={ring.d} />
						{/each}
						{#each geometry.spokes as spoke (spoke.key)}
							<line class={styles.spoke} x1={50} y1={50} x2={spoke.x2} y2={spoke.y2} />
						{/each}
						{#each geometry.polygons as polygon (polygon.key)}
							<g data-mark style:--series={polygon.color}>
								<path
									class={styles.area}
									data-open={!polygon.complete || undefined}
									data-line={polygon.line}
									d={polygon.path}
								/>
								{#each polygon.dots as dot (dot.key)}
									<path class={styles.dot} d={dot.d} />
								{/each}
							</g>
						{/each}
					</svg>
					<div class={styles.labels} aria-hidden="true">
						{#each geometry.spokes as spoke (spoke.key)}
							<span data-align={spoke.align} style:left="{spoke.lx}%" style:top="{spoke.ly}%"
								>{spoke.label}</span
							>
						{/each}
						{#each geometry.rings as ring (ring.value)}
							<span data-ring style:left="{ring.labelX}%" style:top="{ring.labelY}%"
								>{ring.label}</span
							>
						{/each}
					</div>
				</div>
			</div>
		{:else}
			<p class={chart.empty}>Measurements unavailable.</p>
		{/if}
		<SeriesTable {label} data={axes} {series} {formatValue} labelHeading="Attribute" />
	</div>
{/if}

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

@layer primitive {
	/* Room around the plot for the axis labels. */
	.frame {
		width: min(100%, 30rem);
		margin-inline: auto;
		padding: var(--space-6) 5.5rem;
	}
	.plot {
		position: relative;
		aspect-ratio: 1;
	}
	.svg {
		position: absolute;
		inset: 0;
		width: 100%;
		height: 100%;
		overflow: visible;
	}
	/* Series grow from the chart's centre, not their own. */
	.plot .svg [data-mark] {
		transform-box: view-box;
		transform-origin: 50% 50%;
	}
	.ring,
	.spoke {
		fill: none;
		stroke: var(--chart-grid, var(--line));
		stroke-width: 1;
		vector-effect: non-scaling-stroke;
	}
	.area {
		fill: color-mix(in oklab, var(--series) 18%, transparent);
		stroke: var(--series);
		stroke-linejoin: round;
		stroke-width: 2;
		vector-effect: non-scaling-stroke;
	}
	.area[data-open] {
		fill: none;
	}
	.area[data-line='dashed'] {
		stroke-dasharray: 6 4;
	}
	.area[data-line='dotted'] {
		stroke-dasharray: 0.5 4.5;
	}
	.dot {
		stroke: var(--series);
		stroke-linecap: round;
		stroke-width: 6;
		vector-effect: non-scaling-stroke;
	}
	.labels {
		position: absolute;
		inset: 0;
		color: var(--ink-2);
		font-size: var(--text-12);
		pointer-events: none;
	}
	.labels span {
		position: absolute;
		white-space: nowrap;
		transform: translate(-50%, -50%);
	}
	.labels span[data-align='start'] {
		transform: translate(0, -50%);
	}
	.labels span[data-align='end'] {
		transform: translate(-100%, -50%);
	}
	.labels span[data-ring] {
		color: var(--chart-label, var(--ink-3));
		font-family: var(--font-mono);
		font-size: var(--text-11);
		transform: translate(-50%, -50%);
	}
}

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

import { seriesColor, type ChartSeries } from './encode';
import { isValue, niceDomain, px, ticksBy } from './scale';

/* RadarChart: one spoke per axis, all on ONE scale from zero. */

/** One axis (spoke) and each series' value on it. */
export type RadarAxis = {
	key: string;
	label: string;
	values: Readonly<Record<string, number | null | undefined>>;
};

const cell = (series: string, axis: number) => `${series}|${axis}`;
export const RADAR_R = 40; // in a 100 × 100 viewBox, centred

export function radarTarget(
	axes: readonly RadarAxis[],
	series: readonly ChartSeries[],
	max?: number
) {
	const target: Record<string, number> = {};
	const all: number[] = [];
	axes.forEach((axis, a) =>
		series.forEach((entry) => {
			const v = axis.values[entry.key];
			if (isValue(v) && v >= 0) {
				target[cell(entry.key, a)] = v;
				all.push(v);
			}
		})
	);
	const { domain, step } = niceDomain(max !== undefined ? [max] : all, {
		count: 4
	});
	target.__max = domain[1];
	return { target, step, measured: all.length > 0 };
}

/** Where on the plot a value on axis `a` sits: angle from 12 o'clock. */
function point(a: number, count: number, value: number, max: number) {
	const angle = (a / count) * 2 * Math.PI - Math.PI / 2;
	const r = max > 0 ? (Math.min(value, max) / max) * RADAR_R : 0;
	return [50 + r * Math.cos(angle), 50 + r * Math.sin(angle)] as const;
}

export function radarGeometry(
	shown: Readonly<Record<string, number>>,
	axes: readonly RadarAxis[],
	series: readonly ChartSeries[],
	step: number,
	format: (value: number) => string
) {
	const n = axes.length;
	const max = shown.__max;
	const rings = ticksBy([0, max], step)
		.filter((v) => v > 0)
		.map((value) => ({
			value,
			label: format(value),
			d:
				axes
					.map((_, a) => {
						const [x, y] = point(a, n, value, max);
						return `${a ? 'L' : 'M'}${px(x)},${px(y)}`;
					})
					.join('') + 'Z',
			// Between the first two spokes, where no axis label sits.
			...(() => {
				const angle = (0.5 / n) * 2 * Math.PI - Math.PI / 2;
				const r = (value / max) * RADAR_R;
				return {
					labelX: 50 + r * Math.cos(angle),
					labelY: 50 + r * Math.sin(angle)
				};
			})()
		}));
	const spokes = axes.map((axis, a) => {
		const [x, y] = point(a, n, max, max);
		const angle = (a / n) * 2 * Math.PI - Math.PI / 2;
		return {
			key: axis.key,
			label: axis.label,
			x2: px(x),
			y2: px(y),
			// Labels sit just outside the rim, anchored away from the centre.
			lx: 50 + Math.cos(angle) * (RADAR_R + 5),
			ly: 50 + Math.sin(angle) * (RADAR_R + 5),
			align: Math.abs(Math.cos(angle)) < 0.2 ? 'middle' : Math.cos(angle) > 0 ? 'start' : 'end'
		};
	});
	const polygons = series.map((entry, s) => {
		const points = axes.map((_, a) => {
			const v = shown[cell(entry.key, a)];
			return v === undefined ? null : point(a, n, v, max);
		});
		const complete = points.every(Boolean);
		let path = '';
		if (complete) {
			path = points.map((p, a) => `${a ? 'L' : 'M'}${px(p![0])},${px(p![1])}`).join('') + 'Z';
		} else {
			// An unmeasured axis breaks the outline: no fill, no guessed vertex.
			points.forEach((p, a) => {
				const next = points[(a + 1) % n];
				if (p && next) path += `M${px(p[0])},${px(p[1])}L${px(next[0])},${px(next[1])}`;
			});
		}
		return {
			key: entry.key,
			color: seriesColor(entry, s),
			line: entry.line,
			complete,
			path,
			dots: points.flatMap((p, a) =>
				p ? [{ key: String(a), d: `M${px(p[0])},${px(p[1])}h0` }] : []
			)
		};
	});
	return { rings, spokes, polygons };
}

WaterfallChart

totals · signed changes · a missing change

MRR bridge

Where this month's recurring revenue came from and went.

  • Total
  • Increase
  • Decrease
Use the left and right arrow keys to read each position.
View data for Monthly recurring revenue bridge, thousands
Monthly recurring revenue bridge, thousands
StepChangeRunning total
Start of month — $84.2k
New +$12.4k $96.6k
Expansion +$6.1k $102.7k
Contraction −$2.3k $100.4k
Churn −$5.8k $94.6k
End of month — $94.6k

A missing change is not a zero. Hide the churn reading: the end of month becomes unavailable rather than quietly overstated.

Source src/lib/components/charts/waterfall-chart/doc.ts · src/lib/components/charts/waterfall-chart/WaterfallChart.svelte · src/lib/components/charts/_kernel/waterfall.ts

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

/**
 * WaterfallChart — a level, the changes that move it, and where it lands.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label         REQUIRED
 *     steps         { key, label, value: number | null, total?: boolean }[]
 *     formatValue?, formatTick?, height?, animation?  (default: grow)
 *     inspect?     read steps by pointer or keyboard; default true
 *
 * # Behaviour
 *
 * R1  A total stands on zero; a total left null is the running sum so far.
 * R2  A change floats from the running total to the new one; increases and
 *     decreases differ in colour, and every change prints its sign.
 * R3  Dashed connectors join each bar's end to the next bar's start.
 * R4  An unmeasured change is never skipped as zero: it and every level
 *     after it (until a given total) are unavailable.
 * R5  The axis includes zero. The table lists each change and the running
 *     total after it.
 * R6  Inspection (inspect, default on), as in LineChart: each step reads
 *     its change and the running total it lands on; a total reads its level;
 *     past an unmeasured change the running total reads as unknown.
 */
export {};

src/lib/components/charts/waterfall-chart/WaterfallChart.svelte

<script lang="ts">
	import { TBody, Td, Th, THead, Tr } from '$lib/components/display/table';
	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 { yAxis } from '../_kernel/cartesian';
	import { waterfallReadout } from '../_kernel/inspect';
	import { formatExact, formatTick } from '../_kernel/format';
	import { band, niceDomain, PLOT, px } from '../_kernel/scale';
	import { waterfallBars, type WaterfallStep } from '../_kernel/waterfall';
	import CartesianPlot from '../_shared/CartesianPlot.svelte';
	import ChartInspector from '../_shared/ChartInspector.svelte';
	import ChartData from '../_shared/ChartData.svelte';
	import chart from '../_shared/chart.module.css';
	import styles from './waterfall-chart.module.css';

	/* A level, the changes that move it, and where it lands: an MRR bridge, a
	   budget. Each change floats from the running total; totals stand on
	   zero. An unmeasured change leaves every later level unknown. */
	let {
		label,
		steps,
		formatValue = formatExact,
		formatTick: tickFormat = formatTick,
		height,
		animation,
		inspect = true,
		class: className = ''
	}: {
		/** Names the chart. */
		label: string;
		/** In order: levels (total: true) and the changes between them. */
		steps: readonly WaterfallStep[];
		formatValue?: (value: number) => string;
		formatTick?: (value: number) => string;
		height?: string;
		/** Default: bars grow from where they start, 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 bars = $derived(waterfallBars(steps));
	const levels = $derived(
		bars.flatMap((bar) => (bar.from === null || bar.to === null ? [] : [bar.from, bar.to]))
	);
	const nice = $derived(niceDomain(levels));
	const shown = new Tweened(
		() => {
			const target: Record<string, number> = { __lo: nice.domain[0], __hi: nice.domain[1] };
			for (const bar of bars)
				if (bar.from !== null && bar.to !== null) {
					target[`${bar.key}|from`] = bar.from;
					target[`${bar.key}|to`] = bar.to;
				}
			return target;
		},
		() => motion.update
	);
	const axis = $derived(yAxis([shown.current.__lo, shown.current.__hi], nice.step, tickFormat));
	const slots = $derived(band(bars.length, 0.25));
	const drawn = $derived(
		bars.flatMap((bar, index) => {
			const from = shown.current[`${bar.key}|from`];
			const to = shown.current[`${bar.key}|to`];
			if (from === undefined || to === undefined) return [];
			return [
				{
					bar,
					index,
					top: Math.min(axis.y(from), axis.y(to)),
					bottom: Math.max(axis.y(from), axis.y(to))
				}
			];
		})
	);
	const change = (v: number) =>
		v > 0 ? `+${formatValue(v)}` : v < 0 ? `−${formatValue(-v)}` : formatValue(v);
</script>

{#snippet inspector()}
	<ChartInspector
		{label}
		positions={bars.map((_, index) => px(slots.centre(index)))}
		band={px(slots.step)}
		readout={(index) => waterfallReadout(bars[index], formatValue)}
	/>
{/snippet}

{#if !steps.length}
	<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)}>
		<ChartLegend
			items={[
				{ key: 'total', label: 'Total', color: 'var(--chart-neutral)' },
				{ key: 'increase', label: 'Increase', color: 'var(--chart-pos)' },
				{ key: 'decrease', label: 'Decrease', color: 'var(--chart-neg)' }
			]}
		/>
		{#if levels.length}
			<CartesianPlot
				{label}
				ticks={axis.ticks}
				{height}
				zeroAt={shown.current.__lo < 0 ? px(axis.y(0)) : undefined}
				wrap={slots.step / PLOT}
				overlay={inspect ? inspector : undefined}
				xLabels={bars.map((bar, index) => ({
					key: bar.key,
					at: px(slots.centre(index)),
					label: bar.label,
					align: 'middle' as const,
					tier: 2 as const
				}))}
				notes={drawn.map(({ bar, index, top }) => ({
					key: bar.key,
					x: slots.centre(index) / PLOT,
					y: top / PLOT,
					text:
						bar.kind === 'total' ? formatValue(bar.value as number) : change(bar.value as number)
				}))}
			>
				{#each drawn as { bar, index }, i (bar.key)}
					{@const next = drawn[i + 1]}
					{#if next && next.index === index + 1 && bar.to !== null}
						<line
							class={styles.connector}
							x1={px(slots.start(index) + slots.width)}
							x2={px(slots.start(index + 1))}
							y1={px(axis.y(shown.current[`${bar.key}|to`]))}
							y2={px(axis.y(shown.current[`${bar.key}|to`]))}
						/>
					{/if}
				{/each}
				{#each drawn as { bar, index, top, bottom } (bar.key)}
					<rect
						data-mark
						data-kind={bar.kind}
						class={styles.bar}
						x={px(slots.start(index))}
						width={px(slots.width)}
						y={px(top)}
						height={px(Math.max(1, bottom - top))}
					/>
				{/each}
			</CartesianPlot>
		{:else}
			<p class={chart.empty}>Measurements unavailable.</p>
		{/if}
		<ChartData {label}>
			<THead>
				<Tr><Th>Step</Th><Th numeric>Change</Th><Th numeric>Running total</Th></Tr>
			</THead>
			<TBody>
				{#each bars as bar (bar.key)}
					<Tr>
						<Th scope="row">{bar.label}</Th>
						<Td numeric
							>{bar.kind === 'total'
								? '—'
								: bar.value !== null
									? change(bar.value)
									: 'Unavailable'}</Td
						>
						<Td numeric>{bar.to !== null ? formatValue(bar.to) : 'Unavailable'}</Td>
					</Tr>
				{/each}
			</TBody>
		</ChartData>
	</div>
{/if}

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

import { isValue } from './scale';

/* WaterfallChart: a start, the changes, and where they land. */

export type WaterfallStep = {
	key: string;
	label: string;
	/** A change, or (total: true) a level. A total left null is the running
	 *  sum so far. */
	value: number | null;
	total?: boolean;
};

export type WaterfallBar = {
	key: string;
	label: string;
	kind: 'total' | 'increase' | 'decrease' | 'unavailable';
	/** Where the bar runs from and to; null when it cannot be known. */
	from: number | null;
	to: number | null;
	/** The change (or level, for totals) as given or computed. */
	value: number | null;
};

/** Running totals through the steps. An unmeasured change makes every
 *  later level unknown — it is never skipped as zero. */
export function waterfallBars(steps: readonly WaterfallStep[]): WaterfallBar[] {
	let level: number | null = 0;
	return steps.map((step) => {
		if (step.total) {
			const value = isValue(step.value) ? step.value : level;
			if (isValue(step.value)) level = step.value;
			return {
				key: step.key,
				label: step.label,
				kind: value === null ? 'unavailable' : 'total',
				from: value === null ? null : 0,
				to: value,
				value
			};
		}
		if (!isValue(step.value) || level === null) {
			level = null;
			return {
				key: step.key,
				label: step.label,
				kind: 'unavailable',
				from: null,
				to: null,
				value: isValue(step.value) ? step.value : null
			};
		}
		const from: number = level;
		// Rounded to 12 significant digits: summing decimals must not print
		// 94.60000000000001.
		level = Number((from + step.value).toPrecision(12));
		return {
			key: step.key,
			label: step.label,
			kind: step.value >= 0 ? 'increase' : 'decrease',
			from,
			to: level,
			value: step.value
		};
	});
}

CalendarHeatmap

a year · four levels · zero and no data

Deploys

Every day of the last year. The outlined week in March had no data.

View data for Deploys per day, last 12 months
Deploys per day, last 12 months
MonthTotalActive daysDays with dataBusiest day
Sep 2025 6 3 4 2025-09-30 (4)
Oct 2025 63 21 31 2025-10-10 (5)
Nov 2025 70 22 30 2025-11-04 (6)
Dec 2025 67 23 31 2025-12-12 (7)
Jan 2026 79 21 31 2026-01-07 (8)
Feb 2026 109 24 28 2026-02-24 (9)
Mar 2026 64 17 24 2026-03-17 (9)
Apr 2026 116 27 30 2026-04-20 (10)
May 2026 93 21 31 2026-05-13 (10)
Jun 2026 127 25 30 2026-06-10 (11)
Jul 2026 160 26 31 2026-07-10 (12)
Aug 2026 126 27 31 2026-08-12 (13)
Sep 2026 133 22 26 2026-09-03 (13)

Zero and no data differ. A day measured as nothing is filled with the empty level; a day with no data is outlined. The picture is named with its summary, and the table has each month.

Source src/lib/components/charts/calendar-heatmap/doc.ts · src/lib/components/charts/calendar-heatmap/CalendarHeatmap.svelte · src/lib/components/charts/calendar-heatmap/calendar-heatmap.module.css · src/lib/components/charts/_kernel/calendar.ts

src/lib/components/charts/calendar-heatmap/doc.ts

/**
 * CalendarHeatmap — a span of days, a week to a column.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label         REQUIRED
 *     days          { date: "YYYY-MM-DD", value: number | null }[]
 *     from, to      the first and last day shown
 *     unit?         what a day's number counts, for the summary
 *     formatValue?, animation?  (default: fade)
 *
 * # Behaviour
 *
 * R1  Weeks run left to right, Monday at the top. Dates are UTC calendar
 *     days, so every time zone draws the same grid.
 * R2  Four states: a value (one of four levels, by quartile of the busy
 *     days); zero (filled, the empty level); no data — a day with no entry
 *     or a null (outlined); and outside the range (not drawn).
 * R3  The picture is named with a summary: total, active days, and the
 *     busiest day. Each day carries its date and value as a tooltip.
 * R4  The table summarises by month: total, active days, days with data,
 *     and the busiest day.
 */
export {};

src/lib/components/charts/calendar-heatmap/CalendarHeatmap.svelte

<script lang="ts">
	import { TBody, Td, Th, THead, Tr } from '$lib/components/display/table';
	import type { AnimationProp } from '$lib/motion';
	import { chartMotion } from '$lib/motion/svelte.svelte';
	import { cn } from '$lib/utils/cn';
	import { calendarCells, monthLabels, monthSummary, type CalendarDay } from '../_kernel/calendar';
	import { sequentialFill } from '../_kernel/encode';
	import { formatExact } from '../_kernel/format';
	import ChartData from '../_shared/ChartData.svelte';
	import chart from '../_shared/chart.module.css';
	import styles from './calendar-heatmap.module.css';

	/* A year (or any span) of days, a week to a column: activity at a glance.
	   Four levels by quartile of the busy days; a measured zero is filled, a
	   day without data is outlined. The exact numbers are summarised by month
	   in the table. */
	let {
		label,
		days,
		from,
		to,
		unit = '',
		formatValue = formatExact,
		animation,
		class: className = ''
	}: {
		/** Names the chart. */
		label: string;
		/** One entry per day, as YYYY-MM-DD; no entry means no data, not zero. */
		days: readonly CalendarDay[];
		/** The first and last day shown, YYYY-MM-DD (UTC calendar days). */
		from: string;
		to: string;
		/** What a day's number is, for the summary: "deploys". */
		unit?: string;
		formatValue?: (value: number) => string;
		/** Default: days fade in, when scrolled into view. */
		animation?: AnimationProp;
		class?: string;
	} = $props();

	const WEEKDAYS = ['Mon', '', 'Wed', '', 'Fri', '', ''];
	const motion = chartMotion(() => animation, { enter: 'fade', axis: 'y' });
	const cells = $derived(calendarCells(days, from, to).cells);
	const months = $derived(monthLabels(cells));
	const summary = $derived(monthSummary(cells));
	const description = $derived.by(() => {
		const inRange = cells.filter((c) => c.state !== 'outside');
		const total = inRange.reduce((sum, c) => sum + (c.value ?? 0), 0);
		const active = inRange.filter((c) => (c.value ?? 0) > 0).length;
		const busiest = inRange.reduce<(typeof cells)[number] | null>(
			(best, c) => (c.value !== null && (!best || c.value > (best.value ?? 0)) ? c : best),
			null
		);
		const suffix = unit ? ` ${unit}` : '';
		return `${label}: ${formatValue(total)}${suffix} over ${active} active days${
			busiest?.value ? `; busiest ${busiest.date} with ${formatValue(busiest.value)}` : ''
		}.`;
	});
	const suffix = $derived(unit ? ` ${unit}` : '');
</script>

<div {...motion.pending} {@attach motion.attach} class={cn(chart.root, className)}>
	<div class={styles.scroll}>
		<div class={styles.calendar} role="img" aria-label={description}>
			<div class={styles.months} aria-hidden="true">
				{#each months as month (month.week)}
					<span style:--week={month.week}>{month.label}</span>
				{/each}
			</div>
			<div class={styles.days} aria-hidden="true">
				{#each WEEKDAYS as day, index (index)}<span>{day}</span>{/each}
			</div>
			<div class={styles.grid} aria-hidden="true">
				{#each cells as cell (cell.date)}
					<span
						data-mark={cell.state === 'value' || cell.state === 'zero' || undefined}
						data-state={cell.state}
						class={styles.day}
						title={cell.state === 'outside'
							? undefined
							: `${cell.date}: ${cell.value === null ? 'no data' : formatValue(cell.value)}${suffix}`}
						style:--fill={cell.state === 'value' ? sequentialFill(cell.level / 4) : undefined}
					></span>
				{/each}
			</div>
		</div>
	</div>
	<div class={styles.key} aria-hidden="true">
		Less
		{#each [1, 2, 3, 4] as level (level)}
			<span class={styles.day} style:--fill={sequentialFill(level / 4)}></span>
		{/each}
		More
		<span class={styles.gap}></span>
		<span class={styles.day} data-state="zero"></span> None
		<span class={styles.day} data-state="missing"></span> No data
	</div>
	<ChartData {label}>
		<THead>
			<Tr>
				<Th>Month</Th><Th numeric>Total</Th><Th numeric>Active days</Th><Th numeric
					>Days with data</Th
				><Th>Busiest day</Th>
			</Tr>
		</THead>
		<TBody>
			{#each summary as month (month.month)}
				<Tr>
					<Th scope="row">{month.month}</Th>
					<Td numeric>{formatValue(month.total)}</Td>
					<Td numeric>{month.active}</Td>
					<Td numeric>{month.measured}</Td>
					<Td
						>{month.best?.value ? `${month.best.date} (${formatValue(month.best.value)})` : '—'}</Td
					>
				</Tr>
			{/each}
		</TBody>
	</ChartData>
</div>

src/lib/components/charts/calendar-heatmap/calendar-heatmap.module.css

@layer primitive {
	.scroll {
		min-width: 0;
		overflow-x: auto;
		padding-bottom: var(--space-2);
	}
	.calendar {
		--cell: 11px;
		--gap: 3px;
		display: grid;
		width: max-content;
		grid-template-columns: auto auto;
		column-gap: var(--space-3);
		color: var(--chart-label, var(--ink-3));
		font-size: var(--text-11);
	}
	.months {
		position: relative;
		height: 1.4em;
		grid-column: 2;
	}
	.months span {
		position: absolute;
		top: 0;
		left: calc(var(--week) * (var(--cell) + var(--gap)));
	}
	.days {
		display: grid;
		grid-template-rows: repeat(7, var(--cell));
		gap: var(--gap);
		line-height: var(--cell);
	}
	.grid {
		display: grid;
		grid-auto-columns: var(--cell);
		grid-auto-flow: column;
		grid-template-rows: repeat(7, var(--cell));
		gap: var(--gap);
	}
	.day {
		border-radius: 2px;
		background: var(--fill, var(--chart-absent));
	}
	.day[data-state='zero'] {
		--fill: var(--chart-absent);
	}
	.day[data-state='missing'] {
		border: 1px dashed var(--ink-4, var(--line-strong));
		background: transparent;
	}
	.day[data-state='outside'] {
		visibility: hidden;
	}
	.key {
		display: flex;
		flex-wrap: wrap;
		align-items: center;
		gap: var(--space-2);
		color: var(--ink-3);
		font-size: var(--text-11);
	}
	.key .day {
		display: inline-block;
		width: 11px;
		height: 11px;
	}
	.key .gap {
		width: var(--space-5);
	}
}

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

import { isValue } from './scale';

/* CalendarHeatmap: days in week columns, Monday first. All dates are UTC
   calendar days, so server and browser agree whatever their time zones. */

export type CalendarDay = { date: string; value: number | null };

export type CalendarCell = {
	date: string;
	/** 0 Monday … 6 Sunday. */
	weekday: number;
	week: number;
	state: 'value' | 'zero' | 'missing' | 'outside';
	value: number | null;
	/** 1–4 for values: which quartile of the non-zero values. */
	level: number;
};

const DAY = 86_400_000;
const parse = (date: string) => Date.parse(`${date}T00:00:00Z`);
const iso = (ms: number) => new Date(ms).toISOString().slice(0, 10);
const weekday = (ms: number) => (new Date(ms).getUTCDay() + 6) % 7;

/** Every day from `from` to `to`, padded to whole weeks. Days in range with
 *  no entry are "missing" (no data); padding days are "outside". */
export function calendarCells(days: readonly CalendarDay[], from: string, to: string) {
	const byDate = new Map(days.map((d) => [d.date, d.value]));
	const start = parse(from);
	const end = parse(to);
	const first = start - weekday(start) * DAY;
	const last = end + (6 - weekday(end)) * DAY;
	const positives = days
		.map((d) => d.value)
		.filter((v): v is number => isValue(v) && v > 0)
		.toSorted((a, b) => a - b);
	const cut = (p: number) =>
		positives.length
			? positives[Math.min(positives.length - 1, Math.floor(p * positives.length))]
			: 0;
	const cuts = [cut(0.25), cut(0.5), cut(0.75)];
	const cells: CalendarCell[] = [];
	for (let ms = first, i = 0; ms <= last; ms += DAY, i++) {
		const date = iso(ms);
		const inRange = ms >= start && ms <= end;
		const raw = byDate.get(date);
		const has = byDate.has(date) && isValue(raw);
		const value = has ? (raw as number) : null;
		cells.push({
			date,
			weekday: weekday(ms),
			week: Math.floor(i / 7),
			state: !inRange ? 'outside' : !has ? 'missing' : value === 0 ? 'zero' : 'value',
			value,
			level: value && value > 0 ? 1 + cuts.filter((c) => value > c).length : 0
		});
	}
	return { cells, weeks: Math.ceil(cells.length / 7) };
}

const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];

/** A label over the first week column of each month. */
export function monthLabels(cells: readonly CalendarCell[]) {
	const out: { week: number; label: string }[] = [];
	let seen = '';
	for (const cell of cells) {
		if (cell.state === 'outside' || cell.weekday !== 0) continue;
		const month = cell.date.slice(0, 7);
		if (month !== seen) {
			seen = month;
			out.push({
				week: cell.week,
				label: MONTHS[Number(cell.date.slice(5, 7)) - 1]
			});
		}
	}
	// A month that starts too close to the next (a partial first month) would
	// print on top of it.
	return out.filter((label, i) => !out[i + 1] || out[i + 1].week - label.week >= 3);
}

/** Per-month summary for the data table. */
export function monthSummary(cells: readonly CalendarCell[]) {
	const months = new Map<
		string,
		{
			total: number;
			active: number;
			measured: number;
			best: CalendarCell | null;
		}
	>();
	for (const cell of cells) {
		if (cell.state === 'outside') continue;
		const key = cell.date.slice(0, 7);
		const m = months.get(key) ?? {
			total: 0,
			active: 0,
			measured: 0,
			best: null
		};
		if (cell.value !== null) {
			m.measured++;
			m.total += cell.value;
			if (cell.value > 0) m.active++;
			if (!m.best || cell.value > (m.best.value ?? 0)) m.best = cell;
		}
		months.set(key, m);
	}
	return [...months].map(([month, m]) => ({
		month: `${MONTHS[Number(month.slice(5, 7)) - 1]} ${month.slice(0, 4)}`,
		...m
	}));
}

SunburstChart

a hierarchy · a hue per branch

Storage

By team, then by project.

  • Data 380 GB · 39%
  • Platform 420 GB · 43.1%
  • Growth 105 GB · 10.8%
  • Design 70 GB · 7.2%
View data for Storage by team and project, GB
Storage by team and project, GB
PartValueShare of total
Data 380 GB 39%
Data / Warehouse 300 GB 30.8%
Data / Exports 80 GB 8.2%
Platform 420 GB 43.1%
Platform / atlas-db 210 GB 21.5%
Platform / atlas-api 120 GB 12.3%
Platform / Logs 90 GB 9.2%
Growth 105 GB 10.8%
Growth / Website 60 GB 6.2%
Growth / Experiments 45 GB 4.6%
Design 70 GB 7.2%
Design / Assets 70 GB 7.2%
Source src/lib/components/charts/sunburst-chart/doc.ts · src/lib/components/charts/sunburst-chart/SunburstChart.svelte · src/lib/components/charts/_kernel/sunburst.ts

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

/**
 * SunburstChart — parts of a whole, and the parts of those parts.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label         REQUIRED
 *     data          { key, label, value?, children? }[] — the top level
 *     totalLabel?, formatValue?, animation?  (default: trace)
 *
 * # Behaviour
 *
 * R1  The top level is the inner ring; each child sits outside its parent,
 *     spanning its share of the parent. Up to three rings are drawn.
 * R2  A parent's value is the sum of its children; only leaves carry
 *     values. Non-positive or non-finite leaves count as nothing.
 * R3  Each top-level part takes a hue (four, then neutral) and its
 *     descendants take lighter tints of it, so a branch reads as one family.
 * R4  The legend lists the top level with values and shares; the table
 *     lists every node by its full path.
 */
export {};

src/lib/components/charts/sunburst-chart/SunburstChart.svelte

<script lang="ts">
	import { TBody, Td, Th, THead, Tr } from '$lib/components/display/table';
	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 { formatExact, formatPercent } from '../_kernel/format';
	import {
		sunburstArcs,
		sunburstPath,
		sunburstRing,
		sunburstRows,
		sunburstTarget,
		type SunburstNode
	} from '../_kernel/sunburst';
	import ChartData from '../_shared/ChartData.svelte';
	import chart from '../_shared/chart.module.css';
	import styles from './sunburst-chart.module.css';

	/* Parts of a whole, and the parts of those parts: storage by team and
	   then by project. Each top-level part keeps its hue as it splits. The
	   legend names the top level; the table has every level. */
	let {
		label,
		data,
		totalLabel = 'Total',
		formatValue = formatExact,
		animation,
		class: className = ''
	}: {
		/** Names the chart. */
		label: string;
		/** The top level; children nest outward, up to three rings. */
		data: readonly SunburstNode[];
		/** Under the total in the middle. */
		totalLabel?: string;
		formatValue?: (value: number) => string;
		/** Default: arcs trace round, when scrolled into view. */
		animation?: AnimationProp;
		class?: string;
	} = $props();

	const depthOf = (nodes: readonly SunburstNode[]): number =>
		nodes.length ? 1 + Math.max(...nodes.map((n) => depthOf(n.children ?? []))) : 0;

	const motion = chartMotion(() => animation, { enter: 'trace', axis: 'x' });
	const shown = new Tweened(
		() => sunburstTarget(data),
		() => motion.update
	);
	const depths = $derived(Math.min(3, depthOf(data)));
	const layout = $derived(sunburstArcs(data, shown.current, depths));
	const exactTotal = $derived(
		sunburstRows(data, 0)
			.filter((row) => row.depth === 0)
			.reduce((sum, row) => sum + row.value, 0)
	);
	const rows = $derived(sunburstRows(data, exactTotal));
	const legend = $derived(
		data.map((node) => {
			const value = rows.find((row) => row.depth === 0 && row.key === node.key)?.value ?? 0;
			return {
				key: node.key,
				label: node.label,
				color: layout.arcs.find((arc) => arc.path === node.key)?.color ?? 'var(--chart-neutral)',
				value: formatValue(value),
				note: exactTotal > 0 ? formatPercent(value / exactTotal) : undefined
			};
		})
	);
</script>

<div {...motion.pending} {@attach motion.attach} class={cn(chart.root, styles.wrap, className)}>
	{#if !data.length}
		<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">
					{#each layout.arcs as arc (arc.path)}
						<path
							data-mark
							class={styles.arc}
							d={sunburstPath(arc, depths)}
							pathLength={1}
							stroke-width={sunburstRing(arc.depth, depths).width}
							style:--series={arc.color}
						/>
					{/each}
				</svg>
				<div class={styles.centre} aria-hidden="true">
					<span class={styles.total}>{layout.total > 0 ? formatValue(exactTotal) : '—'}</span>
					<span class={styles.caption}>{totalLabel}</span>
				</div>
			</div>
			<ChartLegend
				class={styles.legend}
				layout="list"
				label="{label}: {totalLabel.toLowerCase()} {formatValue(exactTotal)}"
				items={legend}
			/>
		</div>
		<ChartData {label}>
			<THead>
				<Tr><Th>Part</Th><Th numeric>Value</Th><Th numeric>Share of total</Th></Tr>
			</THead>
			<TBody>
				{#each rows as row (row.path)}
					<Tr>
						<Th scope="row">{row.path}</Th>
						<Td numeric>{formatValue(row.value)}</Td>
						<Td numeric>{row.share !== null ? formatPercent(row.share) : '—'}</Td>
					</Tr>
				{/each}
			</TBody>
		</ChartData>
	{/if}
</div>

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

import { arcPath } from './donut';
import { colorVar, type ChartColor } from './encode';
import { isValue } from './scale';

/* SunburstChart: a hierarchy as rings, parents inside their children. */

export type SunburstNode = {
	key: string;
	label: string;
	/** Leaves carry a value; a parent's value is the sum of its children. */
	value?: number | null;
	children?: readonly SunburstNode[];
};

export type SunburstArc = {
	/** Keys from the top level down, joined with "/". */
	path: string;
	label: string;
	depth: number;
	value: number;
	color: string;
	/** Turns. */
	start: number;
	end: number;
};

const valueOf = (node: SunburstNode): number => {
	if (node.children?.length) return node.children.reduce((sum, c) => sum + valueOf(c), 0);
	return isValue(node.value) && node.value > 0 ? node.value : 0;
};

/** Every node's value by path, for tweening. */
export function sunburstTarget(
	nodes: readonly SunburstNode[],
	prefix = ''
): Record<string, number> {
	const out: Record<string, number> = {};
	for (const node of nodes) {
		const path = prefix ? `${prefix}/${node.key}` : node.key;
		out[path] = valueOf(node);
		if (node.children?.length) Object.assign(out, sunburstTarget(node.children, path));
	}
	return out;
}

/** Arcs for the tweened values. Each top-level node takes a hue (four, then
 *  neutral); its descendants take lighter tints of it by depth. */
export function sunburstArcs(
	nodes: readonly SunburstNode[],
	shown: Readonly<Record<string, number>>,
	maxDepth = 3
) {
	const arcs: SunburstArc[] = [];
	const walk = (
		level: readonly SunburstNode[],
		prefix: string,
		depth: number,
		start: number,
		span: number,
		parentTotal: number,
		hue: string | null
	) => {
		let cursor = start;
		level.forEach((node, index) => {
			const path = prefix ? `${prefix}/${node.key}` : node.key;
			const value = shown[path] ?? 0;
			const share = parentTotal > 0 ? (value / parentTotal) * span : 0;
			const base = hue ?? colorVar(index < 4 ? ((index + 1) as ChartColor) : 'neutral');
			const color =
				depth === 0
					? base
					: `color-mix(in oklab, ${base} ${100 - depth * 28}%, var(--surface-panel))`;
			if (share > 0) {
				arcs.push({
					path,
					label: node.label,
					depth,
					value,
					color,
					start: cursor,
					end: cursor + share
				});
				if (node.children?.length && depth + 1 < maxDepth)
					walk(node.children, path, depth + 1, cursor, share, value, base);
			}
			cursor += share;
		});
	};
	const total = nodes.reduce((sum, n) => sum + (shown[n.key] ?? 0), 0);
	walk(nodes, '', 0, 0, 1, total, null);
	return { arcs, total };
}

/** A ring's radius and width by depth, in a 100 × 100 viewBox. */
export function sunburstRing(depth: number, depths: number) {
	const inner = 16;
	const outer = 47;
	const width = (outer - inner) / depths;
	return { radius: inner + width * (depth + 0.5), width: width - 1 };
}

export const sunburstPath = (arc: SunburstArc, depths: number) => {
	const { radius } = sunburstRing(arc.depth, depths);
	const gap = arc.end - arc.start > 0.006 ? 0.0015 : 0;
	return arcPath(arc.start + gap, arc.end - gap, radius);
};

/** Every node as rows for the data table: its path, value, and share of
 *  the whole. */
export function sunburstRows(nodes: readonly SunburstNode[], total: number) {
	const rows: {
		key: string;
		path: string;
		label: string;
		depth: number;
		value: number;
		share: number | null;
	}[] = [];
	const walk = (level: readonly SunburstNode[], prefix: string, depth: number) => {
		for (const node of level) {
			const label = prefix ? `${prefix} / ${node.label}` : node.label;
			const value = valueOf(node);
			rows.push({
				key: node.key,
				path: label,
				label: node.label,
				depth,
				value,
				share: total > 0 ? value / total : null
			});
			if (node.children?.length) walk(node.children, label, depth + 1);
		}
	};
	walk(nodes, '', 0);
	return rows;
}