Skip to examples
Bento / Kitchen sink
Bento / compositions

Statistical charts

Relationships and distributions: effect against significance, three measures at once, a ranking, and a matrix that keeps four kinds of cell apart. Same rules as every chart — named, exact values available, nothing missing drawn as zero — and the same motion.

Volcano

effect against significance · three states · named points

Experiment results

Lift against significance. Beyond both dashed lines is a finding; grey is a measured null result.

View data for Experiment results: lift against significance
Experiment results: lift against significance
PointLift in conversion (%)−log₁₀ pResult
Experiment 1 -7.81 1 Below threshold
Experiment 2 3.18 1.07 Below threshold
Experiment 3 -0.54 0.5 Below threshold
Experiment 4 3.68 0.55 Below threshold
Experiment 5 4.23 1.14 Below threshold
Experiment 6 -2.11 0.71 Below threshold
Experiment 7 -3.26 1.63 Negative
Experiment 8 6.04 0.63 Below threshold
Experiment 9 -5.53 1.16 Below threshold
Experiment 10 4.19 0.87 Below threshold
Experiment 11 0.86 0.78 Below threshold
Experiment 12 7.46 2.83 Positive
Experiment 13 5.24 0.23 Below threshold
Experiment 14 -2.29 0.45 Below threshold
Experiment 15 -6.59 2.57 Negative
Experiment 16 -1.17 1 Below threshold
Experiment 17 3.25 1.27 Below threshold
Experiment 18 -2.3 0.25 Below threshold
Experiment 19 6.61 1.11 Below threshold
Experiment 20 2.12 0.36 Below threshold
Experiment 21 2.95 1.66 Below threshold
Experiment 22 5.23 2.28 Positive
Experiment 23 -6.13 1.98 Negative
Experiment 24 6.26 3.37 Positive
Experiment 25 1.51 1.43 Below threshold
Experiment 26 0.78 0.29 Below threshold
Experiment 27 0.26 0.62 Below threshold
Experiment 28 0.79 1.05 Below threshold
Experiment 29 0.92 0.69 Below threshold
Experiment 30 2.28 1.2 Below threshold
Experiment 31 -3 0.33 Below threshold
Experiment 32 -7.61 3.9 Negative
Experiment 33 2.09 0.51 Below threshold
Experiment 34 1.52 1.29 Below threshold
Experiment 35 -3.55 2.1 Negative
Experiment 36 4.88 2.03 Positive
Experiment 37 -5.77 2.99 Negative
Experiment 38 1.07 0.39 Below threshold
Experiment 39 -4.63 2.38 Negative
Experiment 40 3.77 1.91 Positive
Experiment 41 1.75 1.34 Below threshold
Experiment 42 -0.17 0.49 Below threshold
Experiment 43 4.73 1.97 Positive
Experiment 44 0.88 0.61 Below threshold
Experiment 45 -5.12 0.93 Below threshold
Experiment 46 -0.36 0.03 Below threshold
Experiment 47 -5.23 0.14 Below threshold
Experiment 48 6.02 2.71 Positive
Experiment 49 -5.21 1.35 Negative
Experiment 50 3.72 0.9 Below threshold
Experiment 51 4.66 2.13 Positive
Experiment 52 0.48 0.5 Below threshold
Experiment 53 1 0.55 Below threshold
Experiment 54 4.42 0.36 Below threshold
Experiment 55 5.23 2.57 Positive
Experiment 56 -1.12 0.92 Below threshold
Experiment 57 -3.98 1.03 Below threshold
Experiment 58 5.28 1.74 Positive
Experiment 59 0.82 0.43 Below threshold
Experiment 60 0.59 0.79 Below threshold
Experiment 61 1.16 1.19 Below threshold
Experiment 62 -0.07 0.57 Below threshold
Experiment 63 6.9 2.23 Positive
Experiment 64 -5.4 2 Negative
Experiment 65 1.76 0.98 Below threshold
Experiment 66 -3.27 0.68 Below threshold
Experiment 67 3.71 1.87 Positive
Experiment 68 -3.85 0.69 Below threshold
Experiment 69 6.82 3.07 Positive
Experiment 70 7.4 1.54 Positive
Experiment 71 -3.48 1.16 Below threshold
Experiment 72 3.42 1.67 Positive
Experiment 73 3.61 1.48 Positive
Experiment 74 -3.65 1.75 Negative
Experiment 75 -5.76 2.24 Negative
Experiment 76 0.95 0.95 Below threshold
Experiment 77 7.25 0.58 Below threshold
Experiment 78 -3.41 1.12 Below threshold
Experiment 79 6.56 2.96 Positive
Experiment 80 6.53 1.21 Below threshold
Experiment 81 6.81 2.71 Positive
Experiment 82 -2.66 1.87 Below threshold
Experiment 83 5.48 0.14 Below threshold
Experiment 84 4.04 0.26 Below threshold
Experiment 85 6.59 1.28 Below threshold
Experiment 86 -3.81 1.94 Negative
Experiment 87 -7.08 2.21 Negative
Experiment 88 -6.68 0.56 Below threshold
Experiment 89 4.63 1.66 Positive
Experiment 90 -0.04 0.39 Below threshold
Experiment 91 -1.01 0.38 Below threshold
Experiment 92 4.36 1.15 Below threshold
Experiment 93 5.32 0.23 Below threshold
Experiment 94 3.47 1.62 Positive
Experiment 95 5.44 1.83 Positive
Experiment 96 -6.48 1.1 Below threshold
Experiment 97 3.09 0.77 Below threshold
Experiment 98 -6.87 1.94 Negative
Experiment 99 4.47 0.92 Below threshold
Experiment 100 -5.27 0.73 Below threshold
Experiment 101 6.09 0.11 Below threshold
Experiment 102 7.04 3.31 Positive
Experiment 103 1.21 0.52 Below threshold
Experiment 104 0.29 0.49 Below threshold
Experiment 105 3.54 1.4 Positive
Experiment 106 1.12 0.23 Below threshold
Experiment 107 6.73 0.95 Below threshold
Experiment 108 -3.46 1.18 Below threshold
Experiment 109 5.18 2.64 Positive
Experiment 110 3.32 1.8 Positive
Experiment 111 3.23 1.25 Below threshold
Experiment 112 -1.55 0.72 Below threshold
Experiment 113 -4.13 1.4 Negative
Experiment 114 2.8 1.11 Below threshold
Experiment 115 -4.13 1.79 Negative
Experiment 116 -0.96 0.82 Below threshold
Experiment 117 -6.01 1.93 Negative
Experiment 118 2.55 0.78 Below threshold
Experiment 119 -1.3 1.12 Below threshold
Experiment 120 0.19 0.31 Below threshold
Experiment 121 7.61 0.83 Below threshold
Experiment 122 -0.85 0.48 Below threshold
Experiment 123 -4.39 2.49 Negative
Experiment 124 0.55 0.37 Below threshold
Experiment 125 -0.29 0.06 Below threshold
Experiment 126 3.22 1.28 Below threshold
Experiment 127 -7.89 2.24 Negative
Experiment 128 -3.01 1.48 Negative
Experiment 129 -3.3 1.37 Negative
Experiment 130 -3.2 0.66 Below threshold
Experiment 131 -6.1 1.86 Negative
Experiment 132 2.75 1.61 Below threshold
Experiment 133 1.2 0.84 Below threshold
Experiment 134 4.04 1.61 Positive
Experiment 135 1.59 1.09 Below threshold
Experiment 136 3.55 2.09 Positive
Experiment 137 2.79 0.91 Below threshold
Experiment 138 -3.92 1.92 Negative
Experiment 139 6.5 0.71 Below threshold
Experiment 140 0.3 0.33 Below threshold
Experiment 141 -5.07 1.31 Negative
Experiment 142 2.33 0.43 Below threshold
Experiment 143 -3.02 1.35 Negative
Experiment 144 -5.58 1.22 Below threshold
Experiment 145 -7.99 2.57 Negative
Experiment 146 7.62 2.52 Positive
Experiment 147 1.74 0.92 Below threshold
Experiment 148 -3.6 1.12 Below threshold
Experiment 149 4.24 1.62 Positive
Experiment 150 -1.92 0.73 Below threshold
Experiment 151 -6.98 1.43 Negative
Experiment 152 4.2 0.57 Below threshold
Experiment 153 -6.44 1.72 Negative
Experiment 154 6.21 2.22 Positive
Experiment 155 5.8 0.2 Below threshold
Experiment 156 -4.68 1.24 Below threshold
Experiment 157 -5.56 1.44 Negative
Experiment 158 -7.86 0.53 Below threshold
Experiment 159 -1.19 0.2 Below threshold
Experiment 160 -0.98 1.03 Below threshold

Grey is a result. A point inside the cuts was measured and showed nothing; it is drawn, smaller and neutral, never left out.

The chart never transforms data. Significance arrives already as −log₁₀ p; a chart that logged its input would record the transform only in an axis label.

PlotFrame draws the axes, grid, and reference lines; the marks are children. It keeps its aspect ratio, so points stay round, and it has one y axis — always.

Source src/lib/components/charts/volcano/doc.ts · src/lib/components/charts/volcano/Volcano.svelte · src/lib/components/charts/plot-frame/doc.ts · src/lib/components/charts/plot-frame/PlotFrame.svelte · src/lib/components/charts/plot-frame/plot-frame.module.css · src/lib/components/charts/_kernel/scatter.ts · src/lib/components/charts/_kernel/plot.ts

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

/**
 * Volcano — effect against significance.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label              REQUIRED
 *     points             { id, x (effect, signed), y (significance), label? }[]
 *     effectCut?         |x| at or beyond this is an effect, default 1
 *     significanceCut?   y at or above this is significant, default 1.3
 *     xTitle?, yTitle?   what the axes measure
 *     labelled?          ids to name beside their point
 *     aspect?, formatValue?, animation?  (default entrance: pop)
 *
 * # Behaviour
 *
 * R1  Three states: beyond both cuts and positive, beyond both and negative,
 *     and everything else — which is a measured result, drawn grey and
 *     smaller, never omitted.
 * R2  The chart never transforms data: y arrives as the caller computed it.
 * R3  The cuts are drawn as reference lines, and always inside the axes.
 * R4  Points with a non-finite x or y are not drawn; the table lists them as
 *     unavailable.
 * R5  Only the named points are labelled: a label on every point is a wall
 *     of text.
 */
export {};

src/lib/components/charts/volcano/Volcano.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 { formatExact } from '../_kernel/format';
	import { spreadLabels } from '../_kernel/plot';
	import {
		scatterScales,
		validPoint,
		volcanoState,
		volcanoTarget,
		type VolcanoPoint
	} from '../_kernel/scatter';
	import ChartData from '../_shared/ChartData.svelte';
	import chart from '../_shared/chart.module.css';
	import PlotFrame from '../plot-frame/PlotFrame.svelte';

	/* Effect against significance: the points beyond both cuts are the
	   finding, and "not significant" is a result, drawn grey. */
	let {
		label,
		points,
		effectCut = 1,
		significanceCut = 1.3,
		xTitle = 'Effect',
		yTitle = 'Significance',
		labelled = [],
		aspect = 1.6,
		formatValue = formatExact,
		animation,
		class: className = ''
	}: {
		/** Names the chart. */
		label: string;
		points: readonly VolcanoPoint[];
		/** |x| at or beyond this is a meaningful effect. */
		effectCut?: number;
		/** y at or above this is significant. */
		significanceCut?: number;
		xTitle?: string;
		yTitle?: string;
		/** Ids to name beside their point. */
		labelled?: readonly string[];
		/** The plot's width over its height. */
		aspect?: number;
		formatValue?: (value: number) => string;
		/** Default: points pop in, when scrolled into view. */
		animation?: AnimationProp;
		class?: string;
	} = $props();

	const RESULT = { up: 'Positive', down: 'Negative', quiet: 'Below threshold' };
	const motion = chartMotion(() => animation, { enter: 'pop', axis: 'y' });
	const model = $derived(volcanoTarget(points, effectCut, significanceCut));
	const shown = new Tweened(
		() => model.target,
		() => motion.update
	);
	const plot = $derived(scatterScales(shown.current, model.xStep, model.yStep, aspect));
	const named = $derived(new Set(labelled));
	const notes = $derived(
		spreadLabels(
			model.valid
				.filter((p) => named.has(p.id) && shown.current[`${p.id}|x`] !== undefined)
				.map((p) => ({
					key: p.id,
					x: plot.x(shown.current[`${p.id}|x`]) / plot.width,
					y: plot.y(shown.current[`${p.id}|y`]) / plot.height,
					text: p.label ?? p.id
				}))
		)
	);
</script>

<div {...motion.pending} {@attach motion.attach} class={cn(chart.root, className)}>
	{#if points.length === 0}
		<p class={chart.empty}>No data to display.</p>
	{:else if model.valid.length === 0}
		<p class={chart.empty}>Measurements unavailable.</p>
	{:else}
		<PlotFrame
			{label}
			{aspect}
			xTicks={plot.xTicks}
			yTicks={plot.yTicks}
			{xTitle}
			{yTitle}
			rules={[
				{ key: 'sig', y: plot.y(significanceCut) },
				{ key: 'up', x: plot.x(effectCut) },
				{ key: 'down', x: plot.x(-effectCut) }
			]}
			{notes}
		>
			{#each model.valid as p (p.id)}
				{@const x = shown.current[`${p.id}|x`]}
				{@const y = shown.current[`${p.id}|y`]}
				{#if x !== undefined && y !== undefined}
					{@const state = volcanoState(p, effectCut, significanceCut)}
					<circle
						data-mark
						data-state={state}
						class={chart.dot}
						cx={plot.x(x)}
						cy={plot.y(y)}
						r={state === 'quiet' ? 4.5 : 6.5}
					/>
				{/if}
			{/each}
		</PlotFrame>
	{/if}
	{#if points.length}
		<ChartData {label}>
			<THead>
				<Tr>
					<Th>Point</Th><Th numeric>{xTitle}</Th><Th numeric>{yTitle}</Th><Th>Result</Th>
				</Tr>
			</THead>
			<TBody>
				{#each points as p (p.id)}
					<Tr>
						<Th scope="row">{p.label ?? p.id}</Th>
						<Td numeric>{Number.isFinite(p.x) ? formatValue(p.x) : 'Unavailable'}</Td>
						<Td numeric>{Number.isFinite(p.y) ? formatValue(p.y) : 'Unavailable'}</Td>
						<Td
							>{validPoint(p)
								? RESULT[volcanoState(p, effectCut, significanceCut)]
								: 'Unavailable'}</Td
						>
					</Tr>
				{/each}
			</TBody>
		</ChartData>
	{/if}
</div>

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

/**
 * PlotFrame — two numeric axes and a plot area for marks.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label            REQUIRED, names the plot image
 *     aspect?          width over height, default 1.6
 *     xTicks, yTicks   { value, at (0–1 of the plot), label }[]
 *     xTitle?, yTitle? what each axis measures
 *     rules?           { x? | y? }[] — lines to compare against
 *     notes?           { x, y (0–1), text, kind?, corner? }[] — text over the
 *                      plot, beside a point or inside a corner
 *     bands?           shaded regions, in plot coordinates
 *     children         the marks, in plot coordinates: 0–1000 wide,
 *                      1000 / aspect tall
 *
 * # Behaviour
 *
 * R1  One y axis, always. Two measures of different scale on one frame make
 *     a crossing point that is an artefact of where the axes were put.
 * R2  The plot keeps its aspect ratio at any width, so round marks stay
 *     round. Axis text stays at reading size; it is never scaled.
 * R3  Rules are dashed and darker than the grid, and drawn under the marks:
 *     a value to compare against, never mistaken for grid or data.
 * R4  The frame knows nothing about the marks drawn in it.
 */
export {};

src/lib/components/charts/plot-frame/PlotFrame.svelte

<script lang="ts" module>
	export type PlotNote = {
		key: string;
		/** Position as fractions of the plot: 0 left/top, 1 right/bottom. */
		x: number;
		y: number;
		text: string;
		kind?: 'point' | 'rule';
		/** Anchor the text inside a corner of the plot instead of beside x, y. */
		corner?: 'tl' | 'tr' | 'bl' | 'br';
	};
	/** A shaded region, in plot coordinates: a quadrant, a target zone. */
	export type PlotBand = { key: string; x: number; y: number; width: number; height: number };
	export type PlotRule = { key: string; x?: number; y?: number };
</script>

<script lang="ts">
	import type { Snippet } from 'svelte';
	import type { AxisTick } from '../_kernel/plot';
	import styles from './plot-frame.module.css';

	/* Two numeric axes and a plot area for marks. One y axis, always: two
	   measures of different scale on one frame make a crossing point that is an
	   artefact of where the axes were put. The marks are children, so the frame
	   knows nothing about any chart drawn in it. */
	let {
		label,
		aspect = 1.6,
		xTicks,
		yTicks,
		xTitle,
		yTitle,
		rules = [],
		notes = [],
		bands = [],
		children
	}: {
		/** Names the plot image. */
		label: string;
		/** Width over height; the plot keeps it so marks stay round. */
		aspect?: number;
		xTicks: readonly AxisTick[];
		yTicks: readonly AxisTick[];
		xTitle?: string;
		yTitle?: string;
		/** Lines to compare against, in plot coordinates (0–1000 wide). */
		rules?: readonly PlotRule[];
		/** Text over the plot: named points, what a rule means. */
		notes?: readonly PlotNote[];
		/** Shaded regions, under everything else. */
		bands?: readonly PlotBand[];
		/** The marks, in plot coordinates: 0–1000 wide, 1000 / aspect tall. */
		children: Snippet;
	} = $props();

	const width = 1000;
	const height = $derived(width / aspect);
</script>

<div
	class={styles.root}
	style:--aspect={aspect}
	style:--axis-ch={Math.max(2, ...yTicks.map((tick) => tick.label.length))}
>
	{#if yTitle}<span class={styles.yTitle} aria-hidden="true">{yTitle}</span>{:else}<span
		></span>{/if}
	<div class={styles.yTicks} aria-hidden="true">
		{#each yTicks as tick (tick.value)}
			<span style:top="{tick.at * 100}%">{tick.label}</span>
		{/each}
	</div>
	<div class={styles.plot}>
		<svg
			class={styles.svg}
			viewBox="0 0 {width} {height}"
			preserveAspectRatio="none"
			role="img"
			aria-label="{label}. Exact values are in the data table."
		>
			{#each bands as band (band.key)}
				<rect class={styles.band} x={band.x} y={band.y} width={band.width} height={band.height} />
			{/each}
			{#each yTicks as tick (tick.value)}
				<line class={styles.grid} x1={0} x2={width} y1={tick.at * height} y2={tick.at * height} />
			{/each}
			{#each xTicks as tick (tick.value)}
				<line class={styles.grid} y1={0} y2={height} x1={tick.at * width} x2={tick.at * width} />
			{/each}
			{#each rules as rule (rule.key)}
				{#if rule.y !== undefined}
					<line class={styles.rule} x1={0} x2={width} y1={rule.y} y2={rule.y} />
				{:else}
					<line class={styles.rule} y1={0} y2={height} x1={rule.x} x2={rule.x} />
				{/if}
			{/each}
			{@render children()}
			<rect class={styles.frame} x={0} y={0} {width} {height} />
		</svg>
		{#if notes.length}
			<div class={styles.notes} aria-hidden="true">
				{#each notes as note (note.key)}
					<span
						data-kind={note.kind ?? 'point'}
						data-corner={note.corner}
						data-side={(note.kind ?? 'point') === 'point' && note.x > 0.5 ? 'left' : undefined}
						style:left="{note.x * 100}%"
						style:top="{note.y * 100}%">{note.text}</span
					>
				{/each}
			</div>
		{/if}
	</div>
	<div class={styles.xTicks} aria-hidden="true">
		{#each xTicks as tick (tick.value)}
			<span style:left="{tick.at * 100}%">{tick.label}</span>
		{/each}
	</div>
	{#if xTitle}<span class={styles.xTitle} aria-hidden="true">{xTitle}</span>{/if}
</div>

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

@layer primitive {
	/* y title | y ticks | plot, then x ticks and x title under the plot. */
	.root {
		display: grid;
		grid-template-columns: auto calc(var(--axis-ch, 3) * 1ch) minmax(0, 1fr);
		grid-template-rows: auto auto auto;
		column-gap: var(--space-3);
		font-family: var(--font-mono);
		font-size: var(--text-11);
		font-variant-numeric: tabular-nums;
		color: var(--chart-label, var(--ink-3));
	}
	.yTitle {
		grid-row: 1;
		grid-column: 1;
		align-self: center;
		font-family: var(--font-sans);
		writing-mode: vertical-rl;
		transform: rotate(180deg);
		white-space: nowrap;
	}
	.yTicks {
		position: relative;
		grid-row: 1;
		grid-column: 2;
	}
	.yTicks span {
		position: absolute;
		right: 0;
		line-height: 1;
		transform: translateY(-50%);
		white-space: nowrap;
	}
	.plot {
		position: relative;
		grid-row: 1;
		grid-column: 3;
		aspect-ratio: var(--aspect, 1.6);
	}
	.svg {
		position: absolute;
		inset: 0;
		width: 100%;
		height: 100%;
		overflow: visible;
	}
	.xTicks {
		position: relative;
		height: 1.4em;
		grid-row: 2;
		grid-column: 3;
		margin-top: var(--space-3);
	}
	.xTicks span {
		position: absolute;
		top: 0;
		line-height: 1.4;
		transform: translateX(-50%);
		white-space: nowrap;
	}
	.xTitle {
		grid-row: 3;
		grid-column: 3;
		margin-top: var(--space-2);
		font-family: var(--font-sans);
		text-align: center;
	}
	.grid {
		stroke: var(--chart-grid, var(--line));
		stroke-width: 1;
		vector-effect: non-scaling-stroke;
	}
	.frame {
		fill: none;
		stroke: var(--chart-axis, var(--line-strong));
		stroke-width: 1;
		vector-effect: non-scaling-stroke;
	}
	/* A value to compare against: dashed, darker than grid, never a mark. */
	.rule {
		stroke: var(--chart-axis, var(--line-strong));
		stroke-dasharray: 4 3;
		stroke-width: 1;
		vector-effect: non-scaling-stroke;
	}
	.band {
		fill: color-mix(in oklab, var(--accent) 9%, transparent);
	}
	/* Labels over the plot, placed by percentage: named points, rule notes. */
	.notes {
		position: absolute;
		inset: 0;
		pointer-events: none;
	}
	.notes span {
		position: absolute;
		padding: 0 var(--space-2);
		color: var(--chart-ink, var(--ink));
		font-size: var(--text-11);
		line-height: 1.2;
		white-space: nowrap;
		transform: translate(4px, -50%);
	}
	.notes span[data-side='left'] {
		transform: translate(calc(-100% - 4px), -50%);
	}
	.notes span[data-corner] {
		color: var(--chart-label, var(--ink-3));
		font-family: var(--font-sans);
	}
	.notes span[data-corner='tl'] {
		transform: translate(4px, 4px);
	}
	.notes span[data-corner='tr'] {
		transform: translate(calc(-100% - 4px), 4px);
	}
	.notes span[data-corner='bl'] {
		transform: translate(4px, calc(-100% - 4px));
	}
	.notes span[data-corner='br'] {
		transform: translate(calc(-100% - 4px), calc(-100% - 4px));
	}
	.notes span[data-kind='rule'] {
		color: var(--chart-label, var(--ink-3));
		transform: translate(-100%, -120%);
	}
}

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

import { colorVar, radiusFor, type ChartColor, type MarkShape } from './encode';
import { numericAxis, plotScales } from './plot';
import { isValue } from './scale';

/* Volcano and bubble plots: points by id, tweened by id. */

export type VolcanoPoint = {
	id: string;
	/** Effect: a lift, a fold change — signed. */
	x: number;
	/** Significance, already transformed by the caller (e.g. −log₁₀ p). The
	 *  chart never transforms data: an axis label would be the only record. */
	y: number;
	label?: string;
};

export type VolcanoState = 'up' | 'down' | 'quiet';

export const validPoint = (p: { x: number; y: number }) => isValue(p.x) && isValue(p.y);

/** Three states, and "not significant" is one of them: a measured result. */
export function volcanoState(
	p: { x: number; y: number },
	effectCut: number,
	significanceCut: number
): VolcanoState {
	if (p.y < significanceCut) return 'quiet';
	return p.x >= effectCut ? 'up' : p.x <= -effectCut ? 'down' : 'quiet';
}

export function volcanoTarget(
	points: readonly VolcanoPoint[],
	effectCut: number,
	significanceCut: number
) {
	const valid = points.filter(validPoint);
	const x = numericAxis(
		valid.map((p) => p.x),
		{ extra: [effectCut, -effectCut] }
	);
	const y = numericAxis(
		valid.map((p) => p.y),
		{
			zero: true,
			extra: [significanceCut]
		}
	);
	const target: Record<string, number> = {
		__x0: x.domain[0],
		__x1: x.domain[1],
		__y0: y.domain[0],
		__y1: y.domain[1]
	};
	for (const p of valid) {
		target[`${p.id}|x`] = p.x;
		target[`${p.id}|y`] = p.y;
	}
	return { target, xStep: x.step, yStep: y.step, valid };
}

export type Bubble = {
	id: string;
	x: number;
	y: number;
	/** Drawn as area, not radius. */
	weight: number;
	category?: string;
	label?: string;
};

export const validBubble = (b: Bubble) => validPoint(b) && isValue(b.weight) && b.weight >= 0;

export function bubbleTarget(bubbles: readonly Bubble[]) {
	const valid = bubbles.filter(validBubble);
	const x = numericAxis(valid.map((b) => b.x));
	const y = numericAxis(valid.map((b) => b.y));
	const weights = valid.map((b) => b.weight);
	const target: Record<string, number> = {
		__x0: x.domain[0],
		__x1: x.domain[1],
		__y0: y.domain[0],
		__y1: y.domain[1],
		__w0: weights.length ? Math.min(...weights) : 0,
		__w1: weights.length ? Math.max(...weights) : 1
	};
	for (const b of valid) {
		target[`${b.id}|x`] = b.x;
		target[`${b.id}|y`] = b.y;
		target[`${b.id}|w`] = b.weight;
	}
	return { target, xStep: x.step, yStep: y.step, valid };
}

/** A category's colour, by its position in a FIXED order: assigning hue by
 *  first appearance would repaint survivors when a filter changes. */
export function categoryColor(order: readonly string[], category?: string) {
	const index = order.indexOf(category ?? '');
	return colorVar(index >= 0 && index < 4 ? ((index + 1) as ChartColor) : 'neutral');
}

/** Scales for the tweened record `shown`. */
export function scatterScales(
	shown: Readonly<Record<string, number>>,
	xStep: number,
	yStep: number,
	aspect: number,
	format?: { x?: (v: number) => string; y?: (v: number) => string }
) {
	return plotScales(
		{ domain: [shown.__x0, shown.__x1], step: xStep },
		{ domain: [shown.__y0, shown.__y1], step: yStep },
		aspect,
		format
	);
}

/** A bubble's radius in plot units, by area. */
export const bubbleRadius = (shown: Readonly<Record<string, number>>, weight: number) =>
	radiusFor(weight, [shown.__w0, shown.__w1]);

export type ScatterPoint = {
	id: string;
	x: number;
	y: number;
	/** Which series it belongs to; sets colour AND shape. */
	series?: string;
	label?: string;
};

export function scatterTarget(
	points: readonly ScatterPoint[],
	extra: { x?: readonly number[]; y?: readonly number[] } = {}
) {
	const valid = points.filter(validPoint);
	const x = numericAxis(
		valid.map((p) => p.x),
		{ extra: extra.x }
	);
	const y = numericAxis(
		valid.map((p) => p.y),
		{ extra: extra.y }
	);
	const target: Record<string, number> = {
		__x0: x.domain[0],
		__x1: x.domain[1],
		__y0: y.domain[0],
		__y1: y.domain[1]
	};
	for (const p of valid) {
		target[`${p.id}|x`] = p.x;
		target[`${p.id}|y`] = p.y;
	}
	return { target, xStep: x.step, yStep: y.step, valid };
}

const SHAPES: readonly MarkShape[] = ['circle', 'square', 'diamond', 'triangle'];

/** A series' shape, by its place in the fixed order: identity is never
 *  colour alone. */
export function seriesShape(order: readonly string[], series?: string): MarkShape {
	const index = order.indexOf(series ?? '');
	return SHAPES[index >= 0 && index < SHAPES.length ? index : 0];
}

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

import { formatTick } from './format';
import { linear, niceDomain, PLOT, px, ticksBy } from './scale';

/* Two numeric axes over a plot of fixed aspect ratio: 0–1000 wide and
   1000 / aspect tall, so the SVG scales uniformly and circles stay round. */

export type AxisTick = { value: number; at: number; label: string };

export type NumericAxis = {
	domain: readonly [number, number];
	step: number;
};

/** A nice domain around `values` (and `extra`, e.g. a threshold), padded by
 *  a step so no mark sits on the frame. */
export function numericAxis(
	values: readonly number[],
	{ zero = false, extra = [] as readonly number[], pad = true } = {}
): NumericAxis {
	const all = [...values, ...extra];
	const { domain, step } = niceDomain(all, { zero });
	if (!pad || !all.length) return { domain, step };
	const lo = Math.min(...all);
	const hi = Math.max(...all);
	return {
		domain: [
			lo <= domain[0] + step * 0.05 && !(zero && domain[0] === 0) ? domain[0] - step : domain[0],
			hi >= domain[1] - step * 0.05 ? domain[1] + step : domain[1]
		],
		step
	};
}

/** Scales into the plot's coordinates, and the ticks as fractions (0–1) of
 *  the plot's width and height, for HTML labels. */
export function plotScales(
	x: NumericAxis,
	y: NumericAxis,
	aspect: number,
	format: { x?: (v: number) => string; y?: (v: number) => string } = {}
) {
	const height = PLOT / aspect;
	const sx = linear(x.domain, [0, PLOT]);
	const sy = linear(y.domain, [height, 0]);
	return {
		width: PLOT,
		height,
		x: (v: number) => px(sx(v)),
		y: (v: number) => px(sy(v)),
		xTicks: ticksBy(x.domain, x.step).map((value) => ({
			value,
			at: sx(value) / PLOT,
			label: (format.x ?? formatTick)(value)
		})) as AxisTick[],
		yTicks: ticksBy(y.domain, y.step).map((value) => ({
			value,
			at: sy(value) / height,
			label: (format.y ?? formatTick)(value)
		})) as AxisTick[]
	};
}

/** Nudge labels (positions as 0–1 fractions of the plot) apart vertically so
 *  none overlap: greedy, top to bottom. `width` is each label's estimated
 *  width as a fraction of the plot. */
export function spreadLabels<T extends { x: number; y: number; text: string }>(
	labels: readonly T[],
	{ gap = 0.05, charWidth = 0.011 } = {}
): T[] {
	const placed: T[] = [];
	for (const label of [...labels].sort((a, b) => a.y - b.y)) {
		let y = label.y;
		for (const other of placed) {
			const width = Math.max(label.text.length, other.text.length) * charWidth;
			if (Math.abs(other.x - label.x) < width && Math.abs(other.y - y) < gap) y = other.y + gap;
		}
		placed.push({ ...label, y });
	}
	return placed;
}

ScatterPlot

series by colour and shape · quadrants

Features

Adoption against satisfaction. Series differ by shape as well as colour.

  • Core
  • Growth
  • Admin
View data for Feature adoption against satisfaction
Feature adoption against satisfaction
Point Series Adoption (% of workspaces) Satisfaction (1–5)
Projects Core 92 4.4
Deploys Core 81 4.1
Members Core 74 3.2
Audit log Admin 22 4.5
SSO Admin 31 3.9
Roles Admin 44 2.6
Usage alerts Growth 38 4.2
Referrals Growth 12 2.4
Templates Growth 57 3.7
Webhooks Core 29 3.1
Insights Growth 66 2.9

Shape as well as colour. Each series has its own shape, so the plot still reads in greyscale. Quadrant lines are reference lines; one quadrant can be shaded to say where to look.

Source src/lib/components/charts/scatter-plot/doc.ts · src/lib/components/charts/scatter-plot/ScatterPlot.svelte · src/lib/components/charts/_kernel/scatter.ts

src/lib/components/charts/scatter-plot/doc.ts

/**
 * ScatterPlot — two measures, one point each.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label         REQUIRED
 *     points        { id, x, y, series?, label? }[]
 *     series?       the series in a FIXED order
 *     xTitle?, yTitle?
 *     quadrants?    { x, y, labels?: [top-left, top-right, bottom-left,
 *                   bottom-right], highlight?: which corner to shade }
 *     labelled?, aspect?, formatValue?, animation?  (default entrance: pop)
 *
 * # Behaviour
 *
 * R1  Each series has a colour AND a shape (circle, square, diamond,
 *     triangle), by its place in the fixed order.
 * R2  Quadrant lines are reference lines; the split values are always inside
 *     the axes. Corner labels sit inside their corner.
 * R3  A point with a non-finite x or y is not drawn; the table lists it.
 * R4  Named points are labelled, nudged apart so labels never overlap.
 */
export {};

src/lib/components/charts/scatter-plot/ScatterPlot.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 { shapePath } from '../_kernel/encode';
	import { formatExact } from '../_kernel/format';
	import { spreadLabels } from '../_kernel/plot';
	import {
		categoryColor,
		scatterScales,
		scatterTarget,
		seriesShape,
		type ScatterPoint
	} from '../_kernel/scatter';
	import ChartData from '../_shared/ChartData.svelte';
	import chart from '../_shared/chart.module.css';
	import PlotFrame, {
		type PlotBand,
		type PlotNote,
		type PlotRule
	} from '../plot-frame/PlotFrame.svelte';

	type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';

	/* Two measures, one point each. Series differ by shape as well as
	   colour, so the chart survives greyscale and colour-vision differences. */
	let {
		label,
		points,
		series,
		xTitle = 'X',
		yTitle = 'Y',
		quadrants,
		labelled = [],
		aspect = 1.6,
		formatValue = formatExact,
		animation,
		class: className = ''
	}: {
		/** Names the chart. */
		label: string;
		points: readonly ScatterPoint[];
		/** The series in a FIXED order: it sets each one's colour and shape. */
		series?: readonly string[];
		xTitle?: string;
		yTitle?: string;
		/** Split the plot at x and y into four, with optional corner labels
		 *  (top-left, top-right, bottom-left, bottom-right) and one shaded. */
		quadrants?: {
			x: number;
			y: number;
			labels?: readonly [string, string, string, string];
			highlight?: Corner;
		};
		/** Ids to name beside their point. */
		labelled?: readonly string[];
		aspect?: number;
		formatValue?: (value: number) => string;
		/** Default: points pop in, when scrolled into view. */
		animation?: AnimationProp;
		class?: string;
	} = $props();

	const motion = chartMotion(() => animation, { enter: 'pop', axis: 'y' });
	const model = $derived(
		scatterTarget(points, {
			x: quadrants ? [quadrants.x] : [],
			y: quadrants ? [quadrants.y] : []
		})
	);
	const shown = new Tweened(
		() => model.target,
		() => motion.update
	);
	const plot = $derived(scatterScales(shown.current, model.xStep, model.yStep, aspect));
	const order = $derived(series ?? [...new Set(points.map((p) => p.series ?? ''))].filter(Boolean));
	const named = $derived(new Set(labelled));

	const frame = $derived.by(() => {
		const rules: PlotRule[] = [];
		const bands: PlotBand[] = [];
		const notes: PlotNote[] = [];
		if (quadrants) {
			const qx = plot.x(quadrants.x);
			const qy = plot.y(quadrants.y);
			rules.push({ key: 'qx', x: qx }, { key: 'qy', y: qy });
			const corners: Record<Corner, PlotBand> = {
				'top-left': { key: 'q', x: 0, y: 0, width: qx, height: qy },
				'top-right': { key: 'q', x: qx, y: 0, width: plot.width - qx, height: qy },
				'bottom-left': { key: 'q', x: 0, y: qy, width: qx, height: plot.height - qy },
				'bottom-right': {
					key: 'q',
					x: qx,
					y: qy,
					width: plot.width - qx,
					height: plot.height - qy
				}
			};
			if (quadrants.highlight) bands.push(corners[quadrants.highlight]);
			quadrants.labels?.forEach((text, index) => {
				const corner = (['tl', 'tr', 'bl', 'br'] as const)[index];
				notes.push({
					key: `q${index}`,
					text,
					corner,
					x: corner.endsWith('l') ? 0 : 1,
					y: corner.startsWith('t') ? 0 : 1
				});
			});
		}
		notes.push(
			...spreadLabels(
				model.valid
					.filter((p) => named.has(p.id) && shown.current[`${p.id}|x`] !== undefined)
					.map((p) => ({
						key: p.id,
						x: plot.x(shown.current[`${p.id}|x`]) / plot.width,
						y: plot.y(shown.current[`${p.id}|y`]) / plot.height,
						text: p.label ?? p.id
					}))
			)
		);
		return { rules, bands, notes };
	});
</script>

<div {...motion.pending} {@attach motion.attach} class={cn(chart.root, className)}>
	{#if order.length > 1}
		<ChartLegend
			items={order.map((name) => ({
				key: name,
				label: name,
				color: categoryColor(order, name),
				shape: seriesShape(order, name)
			}))}
		/>
	{/if}
	{#if points.length === 0}
		<p class={chart.empty}>No data to display.</p>
	{:else if model.valid.length === 0}
		<p class={chart.empty}>Measurements unavailable.</p>
	{:else}
		<PlotFrame
			{label}
			{aspect}
			xTicks={plot.xTicks}
			yTicks={plot.yTicks}
			{xTitle}
			{yTitle}
			rules={frame.rules}
			bands={frame.bands}
			notes={frame.notes}
		>
			{#each model.valid as p (p.id)}
				{@const x = shown.current[`${p.id}|x`]}
				{@const y = shown.current[`${p.id}|y`]}
				{#if x !== undefined && y !== undefined}
					<!-- Positioned by the group, so an entrance can scale the shape
					     about its own centre without losing its place. -->
					<g transform="translate({plot.x(x)} {plot.y(y)})">
						<path
							data-mark
							class={chart.shape}
							style:--series={categoryColor(order, p.series)}
							d={shapePath(seriesShape(order, p.series), 6)}
						/>
					</g>
				{/if}
			{/each}
		</PlotFrame>
	{/if}
	{#if points.length}
		<ChartData {label}>
			<THead>
				<Tr>
					<Th>Point</Th>
					{#if order.length}<Th>Series</Th>{/if}
					<Th numeric>{xTitle}</Th>
					<Th numeric>{yTitle}</Th>
				</Tr>
			</THead>
			<TBody>
				{#each points as p (p.id)}
					<Tr>
						<Th scope="row">{p.label ?? p.id}</Th>
						{#if order.length}<Td>{p.series ?? '—'}</Td>{/if}
						<Td numeric>{Number.isFinite(p.x) ? formatValue(p.x) : 'Unavailable'}</Td>
						<Td numeric>{Number.isFinite(p.y) ? formatValue(p.y) : 'Unavailable'}</Td>
					</Tr>
				{/each}
			</TBody>
		</ChartData>
	{/if}
</div>

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

import { colorVar, radiusFor, type ChartColor, type MarkShape } from './encode';
import { numericAxis, plotScales } from './plot';
import { isValue } from './scale';

/* Volcano and bubble plots: points by id, tweened by id. */

export type VolcanoPoint = {
	id: string;
	/** Effect: a lift, a fold change — signed. */
	x: number;
	/** Significance, already transformed by the caller (e.g. −log₁₀ p). The
	 *  chart never transforms data: an axis label would be the only record. */
	y: number;
	label?: string;
};

export type VolcanoState = 'up' | 'down' | 'quiet';

export const validPoint = (p: { x: number; y: number }) => isValue(p.x) && isValue(p.y);

/** Three states, and "not significant" is one of them: a measured result. */
export function volcanoState(
	p: { x: number; y: number },
	effectCut: number,
	significanceCut: number
): VolcanoState {
	if (p.y < significanceCut) return 'quiet';
	return p.x >= effectCut ? 'up' : p.x <= -effectCut ? 'down' : 'quiet';
}

export function volcanoTarget(
	points: readonly VolcanoPoint[],
	effectCut: number,
	significanceCut: number
) {
	const valid = points.filter(validPoint);
	const x = numericAxis(
		valid.map((p) => p.x),
		{ extra: [effectCut, -effectCut] }
	);
	const y = numericAxis(
		valid.map((p) => p.y),
		{
			zero: true,
			extra: [significanceCut]
		}
	);
	const target: Record<string, number> = {
		__x0: x.domain[0],
		__x1: x.domain[1],
		__y0: y.domain[0],
		__y1: y.domain[1]
	};
	for (const p of valid) {
		target[`${p.id}|x`] = p.x;
		target[`${p.id}|y`] = p.y;
	}
	return { target, xStep: x.step, yStep: y.step, valid };
}

export type Bubble = {
	id: string;
	x: number;
	y: number;
	/** Drawn as area, not radius. */
	weight: number;
	category?: string;
	label?: string;
};

export const validBubble = (b: Bubble) => validPoint(b) && isValue(b.weight) && b.weight >= 0;

export function bubbleTarget(bubbles: readonly Bubble[]) {
	const valid = bubbles.filter(validBubble);
	const x = numericAxis(valid.map((b) => b.x));
	const y = numericAxis(valid.map((b) => b.y));
	const weights = valid.map((b) => b.weight);
	const target: Record<string, number> = {
		__x0: x.domain[0],
		__x1: x.domain[1],
		__y0: y.domain[0],
		__y1: y.domain[1],
		__w0: weights.length ? Math.min(...weights) : 0,
		__w1: weights.length ? Math.max(...weights) : 1
	};
	for (const b of valid) {
		target[`${b.id}|x`] = b.x;
		target[`${b.id}|y`] = b.y;
		target[`${b.id}|w`] = b.weight;
	}
	return { target, xStep: x.step, yStep: y.step, valid };
}

/** A category's colour, by its position in a FIXED order: assigning hue by
 *  first appearance would repaint survivors when a filter changes. */
export function categoryColor(order: readonly string[], category?: string) {
	const index = order.indexOf(category ?? '');
	return colorVar(index >= 0 && index < 4 ? ((index + 1) as ChartColor) : 'neutral');
}

/** Scales for the tweened record `shown`. */
export function scatterScales(
	shown: Readonly<Record<string, number>>,
	xStep: number,
	yStep: number,
	aspect: number,
	format?: { x?: (v: number) => string; y?: (v: number) => string }
) {
	return plotScales(
		{ domain: [shown.__x0, shown.__x1], step: xStep },
		{ domain: [shown.__y0, shown.__y1], step: yStep },
		aspect,
		format
	);
}

/** A bubble's radius in plot units, by area. */
export const bubbleRadius = (shown: Readonly<Record<string, number>>, weight: number) =>
	radiusFor(weight, [shown.__w0, shown.__w1]);

export type ScatterPoint = {
	id: string;
	x: number;
	y: number;
	/** Which series it belongs to; sets colour AND shape. */
	series?: string;
	label?: string;
};

export function scatterTarget(
	points: readonly ScatterPoint[],
	extra: { x?: readonly number[]; y?: readonly number[] } = {}
) {
	const valid = points.filter(validPoint);
	const x = numericAxis(
		valid.map((p) => p.x),
		{ extra: extra.x }
	);
	const y = numericAxis(
		valid.map((p) => p.y),
		{ extra: extra.y }
	);
	const target: Record<string, number> = {
		__x0: x.domain[0],
		__x1: x.domain[1],
		__y0: y.domain[0],
		__y1: y.domain[1]
	};
	for (const p of valid) {
		target[`${p.id}|x`] = p.x;
		target[`${p.id}|y`] = p.y;
	}
	return { target, xStep: x.step, yStep: y.step, valid };
}

const SHAPES: readonly MarkShape[] = ['circle', 'square', 'diamond', 'triangle'];

/** A series' shape, by its place in the fixed order: identity is never
 *  colour alone. */
export function seriesShape(order: readonly string[], series?: string): MarkShape {
	const index = order.indexOf(series ?? '');
	return SHAPES[index >= 0 && index < SHAPES.length ? index : 0];
}

BubblePlot

a third measure as area · fixed category order

Accounts

Seats against growth; bubble area is MRR.

  • Starter
  • Team
  • Business
View data for Accounts by seats and growth, sized by MRR
Accounts by seats and growth, sized by MRR
ItemCategorySeatsGrowth (%)MRR
Account 1 Starter 3416.8246
Account 2 Team 78361,598
Account 3 Business 8816.84,464
Account 4 Starter 3013.8260
Account 5 Team 6825.21,569
Account 6 Business 93-4.33,422
Account 7 Starter 277.6192
Account 8 Team 5120.81,080
Account 9 Business 10835.76,375
Account 10 Starter 494.1360
Account 11 Team 58-11.61,400
Account 12 Business 1422.84,452
Account 13 Starter 485.8332
Account 14 Team 7835.71,043
Account 15 Business 13434.25,542
Account 16 Starter 5-10.443
Account 17 Team 7316.61,144
Account 18 Business 11329.13,270
Account 19 Starter 42-12.3263
Account 20 Team 8144.11,499
Account 21 Business 1116.23,022
Account 22 Starter 4710.2383
Account 23 Team 6118.31,041
Account 24 Business 134-5.44,044
Account 25 Starter 4122.4178
Account 26 Team 657.71,799
Account 27 Business 13422.85,689
Account 28 Starter 5820.2360

Area, not radius. A radius proportional to the value is read as its square, overstating the large ones.

Colour by a fixed order. Categories take hues by the order given, not the order seen, so filtering never repaints the survivors.

Source src/lib/components/charts/bubble-plot/doc.ts · src/lib/components/charts/bubble-plot/BubblePlot.svelte · src/lib/components/charts/_kernel/scatter.ts · src/lib/components/charts/_kernel/encode.ts

src/lib/components/charts/bubble-plot/doc.ts

/**
 * BubblePlot — two measures by position and a third by area.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label         REQUIRED
 *     bubbles       { id, x, y, weight, category?, label? }[]
 *     categories?   the categories in a FIXED order
 *     xTitle?, yTitle?, weightTitle?, aspect?, formatValue?, animation?
 *
 * # Behaviour
 *
 * R1  Weight is drawn as area, not radius.
 * R2  Colour comes from each category's place in `categories`: four hues,
 *     then neutral. Hue never depends on which categories are present.
 * R3  Larger bubbles are drawn first, so smaller ones stay visible on top;
 *     each has a ring of the surface colour so overlaps read as separate.
 * R4  A bubble with a non-finite position or a negative or non-finite weight
 *     is not drawn; the table lists it as unavailable.
 * R5  With more than one category, a legend names them.
 */
export {};

src/lib/components/charts/bubble-plot/BubblePlot.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 } from '../_kernel/format';
	import {
		bubbleRadius,
		bubbleTarget,
		categoryColor,
		scatterScales,
		type Bubble
	} from '../_kernel/scatter';
	import ChartData from '../_shared/ChartData.svelte';
	import chart from '../_shared/chart.module.css';
	import PlotFrame from '../plot-frame/PlotFrame.svelte';

	/* Two measures by position and a third by AREA — not radius, which would
	   make the large ones read as their square. */
	let {
		label,
		bubbles,
		categories,
		xTitle = 'X',
		yTitle = 'Y',
		weightTitle = 'Size',
		aspect = 1.6,
		formatValue = formatExact,
		animation,
		class: className = ''
	}: {
		/** Names the chart. */
		label: string;
		bubbles: readonly Bubble[];
		/** The categories in a FIXED order, which fixes their colours. */
		categories?: readonly string[];
		xTitle?: string;
		yTitle?: string;
		/** What the bubble size is, for the table. */
		weightTitle?: string;
		aspect?: number;
		formatValue?: (value: number) => string;
		/** Default: bubbles pop in, when scrolled into view. */
		animation?: AnimationProp;
		class?: string;
	} = $props();

	const motion = chartMotion(() => animation, { enter: 'pop', axis: 'y' });
	const model = $derived(bubbleTarget(bubbles));
	const shown = new Tweened(
		() => model.target,
		() => motion.update
	);
	const plot = $derived(scatterScales(shown.current, model.xStep, model.yStep, aspect));
	const order = $derived(
		categories ?? [...new Set(bubbles.map((b) => b.category ?? ''))].filter(Boolean)
	);
	// Largest first, so small bubbles are drawn on top and stay visible.
	const drawn = $derived(
		[...model.valid].sort(
			(a, b) => (shown.current[`${b.id}|w`] ?? 0) - (shown.current[`${a.id}|w`] ?? 0)
		)
	);
</script>

<div {...motion.pending} {@attach motion.attach} class={cn(chart.root, className)}>
	{#if order.length > 1}
		<ChartLegend
			items={order.map((category) => ({
				key: category,
				label: category,
				color: categoryColor(order, category)
			}))}
		/>
	{/if}
	{#if bubbles.length === 0}
		<p class={chart.empty}>No data to display.</p>
	{:else if model.valid.length === 0}
		<p class={chart.empty}>Measurements unavailable.</p>
	{:else}
		<PlotFrame {label} {aspect} xTicks={plot.xTicks} yTicks={plot.yTicks} {xTitle} {yTitle}>
			{#each drawn as b (b.id)}
				{@const x = shown.current[`${b.id}|x`]}
				{@const y = shown.current[`${b.id}|y`]}
				{@const w = shown.current[`${b.id}|w`]}
				{#if x !== undefined && y !== undefined && w !== undefined}
					<circle
						data-mark
						class={chart.bubble}
						style:--series={categoryColor(order, b.category)}
						cx={plot.x(x)}
						cy={plot.y(y)}
						r={bubbleRadius(shown.current, w)}
					/>
				{/if}
			{/each}
		</PlotFrame>
	{/if}
	{#if bubbles.length}
		<ChartData {label}>
			<THead>
				<Tr>
					<Th>Item</Th><Th>Category</Th><Th numeric>{xTitle}</Th><Th numeric>{yTitle}</Th><Th
						numeric>{weightTitle}</Th
					>
				</Tr>
			</THead>
			<TBody>
				{#each bubbles as b (b.id)}
					<Tr>
						<Th scope="row">{b.label ?? b.id}</Th>
						<Td>{b.category ?? '—'}</Td>
						{#each [b.x, b.y, b.weight] as value, index (index)}
							<Td numeric
								>{Number.isFinite(value) && (index < 2 || value >= 0)
									? formatValue(value)
									: 'Unavailable'}</Td
							>
						{/each}
					</Tr>
				{/each}
			</TBody>
		</ChartData>
	{/if}
</div>

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

import { colorVar, radiusFor, type ChartColor, type MarkShape } from './encode';
import { numericAxis, plotScales } from './plot';
import { isValue } from './scale';

/* Volcano and bubble plots: points by id, tweened by id. */

export type VolcanoPoint = {
	id: string;
	/** Effect: a lift, a fold change — signed. */
	x: number;
	/** Significance, already transformed by the caller (e.g. −log₁₀ p). The
	 *  chart never transforms data: an axis label would be the only record. */
	y: number;
	label?: string;
};

export type VolcanoState = 'up' | 'down' | 'quiet';

export const validPoint = (p: { x: number; y: number }) => isValue(p.x) && isValue(p.y);

/** Three states, and "not significant" is one of them: a measured result. */
export function volcanoState(
	p: { x: number; y: number },
	effectCut: number,
	significanceCut: number
): VolcanoState {
	if (p.y < significanceCut) return 'quiet';
	return p.x >= effectCut ? 'up' : p.x <= -effectCut ? 'down' : 'quiet';
}

export function volcanoTarget(
	points: readonly VolcanoPoint[],
	effectCut: number,
	significanceCut: number
) {
	const valid = points.filter(validPoint);
	const x = numericAxis(
		valid.map((p) => p.x),
		{ extra: [effectCut, -effectCut] }
	);
	const y = numericAxis(
		valid.map((p) => p.y),
		{
			zero: true,
			extra: [significanceCut]
		}
	);
	const target: Record<string, number> = {
		__x0: x.domain[0],
		__x1: x.domain[1],
		__y0: y.domain[0],
		__y1: y.domain[1]
	};
	for (const p of valid) {
		target[`${p.id}|x`] = p.x;
		target[`${p.id}|y`] = p.y;
	}
	return { target, xStep: x.step, yStep: y.step, valid };
}

export type Bubble = {
	id: string;
	x: number;
	y: number;
	/** Drawn as area, not radius. */
	weight: number;
	category?: string;
	label?: string;
};

export const validBubble = (b: Bubble) => validPoint(b) && isValue(b.weight) && b.weight >= 0;

export function bubbleTarget(bubbles: readonly Bubble[]) {
	const valid = bubbles.filter(validBubble);
	const x = numericAxis(valid.map((b) => b.x));
	const y = numericAxis(valid.map((b) => b.y));
	const weights = valid.map((b) => b.weight);
	const target: Record<string, number> = {
		__x0: x.domain[0],
		__x1: x.domain[1],
		__y0: y.domain[0],
		__y1: y.domain[1],
		__w0: weights.length ? Math.min(...weights) : 0,
		__w1: weights.length ? Math.max(...weights) : 1
	};
	for (const b of valid) {
		target[`${b.id}|x`] = b.x;
		target[`${b.id}|y`] = b.y;
		target[`${b.id}|w`] = b.weight;
	}
	return { target, xStep: x.step, yStep: y.step, valid };
}

/** A category's colour, by its position in a FIXED order: assigning hue by
 *  first appearance would repaint survivors when a filter changes. */
export function categoryColor(order: readonly string[], category?: string) {
	const index = order.indexOf(category ?? '');
	return colorVar(index >= 0 && index < 4 ? ((index + 1) as ChartColor) : 'neutral');
}

/** Scales for the tweened record `shown`. */
export function scatterScales(
	shown: Readonly<Record<string, number>>,
	xStep: number,
	yStep: number,
	aspect: number,
	format?: { x?: (v: number) => string; y?: (v: number) => string }
) {
	return plotScales(
		{ domain: [shown.__x0, shown.__x1], step: xStep },
		{ domain: [shown.__y0, shown.__y1], step: yStep },
		aspect,
		format
	);
}

/** A bubble's radius in plot units, by area. */
export const bubbleRadius = (shown: Readonly<Record<string, number>>, weight: number) =>
	radiusFor(weight, [shown.__w0, shown.__w1]);

export type ScatterPoint = {
	id: string;
	x: number;
	y: number;
	/** Which series it belongs to; sets colour AND shape. */
	series?: string;
	label?: string;
};

export function scatterTarget(
	points: readonly ScatterPoint[],
	extra: { x?: readonly number[]; y?: readonly number[] } = {}
) {
	const valid = points.filter(validPoint);
	const x = numericAxis(
		valid.map((p) => p.x),
		{ extra: extra.x }
	);
	const y = numericAxis(
		valid.map((p) => p.y),
		{ extra: extra.y }
	);
	const target: Record<string, number> = {
		__x0: x.domain[0],
		__x1: x.domain[1],
		__y0: y.domain[0],
		__y1: y.domain[1]
	};
	for (const p of valid) {
		target[`${p.id}|x`] = p.x;
		target[`${p.id}|y`] = p.y;
	}
	return { target, xStep: x.step, yStep: y.step, valid };
}

const SHAPES: readonly MarkShape[] = ['circle', 'square', 'diamond', 'triangle'];

/** A series' shape, by its place in the fixed order: identity is never
 *  colour alone. */
export function seriesShape(order: readonly string[], series?: string): MarkShape {
	const index = order.indexOf(series ?? '');
	return SHAPES[index >= 0 && index < SHAPES.length ? index : 0];
}

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

/* Colour and line style: how a series is told apart. */

/** Four categorical slots, then neutral. The palette never cycles: a fifth
 *  series taking the first hue would make two series look like one, and
 *  every legend a lie the moment a filter changes the count. */
export type ChartColor = 1 | 2 | 3 | 4 | 'neutral';

export type LineStyle = 'solid' | 'dashed' | 'dotted';

/** A named series. Colour defaults to its position; line style is the channel
 *  that survives greyscale and colour-vision differences, so use it when
 *  series must be told apart without colour. */
export type ChartSeries = {
	key: string;
	label: string;
	color?: ChartColor;
	line?: LineStyle;
};

export function colorVar(color: ChartColor) {
	return color === 'neutral' ? 'var(--chart-neutral)' : `var(--chart-${color})`;
}

/** A series' colour: its own, else its slot, else neutral past the fourth. */
export function seriesColor(series: ChartSeries, index: number) {
	return colorVar(series.color ?? (index < 4 ? ((index + 1) as ChartColor) : 'neutral'));
}

/** Shape carries a class, and survives greyscale and colour-vision
 *  differences. Hue and shape are separate choices. */
export type MarkShape = 'circle' | 'square' | 'diamond' | 'triangle';

/** An SVG path for a shape of radius `r`, centred on the origin. */
export function shapePath(shape: MarkShape, r: number) {
	switch (shape) {
		case 'circle':
			return `M ${-r} 0 a ${r} ${r} 0 1 0 ${r * 2} 0 a ${r} ${r} 0 1 0 ${-r * 2} 0`;
		case 'square':
			return `M ${-r} ${-r} H ${r} V ${r} H ${-r} Z`;
		case 'diamond':
			return `M 0 ${-r * 1.25} L ${r * 1.25} 0 L 0 ${r * 1.25} L ${-r * 1.25} 0 Z`;
		case 'triangle':
			return `M 0 ${-r * 1.2} L ${r * 1.1} ${r * 0.8} L ${-r * 1.1} ${r * 0.8} Z`;
	}
}

/** A radius whose AREA is proportional to the value. A radius proportional
 *  to value is read as its square, which overstates the large ones. */
export function radiusFor(
	value: number,
	[d0, d1]: readonly [number, number],
	[r0, r1]: readonly [number, number] = [6, 34]
) {
	const span = d1 - d0;
	const t = span === 0 ? 0.5 : Math.max(0, Math.min(1, (value - d0) / span));
	return Math.sqrt(r0 * r0 + t * (r1 * r1 - r0 * r0));
}

/** A single-hue ramp for magnitude with no sign; t in 0–1. */
export function sequentialFill(t: number) {
	const c = Math.max(0, Math.min(1, t));
	return `color-mix(in oklab, var(--chart-seq) ${Math.round(12 + c * 88)}%, var(--chart-absent))`;
}

/** Two hues about a genuinely neutral midpoint, never a rainbow; t in −1–1. */
export function divergingFill(t: number) {
	const c = Math.max(-1, Math.min(1, t));
	return c >= 0
		? `color-mix(in oklab, var(--chart-pos) ${Math.round(c * 100)}%, var(--chart-mid))`
		: `color-mix(in oklab, var(--chart-neg) ${Math.round(-c * 100)}%, var(--chart-mid))`;
}

BoxPlot

quartiles · whiskers · outliers

Response times by region

Each box is the middle half of 60 requests; the line is the median.

  • API
  • Web
Use the left and right arrow keys to read each position.
View data for Response times by region, milliseconds
Response times by region, milliseconds
Group Series nMinQ1MedianQ3MaxOutliers
us-east API 60 82 ms109 ms122 ms132 ms154 ms 191 ms, 204 ms, 229 ms, 243 ms, 271 ms
us-east Web 60 167 ms195 ms210 ms229 ms255 ms 323 ms, 326 ms, 457 ms
eu-west API 60 116 ms142 ms155 ms171 ms215 ms 262 ms, 312 ms
eu-west Web 60 186 ms234 ms252 ms270 ms311 ms —
ap-south API 60 143 ms174 ms187 ms209 ms240 ms 284 ms, 354 ms
ap-south Web 60 220 ms276 ms296 ms319 ms355 ms 484 ms, 494 ms, 494 ms, 540 ms, 555 ms
sa-east API 60 163 ms206 ms227 ms242 ms296 ms 428 ms, 464 ms
sa-east Web 60 258 ms323 ms344 ms368 ms427 ms 243 ms, 588 ms, 628 ms

Spread, not size. The axis does not start at zero. Whiskers reach the furthest values within 1.5 × the box; anything beyond is drawn as a dot, and every box's numbers are in the table.

Source src/lib/components/charts/box-plot/doc.ts · src/lib/components/charts/box-plot/BoxPlot.svelte · src/lib/components/charts/box-plot/box-plot.module.css · src/lib/components/charts/_kernel/box.ts

src/lib/components/charts/box-plot/doc.ts

/**
 * BoxPlot — distributions side by side.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label         REQUIRED
 *     data          { label, values: { [series key]: number[] | BoxStats } }[]
 *     series        one or more { key, label, color? }
 *     formatValue?, formatTick?, height?, animation?  (default: grow)
 *     inspect?     read groups by pointer or keyboard; default true
 *
 *     BoxStats      { min, q1, median, q3, max, outliers?, n? }
 *
 * # Behaviour
 *
 * R1  Raw values are summarised the same way everywhere: quartiles by
 *     linear interpolation, whiskers to the furthest values within 1.5 × the
 *     interquartile range, every value beyond drawn as an outlier.
 * R2  The axis covers the whiskers and outliers and does not start at zero:
 *     a box plot shows spread, not size.
 * R3  A group with no finite values draws nothing; the table says
 *     "Unavailable".
 * R4  The table has n, the five numbers, and the outliers for every box.
 * R5  Inspection (inspect, default on), as in LineChart: the card shows each
 *     series' median and middle half; the announcement adds the whiskers
 *     and how many outliers there are.
 */
export {};

src/lib/components/charts/box-plot/BoxPlot.svelte

<script lang="ts" module>
	import type { BoxStats } from '../_kernel/box';
	export type BoxGroup = {
		label: string;
		/** Per series: the raw values (summarised here) or the five numbers. */
		values: Readonly<Record<string, readonly number[] | BoxStats | null | undefined>>;
	};
</script>

<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 { boxDomain, statsOf } from '../_kernel/box';
	import { xLabels, yAxis } from '../_kernel/cartesian';
	import { seriesColor, type ChartSeries } from '../_kernel/encode';
	import { boxReadout } from '../_kernel/inspect';
	import { formatExact, formatTick } from '../_kernel/format';
	import { band, px } from '../_kernel/scale';
	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 SeriesLegend from '../_shared/SeriesLegend.svelte';
	import styles from './box-plot.module.css';

	/* Distributions side by side: the middle half as a box, the median as a
	   line, whiskers to the furthest values within 1.5 × the box, and every
	   value beyond drawn as an outlier. The axis shows spread, so it does not
	   start at zero. */
	let {
		label,
		data,
		series,
		formatValue = formatExact,
		formatTick: tickFormat = formatTick,
		height,
		animation,
		inspect = true,
		class: className = ''
	}: {
		/** Names the chart. */
		label: string;
		data: readonly BoxGroup[];
		series: readonly ChartSeries[];
		formatValue?: (value: number) => string;
		formatTick?: (value: number) => string;
		height?: string;
		/** Default: boxes grow from their middle, when scrolled into view. */
		animation?: AnimationProp;
		/** Read values position by position, by pointer or keyboard (default). */
		inspect?: boolean;
		class?: string;
	} = $props();

	const STATS = ['min', 'q1', 'median', 'q3', 'max'] as const;
	const motion = chartMotion(() => animation, { enter: 'grow', axis: 'y' });
	const stats = $derived(
		data.map((group) => series.map((entry) => statsOf(group.values[entry.key])))
	);
	const nice = $derived(boxDomain(stats.flat()));
	const shown = new Tweened(
		() => {
			const target: Record<string, number> = { __lo: nice.domain[0], __hi: nice.domain[1] };
			stats.forEach((row, g) =>
				row.forEach((s, k) => {
					if (s) for (const stat of STATS) target[`${g}|${k}|${stat}`] = s[stat];
				})
			);
			return target;
		},
		() => motion.update
	);
	const axis = $derived(yAxis([shown.current.__lo, shown.current.__hi], nice.step, tickFormat));
	const outer = $derived(band(data.length, 0.3));
	const inner = $derived(band(series.length, 0.18, outer.width));
	const measured = $derived(stats.flat().some(Boolean));
	const at = (g: number, k: number, stat: (typeof STATS)[number], s: BoxStats) =>
		px(axis.y(shown.current[`${g}|${k}|${stat}`] ?? s[stat]));
</script>

{#snippet inspector()}
	<ChartInspector
		{label}
		positions={data.map((_, index) => px(outer.centre(index)))}
		band={px(outer.step)}
		readout={(index) => boxReadout(data[index].label, series, stats[index], formatValue)}
	/>
{/snippet}

{#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)}>
		{#if series.length > 1}<SeriesLegend {series} lines={false} />{/if}
		{#if measured}
			<CartesianPlot
				{label}
				ticks={axis.ticks}
				{height}
				overlay={inspect ? inspector : undefined}
				xLabels={xLabels(
					data.map((group) => group.label),
					data.map((_, index) => outer.centre(index))
				)}
			>
				{#each stats as row, g (g)}
					{#each row as s, k (k)}
						{#if s}
							{@const x = outer.start(g) + inner.start(k)}
							{@const mid = px(x + inner.width / 2)}
							{@const cap = inner.width * 0.25}
							<g data-mark class={styles.box} style:--series={seriesColor(series[k], k)}>
								<line
									class={styles.whisker}
									x1={mid}
									x2={mid}
									y1={at(g, k, 'max', s)}
									y2={at(g, k, 'q3', s)}
								/>
								<line
									class={styles.whisker}
									x1={mid}
									x2={mid}
									y1={at(g, k, 'q1', s)}
									y2={at(g, k, 'min', s)}
								/>
								<line
									class={styles.whisker}
									x1={px(mid - cap)}
									x2={px(mid + cap)}
									y1={at(g, k, 'max', s)}
									y2={at(g, k, 'max', s)}
								/>
								<line
									class={styles.whisker}
									x1={px(mid - cap)}
									x2={px(mid + cap)}
									y1={at(g, k, 'min', s)}
									y2={at(g, k, 'min', s)}
								/>
								<rect
									class={styles.body}
									x={px(x)}
									width={px(inner.width)}
									y={at(g, k, 'q3', s)}
									height={px(Math.max(0, at(g, k, 'q1', s) - at(g, k, 'q3', s)))}
								/>
								<line
									class={styles.median}
									x1={px(x)}
									x2={px(x + inner.width)}
									y1={at(g, k, 'median', s)}
									y2={at(g, k, 'median', s)}
								/>
								{#each s.outliers ?? [] as value, i (i)}
									<path class={styles.outlier} d="M{mid},{px(axis.y(value))}h0" />
								{/each}
							</g>
						{/if}
					{/each}
				{/each}
			</CartesianPlot>
		{:else}
			<p class={chart.empty}>Measurements unavailable.</p>
		{/if}
		<ChartData {label}>
			<THead>
				<Tr>
					<Th>Group</Th>
					{#if series.length > 1}<Th>Series</Th>{/if}
					<Th numeric>n</Th><Th numeric>Min</Th><Th numeric>Q1</Th><Th numeric>Median</Th><Th
						numeric>Q3</Th
					><Th numeric>Max</Th><Th numeric>Outliers</Th>
				</Tr>
			</THead>
			<TBody>
				{#each stats as row, g (g)}
					{#each row as s, k (k)}
						<Tr>
							<Th scope="row">{data[g].label}</Th>
							{#if series.length > 1}<Td>{series[k].label}</Td>{/if}
							<Td numeric>{s?.n ?? '—'}</Td>
							{#each STATS as stat (stat)}
								<Td numeric>{s ? formatValue(s[stat]) : 'Unavailable'}</Td>
							{/each}
							<Td numeric>{s?.outliers?.length ? s.outliers.map(formatValue).join(', ') : '—'}</Td>
						</Tr>
					{/each}
				{/each}
			</TBody>
		</ChartData>
	</div>
{/if}

src/lib/components/charts/box-plot/box-plot.module.css

@layer primitive {
	/* A box scales about its own middle as it enters. */
	.box {
		transform-origin: 50% 50%;
	}
	.whisker {
		stroke: var(--series);
		stroke-width: 1.5;
		vector-effect: non-scaling-stroke;
	}
	.body {
		fill: color-mix(in oklab, var(--series) 22%, var(--surface-panel));
		stroke: var(--series);
		stroke-width: 1.5;
		vector-effect: non-scaling-stroke;
	}
	.median {
		stroke: var(--series);
		stroke-width: 3;
		vector-effect: non-scaling-stroke;
	}
	.outlier {
		fill: none;
		stroke: var(--series);
		stroke-linecap: round;
		stroke-width: 5;
		vector-effect: non-scaling-stroke;
	}
}

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

import { isValue, niceDomain } from './scale';

/* BoxPlot: a distribution as five numbers and its outliers. */

export type BoxStats = {
	min: number;
	q1: number;
	median: number;
	q3: number;
	max: number;
	/** Values beyond the whiskers. */
	outliers?: readonly number[];
	/** How many values it summarises, when known. */
	n?: number;
};

/** A quantile by linear interpolation (the common "type 7"). */
function quantile(sorted: readonly number[], p: number) {
	const i = (sorted.length - 1) * p;
	const lo = Math.floor(i);
	const hi = Math.ceil(i);
	return sorted[lo] + (sorted[hi] - sorted[lo]) * (i - lo);
}

/** Tukey's box: quartiles, whiskers to the furthest values within 1.5 × IQR
 *  of the box, and everything beyond as outliers. Null for no finite values. */
export function summarize(values: readonly number[]): BoxStats | null {
	const sorted = values.filter(isValue).toSorted((a, b) => a - b);
	if (!sorted.length) return null;
	const q1 = quantile(sorted, 0.25);
	const q3 = quantile(sorted, 0.75);
	const fence = 1.5 * (q3 - q1);
	const inside = sorted.filter((v) => v >= q1 - fence && v <= q3 + fence);
	return {
		min: inside[0],
		q1,
		median: quantile(sorted, 0.5),
		q3,
		max: inside[inside.length - 1],
		outliers: sorted.filter((v) => v < q1 - fence || v > q3 + fence),
		n: sorted.length
	};
}

export const statsOf = (input: readonly number[] | BoxStats | null | undefined) =>
	input == null ? null : Array.isArray(input) ? summarize(input) : (input as BoxStats);

/** The domain covering every whisker and outlier (not anchored at zero:
 *  a box plot shows spread, not size). */
export function boxDomain(all: readonly (BoxStats | null)[]) {
	const values = all.flatMap((s) => (s ? [s.min, s.max, ...(s.outliers ?? [])] : []));
	return niceDomain(values, { zero: false });
}

RankedBar

sorted by the chart · values printed · ranks slide

Errors by endpoint

Sorted by the chart. Switch days and each column slides to its new rank.

Use the left and right arrow keys to read each position.
View data for Errors by endpoint, today
Errors by endpoint, today
RankItemErrors
1 POST /v1/events 412
2 GET /v1/projects 280
3 POST /v1/deploys 266
4 GET /v1/usage 198
5 PATCH /v1/members 121
6 POST /v1/keys 96
7 GET /v1/audit 44
8 DELETE /v1/sessions 18
Source src/lib/components/charts/ranked-bar/doc.ts · src/lib/components/charts/ranked-bar/RankedBar.svelte · src/lib/components/charts/_kernel/ranked.ts

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

/**
 * RankedBar — columns sorted largest first, with their values printed.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label         REQUIRED
 *     data          { id, label, value: number | null }[]
 *     valueTitle?, color?, formatValue?, height?, animation?
 *     inspect?     read items by pointer or keyboard; default true
 *
 * # Behaviour
 *
 * R1  The chart sorts, largest first; ties keep the caller's order. The
 *     order is the finding, so it is not left to the caller.
 * R2  Columns measure from zero, always: a truncated axis exaggerates the
 *     difference by however much was cut off.
 * R3  Every value is printed above its column and every label is shown,
 *     angled, under it.
 * R4  Null, negative, or non-finite values are not ranked; the table lists
 *     them, unranked, after the ranked ones.
 * R5  With new data, each column slides to its new rank.
 * R6  Inspection (inspect, default on), as in LineChart: each item reads its
 *     value and its rank ("Rank 2 of 8").
 */
export {};

src/lib/components/charts/ranked-bar/RankedBar.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 { yAxis } from '../_kernel/cartesian';
	import { colorVar, type ChartColor } from '../_kernel/encode';
	import { rankedReadout } from '../_kernel/inspect';
	import { formatExact, formatTick } from '../_kernel/format';
	import { rank, rankedTarget, type RankedDatum } from '../_kernel/ranked';
	import { band, niceDomain, PLOT, px } from '../_kernel/scale';
	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';

	/* Columns sorted largest first, with their values printed: the order is
	   the finding, so ranking is the chart's job. New data slides each column
	   to its new rank. */
	let {
		label,
		data,
		valueTitle = 'Value',
		color = 1,
		formatValue = formatExact,
		height,
		animation,
		inspect = true,
		class: className = ''
	}: {
		/** Names the chart. */
		label: string;
		data: readonly RankedDatum[];
		/** What the values are, for the table. */
		valueTitle?: string;
		color?: ChartColor;
		formatValue?: (value: number) => string;
		height?: string;
		/** 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 ranked = $derived(rank(data));
	const nice = $derived(niceDomain(ranked.measured.map((item) => item.value)));
	const shown = new Tweened(
		() => ({ ...rankedTarget(ranked.measured), __hi: nice.domain[1] }),
		() => motion.update
	);
	const axis = $derived(yAxis([0, shown.current.__hi], nice.step, formatTick));
	const slots = $derived(band(Math.max(1, ranked.measured.length), 0.28));
	const bars = $derived(
		ranked.measured.flatMap((item) => {
			const value = shown.current[`${item.id}|v`];
			const index = shown.current[`${item.id}|i`];
			if (value === undefined || index === undefined) return [];
			return [{ item, x: slots.start(index), top: axis.y(value) }];
		})
	);
</script>

{#snippet inspector()}
	<ChartInspector
		{label}
		positions={ranked.measured.map((_, index) => px(slots.centre(index)))}
		band={px(slots.step)}
		readout={(index) =>
			rankedReadout(ranked.measured[index], index, ranked.measured.length, {
				title: valueTitle,
				color: colorVar(color),
				format: formatValue
			})}
	/>
{/snippet}

<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 if ranked.measured.length === 0}
		<p class={chart.empty}>Measurements unavailable.</p>
	{:else}
		<CartesianPlot
			{label}
			ticks={axis.ticks}
			{height}
			overlay={inspect ? inspector : undefined}
			angled
			xLabels={ranked.measured.map((item, index) => ({
				key: item.id,
				at: px(slots.centre(index)),
				label: item.label,
				align: 'middle' as const,
				tier: 2 as const
			}))}
			notes={bars.map((bar) => ({
				key: bar.item.id,
				x: (bar.x + slots.width / 2) / PLOT,
				y: bar.top / PLOT,
				text: formatValue(bar.item.value)
			}))}
		>
			{#each bars as bar (bar.item.id)}
				<rect
					data-mark
					class={chart.column}
					x={px(bar.x)}
					width={px(slots.width)}
					y={px(bar.top)}
					height={px(Math.max(0, axis.y(0) - bar.top))}
				/>
			{/each}
		</CartesianPlot>
	{/if}
	{#if data.length}
		<ChartData {label}>
			<THead>
				<Tr><Th>Rank</Th><Th>Item</Th><Th numeric>{valueTitle}</Th></Tr>
			</THead>
			<TBody>
				{#each ranked.measured as item, index (item.id)}
					<Tr>
						<Td>{index + 1}</Td>
						<Th scope="row">{item.label}</Th>
						<Td numeric>{formatValue(item.value)}</Td>
					</Tr>
				{/each}
				{#each ranked.unmeasured as item (item.id)}
					<Tr>
						<Td>—</Td>
						<Th scope="row">{item.label}</Th>
						<Td numeric>Unavailable</Td>
					</Tr>
				{/each}
			</TBody>
		</ChartData>
	{/if}
</div>

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

import { isValue } from './scale';

/* RankedBar: sorted by the chart, because the order is the finding. */

export type RankedDatum = { id: string; label: string; value: number | null };

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

/** Measured bars, largest first (ties keep the caller's order), and the rest
 *  — kept for the table, never drawn. */
export function rank(data: readonly RankedDatum[]) {
	const measured = data
		.map((item, index) => ({ item, index }))
		.filter(({ item }) => measuredRank(item.value))
		.sort((a, b) => (b.item.value as number) - (a.item.value as number) || a.index - b.index)
		.map(({ item }) => item as RankedDatum & { value: number });
	const unmeasured = data.filter((item) => !measuredRank(item.value));
	return { measured, unmeasured };
}

/** Value and rank position per id, tweened together: a bar that changes rank
 *  slides to its new place. */
export function rankedTarget(measured: readonly (RankedDatum & { value: number })[]) {
	const target: Record<string, number> = {};
	measured.forEach((item, index) => {
		target[`${item.id}|v`] = item.value;
		target[`${item.id}|i`] = index;
	});
	return target;
}

/** Pareto rows: each item's share of the total and the running total, both
 *  in percent, so columns and line share one honest axis. */
export function paretoRows(measured: readonly (RankedDatum & { value: number })[]) {
	const total = measured.reduce((sum, item) => sum + item.value, 0);
	let running = 0;
	return measured.map((item) => {
		running += item.value;
		return {
			item,
			share: total > 0 ? (item.value / total) * 100 : 0,
			cumulative: total > 0 ? (running / total) * 100 : 0
		};
	});
}

Matrix

a real table · four kinds of cell · coverage

Feature adoption

Share of each workspace's members using each feature.

Feature adoption by workspace, % of members
SSOAudit logDeploysWebhooksUsage alerts
Northstar 60% 98% 68% 64% 90%
Atlas Not applicable 71% 89% 40% 46%
Juniper Not applicable Not applicable 86% Not checked 66%
Orion Not checked 60% Checked, none found Not checked Checked, none found
Beacon Not applicable 93% 70% Checked, none found 59%
Canvas Not applicable Not applicable 46% 51% 55%
  • Checked, none found
  • Not checked
  • Not applicable — excluded from coverage
Adoption, % of members — from 0% to 100%

Four states, kept apart. A value; checked and none found (a measured zero, filled); never checked (outlined, empty); not applicable (slashed). Coverage counts the first two over everything but the last.

It is a table. Every cell is announced with its row and column, so the picture is its own data table.

Source src/lib/components/charts/matrix/doc.ts · src/lib/components/charts/matrix/Matrix.svelte · src/lib/components/charts/matrix/MatrixKey.svelte · src/lib/components/charts/matrix/matrix.module.css · src/lib/components/charts/_kernel/matrix.ts

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

/**
 * Matrix — rows by columns of cells, and four kinds of cell.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label          REQUIRED, names the table
 *     rows, columns  labels
 *     cell           (row, column) → { state: "value", value }
 *                    | { state: "absent" } | { state: "unattempted" }
 *                    | { state: "na" }
 *     ramp?          "sequential" (default) | "diverging"
 *     midpoint?, extent?, formatValue?, showValues?
 *     onSelect?      makes cells selectable; selected? is "row|column"
 *     animation?     default entrance: fade
 *
 *     coverage(rows, columns, cell)  → { checked, applicable, ratio } | null
 *     MatrixKey                      what the non-value cells mean
 *
 * # Behaviour
 *
 * R1  A table: every cell is announced with its row and column headers, so
 *     the picture is its own data table.
 * R2  Four states stay distinct: a value (coloured by the ramp); absent —
 *     checked, none found, a measured zero (filled); unattempted — never
 *     checked (outlined, empty); na — no question to ask (slashed). A value
 *     that is not finite is "unavailable" (dashed, no colour).
 * R3  Coverage is checked (value or absent) over applicable (everything but
 *     na), and null when nothing applies — a ratio over nothing is not zero.
 * R4  Selectable cells are buttons with one tab stop for the whole grid;
 *     arrow keys, Home, and End move between them; Enter or Space selects.
 * R5  A diverging ramp is symmetric about its midpoint.
 * R6  Any screen that uses Matrix shows MatrixKey.
 */
export {};

src/lib/components/charts/matrix/Matrix.svelte

<script lang="ts">
	import { VisuallyHidden } from '$lib/components/utility/visually-hidden';
	import type { AnimationProp } from '$lib/motion';
	import { chartMotion, Tweened } from '$lib/motion/svelte.svelte';
	import { cn } from '$lib/utils/cn';
	import { formatExact } from '../_kernel/format';
	import {
		cellFill,
		cellState,
		describeCell,
		matrixExtent,
		sampleMatrix,
		type Cell
	} from '../_kernel/matrix';
	import chart from '../_shared/chart.module.css';
	import styles from './matrix.module.css';

	/* Rows by columns of cells, as a real table: each cell is announced with
	   its row and column, so the picture is its own data. Four states are kept
	   apart — a value, checked and none found, never checked, not applicable. */
	let {
		label,
		rows,
		columns,
		cell,
		ramp = 'sequential',
		midpoint = 0,
		extent,
		formatValue = formatExact,
		showValues = true,
		onSelect,
		selected = null,
		animation,
		class: className = ''
	}: {
		/** Names the table. */
		label: string;
		rows: readonly string[];
		columns: readonly string[];
		cell: (row: string, column: string) => Cell;
		/** Sequential for magnitude; diverging for signed values about a midpoint. */
		ramp?: 'sequential' | 'diverging';
		midpoint?: number;
		/** The distance from the midpoint that is full colour; defaults to the data's. */
		extent?: number;
		formatValue?: (value: number) => string;
		/** Print values in the cells (default). */
		showValues?: boolean;
		/** Makes cells buttons; arrow keys move between them. */
		onSelect?: (row: string, column: string) => void;
		/** The selected cell, as `${row}|${column}`. */
		selected?: string | null;
		/** Default: cells fade in, when scrolled into view. */
		animation?: AnimationProp;
		class?: string;
	} = $props();

	const motion = chartMotion(() => animation, { enter: 'fade', axis: 'y' });
	const cells = $derived(sampleMatrix(rows, columns, cell));
	const span = $derived(extent ?? matrixExtent(cells, midpoint));
	const shown = new Tweened(
		() => {
			const target: Record<string, number> = {};
			cells.forEach((row, r) =>
				row.forEach((c, k) => {
					if (cellState(c) === 'value') target[`${r}|${k}`] = (c as { value: number }).value;
				})
			);
			return target;
		},
		() => motion.update
	);

	// Roving focus: one tab stop for the whole grid; arrows move within it.
	let active = $state<[number, number]>([0, 0]);
	let table = $state<HTMLTableElement>();
	function move(event: KeyboardEvent) {
		const [r, c] = active;
		const next: Record<string, [number, number]> = {
			ArrowUp: [Math.max(0, r - 1), c],
			ArrowDown: [Math.min(rows.length - 1, r + 1), c],
			ArrowLeft: [r, Math.max(0, c - 1)],
			ArrowRight: [r, Math.min(columns.length - 1, c + 1)],
			Home: [r, 0],
			End: [r, columns.length - 1]
		};
		const to = next[event.key];
		if (!to) return;
		event.preventDefault();
		active = to;
		table?.querySelector<HTMLButtonElement>(`[data-cell="${to[0]}-${to[1]}"]`)?.focus();
	}
</script>

{#if !rows.length || !columns.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)}>
		<div class={styles.scroll}>
			<!-- Arrow keys are handled once here for the cell buttons inside: a
			     delegated handler, not an interactive table. -->
			<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
			<table
				bind:this={table}
				class={styles.table}
				style:--row-head-ch={Math.min(24, Math.max(...rows.map((r) => r.length)))}
				style:--columns={columns.length}
				onkeydown={onSelect ? move : undefined}
			>
				<caption><VisuallyHidden>{label}</VisuallyHidden></caption>
				<thead>
					<tr>
						<td class={styles.corner}></td>
						{#each columns as column (column)}
							<th scope="col" class={styles.columnHead}>{column}</th>
						{/each}
					</tr>
				</thead>
				<tbody>
					{#each rows as row, r (row)}
						<tr>
							<th scope="row" class={styles.rowHead} title={row}>{row}</th>
							{#each columns as column, k (column)}
								{@const c = cells[r][k]}
								{@const state = cellState(c)}
								{@const value = shown.current[`${r}|${k}`]}
								{@const fill =
									state === 'value' && value !== undefined
										? cellFill(value, ramp, midpoint, span)
										: undefined}
								{@const strong =
									state === 'value' &&
									value !== undefined &&
									Math.abs(value - midpoint) / span > 0.55}
								{@const visible =
									state === 'value'
										? showValues
											? formatValue(value ?? 0)
											: ''
										: state === 'absent'
											? '0'
											: ''}
								<td class={styles.cell}>
									{#if onSelect}
										<button
											type="button"
											data-mark
											data-state={state}
											data-strong={strong || undefined}
											data-cell="{r}-{k}"
											class={styles.swatch}
											style:background={fill}
											tabindex={active[0] === r && active[1] === k ? 0 : -1}
											aria-pressed={selected === `${row}|${column}`}
											onfocus={() => (active = [r, k])}
											onclick={() => onSelect(row, column)}
										>
											<span aria-hidden="true">{visible}</span>
											<VisuallyHidden>{describeCell(c, formatValue)}</VisuallyHidden>
										</button>
									{:else}
										<span
											data-mark
											data-state={state}
											data-strong={strong || undefined}
											class={styles.swatch}
											style:background={fill}
										>
											<span aria-hidden="true">{visible}</span>
											<VisuallyHidden>{describeCell(c, formatValue)}</VisuallyHidden>
										</span>
									{/if}
								</td>
							{/each}
						</tr>
					{/each}
				</tbody>
			</table>
		</div>
	</div>
{/if}

src/lib/components/charts/matrix/MatrixKey.svelte

<script lang="ts">
	import { cn } from '$lib/utils/cn';
	import styles from './matrix.module.css';

	/* What the non-value cells mean. Any screen using Matrix shows it, or the
	   distinctions the matrix keeps cannot be read. */
	let { class: className = '' }: { class?: string } = $props();
</script>

<ul class={cn(styles.key, className)} aria-label="Cell key">
	<li>
		<span class={styles.swatch} data-state="absent" aria-hidden="true"></span>Checked, none found
	</li>
	<li>
		<span class={styles.swatch} data-state="unattempted" aria-hidden="true"></span>Not checked
	</li>
	<li>
		<span class={styles.swatch} data-state="na" aria-hidden="true"></span>Not applicable — excluded
		from coverage
	</li>
</ul>

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

@layer primitive {
	.scroll {
		min-width: 0;
		overflow-x: auto;
	}
	.table {
		width: 100%;
		min-width: calc(var(--row-head-ch, 8) * 1ch + var(--columns, 4) * 2.75rem);
		border-collapse: separate;
		border-spacing: 3px;
		table-layout: fixed;
		font-size: var(--text-11);
	}
	.corner,
	.rowHead {
		width: calc(var(--row-head-ch, 8) * 1ch + var(--space-5));
	}
	.columnHead {
		padding: 0 var(--space-1) var(--space-2);
		color: var(--chart-label, var(--ink-3));
		font-weight: var(--weight-medium);
		line-height: 1.2;
		text-align: center;
		vertical-align: bottom;
		overflow-wrap: anywhere;
	}
	.rowHead {
		padding-right: var(--space-3);
		color: var(--chart-label, var(--ink-3));
		font-family: var(--font-mono);
		font-weight: var(--weight-regular, 400);
		text-align: end;
		white-space: nowrap;
		overflow: hidden;
		text-overflow: ellipsis;
	}
	.cell {
		height: 2.25rem;
		padding: 0;
	}
	/* The swatch is the mark: it fills the cell and carries the value. */
	.swatch {
		display: grid;
		width: 100%;
		height: 100%;
		place-items: center;
		border-radius: var(--radius-1);
		background: var(--chart-mid);
		color: var(--ink);
		font-family: var(--font-mono);
		font-variant-numeric: tabular-nums;
	}
	.swatch[data-strong] {
		color: var(--surface-panel);
	}
	/* Checked, none found: a measured zero, so it is filled. */
	.swatch[data-state='absent'] {
		border: 1px solid var(--line-strong);
		background: var(--chart-absent);
		color: var(--ink-3);
	}
	/* Never checked: genuinely empty, so an outline and no fill. */
	.swatch[data-state='unattempted'] {
		border: 1px dashed var(--ink-4, var(--line-strong));
		background: var(--chart-unattempted, transparent);
	}
	/* Nothing to ask: a slash, a texture that survives print. */
	.swatch[data-state='na'] {
		background:
			linear-gradient(
				to top right,
				transparent calc(50% - 0.5px),
				var(--line-strong) calc(50% - 0.5px),
				var(--line-strong) calc(50% + 0.5px),
				transparent calc(50% + 0.5px)
			),
			var(--chart-na, var(--surface-sunk));
	}
	.swatch[data-state='unavailable'] {
		border: 1px dashed var(--ink-3);
		background: transparent;
		color: var(--ink-3);
	}
	button.swatch {
		border: 0;
		cursor: pointer;
		font: inherit;
	}
	button.swatch:focus-visible,
	button.swatch[aria-pressed='true'] {
		outline: 2px solid var(--accent);
		outline-offset: 1px;
	}

	.key {
		display: flex;
		flex-wrap: wrap;
		gap: var(--space-3) var(--space-6);
		margin: 0;
		padding: 0;
		list-style: none;
		color: var(--ink-2);
		font-size: var(--text-12);
	}
	.key li {
		display: inline-flex;
		align-items: center;
		gap: var(--space-3);
	}
	.key .swatch {
		width: 14px;
		height: 14px;
	}
}

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

import { divergingFill, sequentialFill } from './encode';
import { isValue } from './scale';

/* Matrix: four states, because four different things can be in a cell. */

export type Cell =
	| { state: 'value'; value: number }
	/** Looked, and found nothing: a measured zero. */
	| { state: 'absent' }
	/** Nobody checked. Never drawn as a zero. */
	| { state: 'unattempted' }
	/** There is no question to ask here. Excluded from coverage entirely. */
	| { state: 'na' };

export type CellState = Cell['state'] | 'unavailable';

export function cellState(cell: Cell): CellState {
	return cell.state === 'value' && !isValue(cell.value) ? 'unavailable' : cell.state;
}

export function describeCell(cell: Cell, format: (v: number) => string) {
	switch (cellState(cell)) {
		case 'value':
			return format((cell as { value: number }).value);
		case 'unavailable':
			return 'Unavailable';
		case 'absent':
			return 'Checked, none found';
		case 'unattempted':
			return 'Not checked';
		case 'na':
			return 'Not applicable';
	}
}

/** Every cell, read once. */
export function sampleMatrix(
	rows: readonly string[],
	columns: readonly string[],
	cell: (row: string, column: string) => Cell
) {
	return rows.map((row) => columns.map((column) => cell(row, column)));
}

/** The largest distance from the midpoint, so a ramp spans the data. */
export function matrixExtent(cells: readonly (readonly Cell[])[], midpoint: number) {
	let extent = 1e-9;
	for (const row of cells)
		for (const cell of row)
			if (cell.state === 'value' && isValue(cell.value))
				extent = Math.max(extent, Math.abs(cell.value - midpoint));
	return extent;
}

export function cellFill(
	value: number,
	ramp: 'sequential' | 'diverging',
	midpoint: number,
	extent: number
) {
	return ramp === 'diverging'
		? divergingFill((value - midpoint) / extent)
		: sequentialFill((value - midpoint) / extent);
}

/** Coverage: checked over applicable. "Not applicable" is in neither half —
 *  counting it either way changes the ratio for a question that was never
 *  asked. Null when nothing applies: a ratio over nothing is not zero. */
export function coverageOf(cells: readonly (readonly Cell[])[]) {
	let checked = 0;
	let applicable = 0;
	for (const row of cells)
		for (const cell of row) {
			if (cell.state === 'na') continue;
			applicable++;
			if (cell.state === 'absent' || (cell.state === 'value' && isValue(cell.value))) checked++;
		}
	return applicable === 0 ? null : { checked, applicable, ratio: checked / applicable };
}

Selectable matrix

diverging ramp · one tab stop · arrow keys

Metric correlations

Diverging about zero. Select a cell with the pointer, or Tab in and move with the arrow keys.

Correlation between product metrics
SeatsDeploysAPI callsTicketsChurn risk
Seats
Deploys
API calls
Tickets
Churn risk
Correlation — from -1 through 0 to 1

No cell selected.

Source src/lib/components/charts/matrix/doc.ts · src/lib/components/charts/matrix/Matrix.svelte · src/lib/components/charts/matrix/MatrixKey.svelte · src/lib/components/charts/matrix/matrix.module.css · src/lib/components/charts/_kernel/matrix.ts

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

/**
 * Matrix — rows by columns of cells, and four kinds of cell.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label          REQUIRED, names the table
 *     rows, columns  labels
 *     cell           (row, column) → { state: "value", value }
 *                    | { state: "absent" } | { state: "unattempted" }
 *                    | { state: "na" }
 *     ramp?          "sequential" (default) | "diverging"
 *     midpoint?, extent?, formatValue?, showValues?
 *     onSelect?      makes cells selectable; selected? is "row|column"
 *     animation?     default entrance: fade
 *
 *     coverage(rows, columns, cell)  → { checked, applicable, ratio } | null
 *     MatrixKey                      what the non-value cells mean
 *
 * # Behaviour
 *
 * R1  A table: every cell is announced with its row and column headers, so
 *     the picture is its own data table.
 * R2  Four states stay distinct: a value (coloured by the ramp); absent —
 *     checked, none found, a measured zero (filled); unattempted — never
 *     checked (outlined, empty); na — no question to ask (slashed). A value
 *     that is not finite is "unavailable" (dashed, no colour).
 * R3  Coverage is checked (value or absent) over applicable (everything but
 *     na), and null when nothing applies — a ratio over nothing is not zero.
 * R4  Selectable cells are buttons with one tab stop for the whole grid;
 *     arrow keys, Home, and End move between them; Enter or Space selects.
 * R5  A diverging ramp is symmetric about its midpoint.
 * R6  Any screen that uses Matrix shows MatrixKey.
 */
export {};

src/lib/components/charts/matrix/Matrix.svelte

<script lang="ts">
	import { VisuallyHidden } from '$lib/components/utility/visually-hidden';
	import type { AnimationProp } from '$lib/motion';
	import { chartMotion, Tweened } from '$lib/motion/svelte.svelte';
	import { cn } from '$lib/utils/cn';
	import { formatExact } from '../_kernel/format';
	import {
		cellFill,
		cellState,
		describeCell,
		matrixExtent,
		sampleMatrix,
		type Cell
	} from '../_kernel/matrix';
	import chart from '../_shared/chart.module.css';
	import styles from './matrix.module.css';

	/* Rows by columns of cells, as a real table: each cell is announced with
	   its row and column, so the picture is its own data. Four states are kept
	   apart — a value, checked and none found, never checked, not applicable. */
	let {
		label,
		rows,
		columns,
		cell,
		ramp = 'sequential',
		midpoint = 0,
		extent,
		formatValue = formatExact,
		showValues = true,
		onSelect,
		selected = null,
		animation,
		class: className = ''
	}: {
		/** Names the table. */
		label: string;
		rows: readonly string[];
		columns: readonly string[];
		cell: (row: string, column: string) => Cell;
		/** Sequential for magnitude; diverging for signed values about a midpoint. */
		ramp?: 'sequential' | 'diverging';
		midpoint?: number;
		/** The distance from the midpoint that is full colour; defaults to the data's. */
		extent?: number;
		formatValue?: (value: number) => string;
		/** Print values in the cells (default). */
		showValues?: boolean;
		/** Makes cells buttons; arrow keys move between them. */
		onSelect?: (row: string, column: string) => void;
		/** The selected cell, as `${row}|${column}`. */
		selected?: string | null;
		/** Default: cells fade in, when scrolled into view. */
		animation?: AnimationProp;
		class?: string;
	} = $props();

	const motion = chartMotion(() => animation, { enter: 'fade', axis: 'y' });
	const cells = $derived(sampleMatrix(rows, columns, cell));
	const span = $derived(extent ?? matrixExtent(cells, midpoint));
	const shown = new Tweened(
		() => {
			const target: Record<string, number> = {};
			cells.forEach((row, r) =>
				row.forEach((c, k) => {
					if (cellState(c) === 'value') target[`${r}|${k}`] = (c as { value: number }).value;
				})
			);
			return target;
		},
		() => motion.update
	);

	// Roving focus: one tab stop for the whole grid; arrows move within it.
	let active = $state<[number, number]>([0, 0]);
	let table = $state<HTMLTableElement>();
	function move(event: KeyboardEvent) {
		const [r, c] = active;
		const next: Record<string, [number, number]> = {
			ArrowUp: [Math.max(0, r - 1), c],
			ArrowDown: [Math.min(rows.length - 1, r + 1), c],
			ArrowLeft: [r, Math.max(0, c - 1)],
			ArrowRight: [r, Math.min(columns.length - 1, c + 1)],
			Home: [r, 0],
			End: [r, columns.length - 1]
		};
		const to = next[event.key];
		if (!to) return;
		event.preventDefault();
		active = to;
		table?.querySelector<HTMLButtonElement>(`[data-cell="${to[0]}-${to[1]}"]`)?.focus();
	}
</script>

{#if !rows.length || !columns.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)}>
		<div class={styles.scroll}>
			<!-- Arrow keys are handled once here for the cell buttons inside: a
			     delegated handler, not an interactive table. -->
			<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
			<table
				bind:this={table}
				class={styles.table}
				style:--row-head-ch={Math.min(24, Math.max(...rows.map((r) => r.length)))}
				style:--columns={columns.length}
				onkeydown={onSelect ? move : undefined}
			>
				<caption><VisuallyHidden>{label}</VisuallyHidden></caption>
				<thead>
					<tr>
						<td class={styles.corner}></td>
						{#each columns as column (column)}
							<th scope="col" class={styles.columnHead}>{column}</th>
						{/each}
					</tr>
				</thead>
				<tbody>
					{#each rows as row, r (row)}
						<tr>
							<th scope="row" class={styles.rowHead} title={row}>{row}</th>
							{#each columns as column, k (column)}
								{@const c = cells[r][k]}
								{@const state = cellState(c)}
								{@const value = shown.current[`${r}|${k}`]}
								{@const fill =
									state === 'value' && value !== undefined
										? cellFill(value, ramp, midpoint, span)
										: undefined}
								{@const strong =
									state === 'value' &&
									value !== undefined &&
									Math.abs(value - midpoint) / span > 0.55}
								{@const visible =
									state === 'value'
										? showValues
											? formatValue(value ?? 0)
											: ''
										: state === 'absent'
											? '0'
											: ''}
								<td class={styles.cell}>
									{#if onSelect}
										<button
											type="button"
											data-mark
											data-state={state}
											data-strong={strong || undefined}
											data-cell="{r}-{k}"
											class={styles.swatch}
											style:background={fill}
											tabindex={active[0] === r && active[1] === k ? 0 : -1}
											aria-pressed={selected === `${row}|${column}`}
											onfocus={() => (active = [r, k])}
											onclick={() => onSelect(row, column)}
										>
											<span aria-hidden="true">{visible}</span>
											<VisuallyHidden>{describeCell(c, formatValue)}</VisuallyHidden>
										</button>
									{:else}
										<span
											data-mark
											data-state={state}
											data-strong={strong || undefined}
											class={styles.swatch}
											style:background={fill}
										>
											<span aria-hidden="true">{visible}</span>
											<VisuallyHidden>{describeCell(c, formatValue)}</VisuallyHidden>
										</span>
									{/if}
								</td>
							{/each}
						</tr>
					{/each}
				</tbody>
			</table>
		</div>
	</div>
{/if}

src/lib/components/charts/matrix/MatrixKey.svelte

<script lang="ts">
	import { cn } from '$lib/utils/cn';
	import styles from './matrix.module.css';

	/* What the non-value cells mean. Any screen using Matrix shows it, or the
	   distinctions the matrix keeps cannot be read. */
	let { class: className = '' }: { class?: string } = $props();
</script>

<ul class={cn(styles.key, className)} aria-label="Cell key">
	<li>
		<span class={styles.swatch} data-state="absent" aria-hidden="true"></span>Checked, none found
	</li>
	<li>
		<span class={styles.swatch} data-state="unattempted" aria-hidden="true"></span>Not checked
	</li>
	<li>
		<span class={styles.swatch} data-state="na" aria-hidden="true"></span>Not applicable — excluded
		from coverage
	</li>
</ul>

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

@layer primitive {
	.scroll {
		min-width: 0;
		overflow-x: auto;
	}
	.table {
		width: 100%;
		min-width: calc(var(--row-head-ch, 8) * 1ch + var(--columns, 4) * 2.75rem);
		border-collapse: separate;
		border-spacing: 3px;
		table-layout: fixed;
		font-size: var(--text-11);
	}
	.corner,
	.rowHead {
		width: calc(var(--row-head-ch, 8) * 1ch + var(--space-5));
	}
	.columnHead {
		padding: 0 var(--space-1) var(--space-2);
		color: var(--chart-label, var(--ink-3));
		font-weight: var(--weight-medium);
		line-height: 1.2;
		text-align: center;
		vertical-align: bottom;
		overflow-wrap: anywhere;
	}
	.rowHead {
		padding-right: var(--space-3);
		color: var(--chart-label, var(--ink-3));
		font-family: var(--font-mono);
		font-weight: var(--weight-regular, 400);
		text-align: end;
		white-space: nowrap;
		overflow: hidden;
		text-overflow: ellipsis;
	}
	.cell {
		height: 2.25rem;
		padding: 0;
	}
	/* The swatch is the mark: it fills the cell and carries the value. */
	.swatch {
		display: grid;
		width: 100%;
		height: 100%;
		place-items: center;
		border-radius: var(--radius-1);
		background: var(--chart-mid);
		color: var(--ink);
		font-family: var(--font-mono);
		font-variant-numeric: tabular-nums;
	}
	.swatch[data-strong] {
		color: var(--surface-panel);
	}
	/* Checked, none found: a measured zero, so it is filled. */
	.swatch[data-state='absent'] {
		border: 1px solid var(--line-strong);
		background: var(--chart-absent);
		color: var(--ink-3);
	}
	/* Never checked: genuinely empty, so an outline and no fill. */
	.swatch[data-state='unattempted'] {
		border: 1px dashed var(--ink-4, var(--line-strong));
		background: var(--chart-unattempted, transparent);
	}
	/* Nothing to ask: a slash, a texture that survives print. */
	.swatch[data-state='na'] {
		background:
			linear-gradient(
				to top right,
				transparent calc(50% - 0.5px),
				var(--line-strong) calc(50% - 0.5px),
				var(--line-strong) calc(50% + 0.5px),
				transparent calc(50% + 0.5px)
			),
			var(--chart-na, var(--surface-sunk));
	}
	.swatch[data-state='unavailable'] {
		border: 1px dashed var(--ink-3);
		background: transparent;
		color: var(--ink-3);
	}
	button.swatch {
		border: 0;
		cursor: pointer;
		font: inherit;
	}
	button.swatch:focus-visible,
	button.swatch[aria-pressed='true'] {
		outline: 2px solid var(--accent);
		outline-offset: 1px;
	}

	.key {
		display: flex;
		flex-wrap: wrap;
		gap: var(--space-3) var(--space-6);
		margin: 0;
		padding: 0;
		list-style: none;
		color: var(--ink-2);
		font-size: var(--text-12);
	}
	.key li {
		display: inline-flex;
		align-items: center;
		gap: var(--space-3);
	}
	.key .swatch {
		width: 14px;
		height: 14px;
	}
}

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

import { divergingFill, sequentialFill } from './encode';
import { isValue } from './scale';

/* Matrix: four states, because four different things can be in a cell. */

export type Cell =
	| { state: 'value'; value: number }
	/** Looked, and found nothing: a measured zero. */
	| { state: 'absent' }
	/** Nobody checked. Never drawn as a zero. */
	| { state: 'unattempted' }
	/** There is no question to ask here. Excluded from coverage entirely. */
	| { state: 'na' };

export type CellState = Cell['state'] | 'unavailable';

export function cellState(cell: Cell): CellState {
	return cell.state === 'value' && !isValue(cell.value) ? 'unavailable' : cell.state;
}

export function describeCell(cell: Cell, format: (v: number) => string) {
	switch (cellState(cell)) {
		case 'value':
			return format((cell as { value: number }).value);
		case 'unavailable':
			return 'Unavailable';
		case 'absent':
			return 'Checked, none found';
		case 'unattempted':
			return 'Not checked';
		case 'na':
			return 'Not applicable';
	}
}

/** Every cell, read once. */
export function sampleMatrix(
	rows: readonly string[],
	columns: readonly string[],
	cell: (row: string, column: string) => Cell
) {
	return rows.map((row) => columns.map((column) => cell(row, column)));
}

/** The largest distance from the midpoint, so a ramp spans the data. */
export function matrixExtent(cells: readonly (readonly Cell[])[], midpoint: number) {
	let extent = 1e-9;
	for (const row of cells)
		for (const cell of row)
			if (cell.state === 'value' && isValue(cell.value))
				extent = Math.max(extent, Math.abs(cell.value - midpoint));
	return extent;
}

export function cellFill(
	value: number,
	ramp: 'sequential' | 'diverging',
	midpoint: number,
	extent: number
) {
	return ramp === 'diverging'
		? divergingFill((value - midpoint) / extent)
		: sequentialFill((value - midpoint) / extent);
}

/** Coverage: checked over applicable. "Not applicable" is in neither half —
 *  counting it either way changes the ratio for a question that was never
 *  asked. Null when nothing applies: a ratio over nothing is not zero. */
export function coverageOf(cells: readonly (readonly Cell[])[]) {
	let checked = 0;
	let applicable = 0;
	for (const row of cells)
		for (const cell of row) {
			if (cell.state === 'na') continue;
			applicable++;
			if (cell.state === 'absent' || (cell.state === 'value' && isValue(cell.value))) checked++;
		}
	return applicable === 0 ? null : { checked, applicable, ratio: checked / applicable };
}

ColourBar

sequential · diverging · off-centre midpoint

Adoption, % of members — from 0% to 100%
Correlation — from -1 through 0 to 1
Change in churn (pts) — from -4 through 0 to 12

Steps, not a gradient. A smooth gradient invites reading a precise value off a smear. A diverging bar is symmetric about its midpoint, so equal distances read as equal colour even when the domain is lopsided.

Source src/lib/components/charts/colour-bar/doc.ts · src/lib/components/charts/colour-bar/ColourBar.svelte · src/lib/components/charts/colour-bar/colour-bar.module.css

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

/**
 * ColourBar — the scale a colour-coded chart uses.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label       REQUIRED, what the colour means
 *     kind?       "sequential" (default) | "diverging"
 *     domain      [low, high]
 *     midpoint?   diverging only, default 0
 *     steps?      2–32, default 9
 *     formatValue?, width?
 *
 * # Behaviour
 *
 * R1  Discrete steps, never a smooth gradient: the eye resolves a handful of
 *     levels, and a gradient invites reading a precise value off a smear.
 * R2  The ends are labelled; a diverging bar labels its midpoint too when it
 *     is not crowding an end.
 * R3  The caption reads the whole range as text.
 * R4  It uses the same ramps as Matrix, so the two always agree.
 */
export {};

src/lib/components/charts/colour-bar/ColourBar.svelte

<script lang="ts">
	import { VisuallyHidden } from '$lib/components/utility/visually-hidden';
	import { cn } from '$lib/utils/cn';
	import { divergingFill, sequentialFill } from '../_kernel/encode';
	import { formatTick } from '../_kernel/format';
	import styles from './colour-bar.module.css';

	/* The scale a colour-coded chart uses, in steps rather than a smooth
	   gradient: a gradient invites reading a precise value off a smear. */
	let {
		label,
		kind = 'sequential',
		domain,
		midpoint = 0,
		steps: requested = 9,
		formatValue = formatTick,
		width,
		class: className = ''
	}: {
		/** What the colour means. */
		label: string;
		kind?: 'sequential' | 'diverging';
		domain: readonly [number, number];
		/** Diverging only: the neutral value. */
		midpoint?: number;
		/** Discrete steps, 2–32. */
		steps?: number;
		formatValue?: (value: number) => string;
		/** The bar's width, as a CSS length. */
		width?: string;
		class?: string;
	} = $props();

	const steps = $derived(Math.max(2, Math.min(32, Math.floor(requested) || 9)));
	const lo = $derived(domain[0]);
	const hi = $derived(domain[1]);
	const extent = $derived(Math.max(1e-9, Math.abs(lo - midpoint), Math.abs(hi - midpoint)));
	const fills = $derived(
		Array.from({ length: steps }, (_, i) => {
			const value = lo + (i / (steps - 1)) * (hi - lo);
			return kind === 'diverging'
				? divergingFill((value - midpoint) / extent)
				: sequentialFill(i / (steps - 1));
		})
	);
	const mid = $derived(hi === lo ? 0.5 : (midpoint - lo) / (hi - lo));
</script>

<figure class={cn(styles.root, className)} style:--bar-w={width}>
	<div class={styles.steps} aria-hidden="true">
		{#each fills as fill, index (index)}<span style:background={fill}></span>{/each}
	</div>
	<div class={styles.ticks} aria-hidden="true">
		<span style:left="0">{formatValue(lo)}</span>
		{#if kind === 'diverging' && mid > 0.15 && mid < 0.85}
			<span style:left="{mid * 100}%">{formatValue(midpoint)}</span>
		{/if}
		<span style:left="100%">{formatValue(hi)}</span>
	</div>
	<figcaption class={styles.caption}>
		{label}<VisuallyHidden
			>&nbsp;— from {formatValue(lo)}{kind === 'diverging'
				? ` through ${formatValue(midpoint)}`
				: ''} to {formatValue(hi)}</VisuallyHidden
		>
	</figcaption>
</figure>

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

@layer primitive {
	.root {
		display: grid;
		width: min(100%, var(--bar-w, 14rem));
		gap: var(--space-2);
		margin: 0;
	}
	.steps {
		display: flex;
		height: 10px;
		overflow: hidden;
		border-radius: var(--radius-1);
	}
	.steps span {
		flex: 1;
	}
	.ticks {
		position: relative;
		height: 1.3em;
		color: var(--chart-label, var(--ink-3));
		font-family: var(--font-mono);
		font-size: var(--text-11);
		font-variant-numeric: tabular-nums;
	}
	.ticks span {
		position: absolute;
		top: 0;
		transform: translateX(-50%);
	}
	.ticks span:first-child {
		transform: none;
	}
	.ticks span:last-child {
		transform: translateX(-100%);
	}
	.caption {
		color: var(--ink-2);
		font-size: var(--text-12);
	}
}

Edge cases

empty · unmeasured · unavailable cell

No points

No data to display.

Unmeasured bubbles

Measurements unavailable.

View data for Unmeasured bubbles
Unmeasured bubbles
ItemCategoryXYSize
a — 12Unavailable
Nothing ranked

Measurements unavailable.

View data for Nothing ranked
Nothing ranked
RankItemValue
— Total Unavailable
Unavailable cell
Unavailable cell
SSODeploys
Atlas Unavailable 64%