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
Lift against significance. Beyond both dashed lines is a finding; grey is a measured null result.
View data for Experiment results: lift against significance
| Point | Lift in conversion (%) | −log₁₀ p | Result |
|---|---|---|---|
| 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
Adoption against satisfaction. Series differ by shape as well as colour.
- Core
- Growth
- Admin
View data for 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
Seats against growth; bubble area is MRR.
- Starter
- Team
- Business
View data for Accounts by seats and growth, sized by MRR
| Item | Category | Seats | Growth (%) | MRR |
|---|---|---|---|---|
| Account 1 | Starter | 34 | 16.8 | 246 |
| Account 2 | Team | 78 | 36 | 1,598 |
| Account 3 | Business | 88 | 16.8 | 4,464 |
| Account 4 | Starter | 30 | 13.8 | 260 |
| Account 5 | Team | 68 | 25.2 | 1,569 |
| Account 6 | Business | 93 | -4.3 | 3,422 |
| Account 7 | Starter | 27 | 7.6 | 192 |
| Account 8 | Team | 51 | 20.8 | 1,080 |
| Account 9 | Business | 108 | 35.7 | 6,375 |
| Account 10 | Starter | 49 | 4.1 | 360 |
| Account 11 | Team | 58 | -11.6 | 1,400 |
| Account 12 | Business | 142 | 2.8 | 4,452 |
| Account 13 | Starter | 48 | 5.8 | 332 |
| Account 14 | Team | 78 | 35.7 | 1,043 |
| Account 15 | Business | 134 | 34.2 | 5,542 |
| Account 16 | Starter | 5 | -10.4 | 43 |
| Account 17 | Team | 73 | 16.6 | 1,144 |
| Account 18 | Business | 113 | 29.1 | 3,270 |
| Account 19 | Starter | 42 | -12.3 | 263 |
| Account 20 | Team | 81 | 44.1 | 1,499 |
| Account 21 | Business | 111 | 6.2 | 3,022 |
| Account 22 | Starter | 47 | 10.2 | 383 |
| Account 23 | Team | 61 | 18.3 | 1,041 |
| Account 24 | Business | 134 | -5.4 | 4,044 |
| Account 25 | Starter | 41 | 22.4 | 178 |
| Account 26 | Team | 65 | 7.7 | 1,799 |
| Account 27 | Business | 134 | 22.8 | 5,689 |
| Account 28 | Starter | 58 | 20.2 | 360 |
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
Each box is the middle half of 60 requests; the line is the median.
- API
- Web
View data for Response times by region, milliseconds
| Group | Series | n | Min | Q1 | Median | Q3 | Max | Outliers |
|---|---|---|---|---|---|---|---|---|
| us-east | API | 60 | 82 ms | 109 ms | 122 ms | 132 ms | 154 ms | 191 ms, 204 ms, 229 ms, 243 ms, 271 ms |
| us-east | Web | 60 | 167 ms | 195 ms | 210 ms | 229 ms | 255 ms | 323 ms, 326 ms, 457 ms |
| eu-west | API | 60 | 116 ms | 142 ms | 155 ms | 171 ms | 215 ms | 262 ms, 312 ms |
| eu-west | Web | 60 | 186 ms | 234 ms | 252 ms | 270 ms | 311 ms | — |
| ap-south | API | 60 | 143 ms | 174 ms | 187 ms | 209 ms | 240 ms | 284 ms, 354 ms |
| ap-south | Web | 60 | 220 ms | 276 ms | 296 ms | 319 ms | 355 ms | 484 ms, 494 ms, 494 ms, 540 ms, 555 ms |
| sa-east | API | 60 | 163 ms | 206 ms | 227 ms | 242 ms | 296 ms | 428 ms, 464 ms |
| sa-east | Web | 60 | 258 ms | 323 ms | 344 ms | 368 ms | 427 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
Sorted by the chart. Switch days and each column slides to its new rank.
View data for Errors by endpoint, today
| Rank | Item | Errors |
|---|---|---|
| 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
Share of each workspace's members using each feature.
| SSO | Audit log | Deploys | Webhooks | Usage 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
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
Diverging about zero. Select a cell with the pointer, or Tab in and move with the arrow keys.
| Seats | Deploys | API calls | Tickets | Churn risk | |
|---|---|---|---|---|---|
| Seats | |||||
| Deploys | |||||
| API calls | |||||
| Tickets | |||||
| Churn risk |
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
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
> — 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 data to display.
Measurements unavailable.
View data for Unmeasured bubbles
| Item | Category | X | Y | Size |
|---|---|---|---|---|
| a | — | 1 | 2 | Unavailable |
Measurements unavailable.
View data for Nothing ranked
| Rank | Item | Value |
|---|---|---|
| — | Total | Unavailable |
| SSO | Deploys | |
|---|---|---|
| Atlas | Unavailable | 64% |