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.
Sourcecomponents/charts/volcano/doc.ts · components/charts/volcano/volcano.tsx · components/charts/plot-frame/doc.ts · components/charts/plot-frame/plot-frame.tsx · components/charts/plot-frame/plot-frame.module.css · components/charts/_kernel/scatter.ts · components/charts/_kernel/plot.ts
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 {};components/charts/volcano/volcano.tsx
"use client";
import { TBody, Td, Th, THead, Tr } from "@/components/display/table";
import type { AnimationProp } from "@/lib/motion";
import { useTweened } from "@/lib/motion/react";
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/chart-data";
import chart from "../_shared/chart.module.css";
import { useChartMotion } from "../_shared/use-chart-motion";
import { PlotFrame } from "../plot-frame";
export type { VolcanoPoint };
export type VolcanoProps = {
/** 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. A judgement the caller makes: a label
* on every point is a wall of text. */
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;
className?: string;
};
const RESULT = { up: "Positive", down: "Negative", quiet: "Below threshold" };
/** Effect against significance: the points beyond both cuts are the
* finding, and "not significant" is a result, drawn grey. */
export function Volcano({
label,
points,
effectCut = 1,
significanceCut = 1.3,
xTitle = "Effect",
yTitle = "Significance",
labelled = [],
aspect = 1.6,
formatValue = formatExact,
animation,
className,
}: VolcanoProps) {
const { rootProps, update } = useChartMotion(animation, {
enter: "pop",
axis: "y",
});
const model = volcanoTarget(points, effectCut, significanceCut);
const shown = useTweened(model.target, update);
const plot = scatterScales(shown, model.xStep, model.yStep, aspect);
const named = new Set(labelled);
return (
<div {...rootProps} className={cn(chart.root, className)}>
{points.length === 0 ? (
<p className={chart.empty}>No data to display.</p>
) : model.valid.length === 0 ? (
<p className={chart.empty}>Measurements unavailable.</p>
) : (
<PlotFrame
label={label}
aspect={aspect}
xTicks={plot.xTicks}
yTicks={plot.yTicks}
xTitle={xTitle}
yTitle={yTitle}
rules={[
{ key: "sig", y: plot.y(significanceCut) },
{ key: "up", x: plot.x(effectCut) },
{ key: "down", x: plot.x(-effectCut) },
]}
notes={spreadLabels(
model.valid
.filter(
(p) => named.has(p.id) && shown[`${p.id}|x`] !== undefined,
)
.map((p) => ({
key: p.id,
x: plot.x(shown[`${p.id}|x`]) / plot.width,
y: plot.y(shown[`${p.id}|y`]) / plot.height,
text: p.label ?? p.id,
})),
)}
>
{model.valid.map((p) => {
const px = shown[`${p.id}|x`];
const py = shown[`${p.id}|y`];
if (px === undefined || py === undefined) return null;
const state = volcanoState(p, effectCut, significanceCut);
return (
<circle
key={p.id}
data-mark
data-state={state}
className={chart.dot}
cx={plot.x(px)}
cy={plot.y(py)}
r={state === "quiet" ? 4.5 : 6.5}
/>
);
})}
</PlotFrame>
)}
{points.length ? (
<ChartData label={label}>
<THead>
<Tr>
<Th>Point</Th>
<Th numeric>{xTitle}</Th>
<Th numeric>{yTitle}</Th>
<Th>Result</Th>
</Tr>
</THead>
<TBody>
{points.map((p) => (
<Tr key={p.id}>
<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>
))}
</TBody>
</ChartData>
) : null}
</div>
);
}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 {};components/charts/plot-frame/plot-frame.tsx
import type { CSSProperties, ReactNode } from "react";
import type { AxisTick } from "../_kernel/plot";
import styles from "./plot-frame.module.css";
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 };
export type PlotFrameProps = {
/** 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: ReactNode;
};
/**
* 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.
*/
export function PlotFrame({
label,
aspect = 1.6,
xTicks,
yTicks,
xTitle,
yTitle,
rules = [],
notes = [],
bands = [],
children,
}: PlotFrameProps) {
const width = 1000;
const height = width / aspect;
return (
<div
className={styles.root}
style={
{
"--aspect": aspect,
"--axis-ch": Math.max(2, ...yTicks.map((tick) => tick.label.length)),
} as CSSProperties
}
>
{yTitle ? (
<span className={styles.yTitle} aria-hidden="true">
{yTitle}
</span>
) : (
<span />
)}
<div className={styles.yTicks} aria-hidden="true">
{yTicks.map((tick) => (
<span key={tick.value} style={{ top: `${tick.at * 100}%` }}>
{tick.label}
</span>
))}
</div>
<div className={styles.plot}>
<svg
className={styles.svg}
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="none"
role="img"
aria-label={`${label}. Exact values are in the data table.`}
>
{bands.map((band) => (
<rect
key={band.key}
className={styles.band}
x={band.x}
y={band.y}
width={band.width}
height={band.height}
/>
))}
{yTicks.map((tick) => (
<line
key={`y${tick.value}`}
className={styles.grid}
x1={0}
x2={width}
y1={tick.at * height}
y2={tick.at * height}
/>
))}
{xTicks.map((tick) => (
<line
key={`x${tick.value}`}
className={styles.grid}
y1={0}
y2={height}
x1={tick.at * width}
x2={tick.at * width}
/>
))}
{rules.map((rule) =>
rule.y !== undefined ? (
<line
key={rule.key}
className={styles.rule}
x1={0}
x2={width}
y1={rule.y}
y2={rule.y}
/>
) : (
<line
key={rule.key}
className={styles.rule}
y1={0}
y2={height}
x1={rule.x}
x2={rule.x}
/>
),
)}
{children}
<rect
className={styles.frame}
x={0}
y={0}
width={width}
height={height}
/>
</svg>
{notes.length ? (
<div className={styles.notes} aria-hidden="true">
{notes.map((note) => (
<span
key={note.key}
data-kind={note.kind ?? "point"}
data-corner={note.corner}
// A point's label goes on the side with room, never off the plot.
data-side={
(note.kind ?? "point") === "point" && note.x > 0.5
? "left"
: undefined
}
style={{ left: `${note.x * 100}%`, top: `${note.y * 100}%` }}
>
{note.text}
</span>
))}
</div>
) : null}
</div>
<div className={styles.xTicks} aria-hidden="true">
{xTicks.map((tick) => (
<span key={tick.value} style={{ left: `${tick.at * 100}%` }}>
{tick.label}
</span>
))}
</div>
{xTitle ? (
<span className={styles.xTitle} aria-hidden="true">
{xTitle}
</span>
) : null}
</div>
);
}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%);
}
}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];
}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.
Sourcecomponents/charts/scatter-plot/doc.ts · components/charts/scatter-plot/scatter-plot.tsx · components/charts/_kernel/scatter.ts
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 {};components/charts/scatter-plot/scatter-plot.tsx
"use client";
import type { CSSProperties } from "react";
import { TBody, Td, Th, THead, Tr } from "@/components/display/table";
import type { AnimationProp } from "@/lib/motion";
import { useTweened } from "@/lib/motion/react";
import { cn } from "@/lib/utils/cn";
import { ChartLegend } from "../chart-legend";
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/chart-data";
import chart from "../_shared/chart.module.css";
import { useChartMotion } from "../_shared/use-chart-motion";
import {
PlotFrame,
type PlotBand,
type PlotNote,
type PlotRule,
} from "../plot-frame";
export type { ScatterPoint };
type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
export type ScatterPlotProps = {
/** 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;
className?: string;
};
/** Two measures, one point each. Series differ by shape as well as colour,
* so the chart survives greyscale and colour-vision differences. */
export function ScatterPlot({
label,
points,
series,
xTitle = "X",
yTitle = "Y",
quadrants,
labelled = [],
aspect = 1.6,
formatValue = formatExact,
animation,
className,
}: ScatterPlotProps) {
const { rootProps, update } = useChartMotion(animation, {
enter: "pop",
axis: "y",
});
const model = scatterTarget(points, {
x: quadrants ? [quadrants.x] : [],
y: quadrants ? [quadrants.y] : [],
});
const shown = useTweened(model.target, update);
const plot = scatterScales(shown, model.xStep, model.yStep, aspect);
const order =
series ?? [...new Set(points.map((p) => p.series ?? ""))].filter(Boolean);
const named = new Set(labelled);
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[`${p.id}|x`] !== undefined)
.map((p) => ({
key: p.id,
x: plot.x(shown[`${p.id}|x`]) / plot.width,
y: plot.y(shown[`${p.id}|y`]) / plot.height,
text: p.label ?? p.id,
})),
),
);
return (
<div {...rootProps} className={cn(chart.root, className)}>
{order.length > 1 ? (
<ChartLegend
items={order.map((name) => ({
key: name,
label: name,
color: categoryColor(order, name),
shape: seriesShape(order, name),
}))}
/>
) : null}
{points.length === 0 ? (
<p className={chart.empty}>No data to display.</p>
) : model.valid.length === 0 ? (
<p className={chart.empty}>Measurements unavailable.</p>
) : (
<PlotFrame
label={label}
aspect={aspect}
xTicks={plot.xTicks}
yTicks={plot.yTicks}
xTitle={xTitle}
yTitle={yTitle}
rules={rules}
bands={bands}
notes={notes}
>
{model.valid.map((p) => {
const x = shown[`${p.id}|x`];
const y = shown[`${p.id}|y`];
if (x === undefined || y === undefined) return null;
// Positioned by the group, so an entrance can scale the shape
// about its own centre without losing its place.
return (
<g key={p.id} transform={`translate(${plot.x(x)} ${plot.y(y)})`}>
<path
data-mark
className={chart.shape}
style={
{
"--series": categoryColor(order, p.series),
} as CSSProperties
}
d={shapePath(seriesShape(order, p.series), 6)}
/>
</g>
);
})}
</PlotFrame>
)}
{points.length ? (
<ChartData label={label}>
<THead>
<Tr>
<Th>Point</Th>
{order.length ? <Th>Series</Th> : null}
<Th numeric>{xTitle}</Th>
<Th numeric>{yTitle}</Th>
</Tr>
</THead>
<TBody>
{points.map((p) => (
<Tr key={p.id}>
<Th scope="row">{p.label ?? p.id}</Th>
{order.length ? <Td>{p.series ?? "—"}</Td> : null}
<Td numeric>
{Number.isFinite(p.x) ? formatValue(p.x) : "Unavailable"}
</Td>
<Td numeric>
{Number.isFinite(p.y) ? formatValue(p.y) : "Unavailable"}
</Td>
</Tr>
))}
</TBody>
</ChartData>
) : null}
</div>
);
}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.
Sourcecomponents/charts/bubble-plot/doc.ts · components/charts/bubble-plot/bubble-plot.tsx · components/charts/_kernel/scatter.ts · components/charts/_kernel/encode.ts
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 {};components/charts/bubble-plot/bubble-plot.tsx
"use client";
import type { CSSProperties } from "react";
import { TBody, Td, Th, THead, Tr } from "@/components/display/table";
import type { AnimationProp } from "@/lib/motion";
import { useTweened } from "@/lib/motion/react";
import { cn } from "@/lib/utils/cn";
import { ChartLegend } from "../chart-legend";
import { formatExact } from "../_kernel/format";
import {
bubbleRadius,
bubbleTarget,
categoryColor,
scatterScales,
type Bubble,
} from "../_kernel/scatter";
import { ChartData } from "../_shared/chart-data";
import chart from "../_shared/chart.module.css";
import { useChartMotion } from "../_shared/use-chart-motion";
import { PlotFrame } from "../plot-frame";
export type { Bubble };
export type BubblePlotProps = {
/** Names the chart. */
label: string;
bubbles: readonly Bubble[];
/** The categories in a FIXED order, which fixes their colours: at most
* four hues, then neutral. */
categories?: readonly string[];
xTitle?: string;
yTitle?: string;
/** What the bubble size is, for the table and legend note. */
weightTitle?: string;
aspect?: number;
formatValue?: (value: number) => string;
/** Default: bubbles pop in, when scrolled into view. */
animation?: AnimationProp;
className?: string;
};
/** Two measures by position and a third by AREA — not radius, which would
* make the large ones read as their square. */
export function BubblePlot({
label,
bubbles,
categories,
xTitle = "X",
yTitle = "Y",
weightTitle = "Size",
aspect = 1.6,
formatValue = formatExact,
animation,
className,
}: BubblePlotProps) {
const { rootProps, update } = useChartMotion(animation, {
enter: "pop",
axis: "y",
});
const model = bubbleTarget(bubbles);
const shown = useTweened(model.target, update);
const plot = scatterScales(shown, model.xStep, model.yStep, aspect);
const order =
categories ??
[...new Set(bubbles.map((b) => b.category ?? ""))].filter(Boolean);
// Largest first, so small bubbles are drawn on top and stay visible.
const drawn = [...model.valid].sort(
(a, b) => (shown[`${b.id}|w`] ?? 0) - (shown[`${a.id}|w`] ?? 0),
);
return (
<div {...rootProps} className={cn(chart.root, className)}>
{order.length > 1 ? (
<ChartLegend
items={order.map((category) => ({
key: category,
label: category,
color: categoryColor(order, category),
}))}
/>
) : null}
{bubbles.length === 0 ? (
<p className={chart.empty}>No data to display.</p>
) : model.valid.length === 0 ? (
<p className={chart.empty}>Measurements unavailable.</p>
) : (
<PlotFrame
label={label}
aspect={aspect}
xTicks={plot.xTicks}
yTicks={plot.yTicks}
xTitle={xTitle}
yTitle={yTitle}
>
{drawn.map((b) => {
const x = shown[`${b.id}|x`];
const y = shown[`${b.id}|y`];
const w = shown[`${b.id}|w`];
if (x === undefined || y === undefined || w === undefined)
return null;
return (
<circle
key={b.id}
data-mark
className={chart.bubble}
style={
{
"--series": categoryColor(order, b.category),
} as CSSProperties
}
cx={plot.x(x)}
cy={plot.y(y)}
r={bubbleRadius(shown, w)}
/>
);
})}
</PlotFrame>
)}
{bubbles.length ? (
<ChartData label={label}>
<THead>
<Tr>
<Th>Item</Th>
<Th>Category</Th>
<Th numeric>{xTitle}</Th>
<Th numeric>{yTitle}</Th>
<Th numeric>{weightTitle}</Th>
</Tr>
</THead>
<TBody>
{bubbles.map((b) => (
<Tr key={b.id}>
<Th scope="row">{b.label ?? b.id}</Th>
<Td>{b.category ?? "—"}</Td>
{[b.x, b.y, b.weight].map((value, index) => (
<Td key={index} numeric>
{Number.isFinite(value) && (index < 2 || value >= 0)
? formatValue(value)
: "Unavailable"}
</Td>
))}
</Tr>
))}
</TBody>
</ChartData>
) : null}
</div>
);
}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];
}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.
Sourcecomponents/charts/box-plot/doc.ts · components/charts/box-plot/box-plot.tsx · components/charts/box-plot/box-plot.module.css · components/charts/_kernel/box.ts
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 {};components/charts/box-plot/box-plot.tsx
"use client";
import type { CSSProperties } from "react";
import { TBody, Td, Th, THead, Tr } from "@/components/display/table";
import type { AnimationProp } from "@/lib/motion";
import { useTweened } from "@/lib/motion/react";
import { cn } from "@/lib/utils/cn";
import { boxDomain, statsOf, type BoxStats } 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/cartesian-plot";
import { ChartInspector } from "../_shared/chart-inspector";
import { ChartData } from "../_shared/chart-data";
import chart from "../_shared/chart.module.css";
import { SeriesLegend } from "../_shared/series-legend";
import { useChartMotion } from "../_shared/use-chart-motion";
import styles from "./box-plot.module.css";
export type { BoxStats };
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>
>;
};
export type BoxPlotProps = {
/** 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;
className?: string;
};
const STATS = ["min", "q1", "median", "q3", "max"] as const;
/** 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. Raw values are summarised the same way
* everywhere; the axis shows spread, so it does not start at zero. */
export function BoxPlot({
label,
data,
series,
formatValue = formatExact,
formatTick: tickFormat = formatTick,
height,
animation,
inspect = true,
className,
}: BoxPlotProps) {
const { rootProps, update } = useChartMotion(animation, {
enter: "grow",
axis: "y",
});
const stats = data.map((group) =>
series.map((entry) => statsOf(group.values[entry.key])),
);
const { domain, step } = boxDomain(stats.flat());
const target: Record<string, number> = { __lo: domain[0], __hi: domain[1] };
stats.forEach((row, g) =>
row.forEach((s, k) => {
if (s) for (const stat of STATS) target[`${g}|${k}|${stat}`] = s[stat];
}),
);
const shown = useTweened(target, update);
if (!data.length || !series.length)
return (
<div className={cn(chart.root, className)}>
<p className={chart.empty}>No data to display.</p>
</div>
);
const { y, ticks } = yAxis([shown.__lo, shown.__hi], step, tickFormat);
const outer = band(data.length, 0.3);
const inner = band(series.length, 0.18, outer.width);
const measured = stats.flat().some(Boolean);
return (
<div {...rootProps} className={cn(chart.root, className)}>
{series.length > 1 ? (
<SeriesLegend series={series} lines={false} />
) : null}
{measured ? (
<CartesianPlot
label={label}
ticks={ticks}
height={height}
overlay={
inspect ? (
<ChartInspector
label={label}
positions={data.map((_, index) => px(outer.centre(index)))}
band={px(outer.step)}
readout={(index) =>
boxReadout(
data[index].label,
series,
stats[index],
formatValue,
)
}
/>
) : null
}
xLabels={xLabels(
data.map((group) => group.label),
data.map((_, index) => outer.centre(index)),
)}
>
{stats.flatMap((row, g) =>
row.map((s, k) => {
if (!s) return null;
const at = (stat: (typeof STATS)[number]) =>
px(y(shown[`${g}|${k}|${stat}`] ?? s[stat]));
const x = outer.start(g) + inner.start(k);
const mid = px(x + inner.width / 2);
const cap = inner.width * 0.25;
return (
<g
key={`${g}-${k}`}
data-mark
className={styles.box}
style={
{ "--series": seriesColor(series[k], k) } as CSSProperties
}
>
<line
className={styles.whisker}
x1={mid}
x2={mid}
y1={at("max")}
y2={at("q3")}
/>
<line
className={styles.whisker}
x1={mid}
x2={mid}
y1={at("q1")}
y2={at("min")}
/>
<line
className={styles.whisker}
x1={px(mid - cap)}
x2={px(mid + cap)}
y1={at("max")}
y2={at("max")}
/>
<line
className={styles.whisker}
x1={px(mid - cap)}
x2={px(mid + cap)}
y1={at("min")}
y2={at("min")}
/>
<rect
className={styles.body}
x={px(x)}
width={px(inner.width)}
y={at("q3")}
height={px(Math.max(0, at("q1") - at("q3")))}
/>
<line
className={styles.median}
x1={px(x)}
x2={px(x + inner.width)}
y1={at("median")}
y2={at("median")}
/>
{(s.outliers ?? []).map((value, i) => (
<path
key={i}
className={styles.outlier}
d={`M${mid},${px(y(value))}h0`}
/>
))}
</g>
);
}),
)}
</CartesianPlot>
) : (
<p className={chart.empty}>Measurements unavailable.</p>
)}
<ChartData label={label}>
<THead>
<Tr>
<Th>Group</Th>
{series.length > 1 ? <Th>Series</Th> : null}
<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>
{stats.flatMap((row, g) =>
row.map((s, k) => (
<Tr key={`${g}-${k}`}>
<Th scope="row">{data[g].label}</Th>
{series.length > 1 ? <Td>{series[k].label}</Td> : null}
<Td numeric>{s?.n ?? "—"}</Td>
{STATS.map((stat) => (
<Td key={stat} numeric>
{s ? formatValue(s[stat]) : "Unavailable"}
</Td>
))}
<Td numeric>
{s?.outliers?.length
? s.outliers.map(formatValue).join(", ")
: "—"}
</Td>
</Tr>
)),
)}
</TBody>
</ChartData>
</div>
);
}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;
}
}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 |
Sourcecomponents/charts/ranked-bar/doc.ts · components/charts/ranked-bar/ranked-bar.tsx · components/charts/_kernel/ranked.ts
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 {};components/charts/ranked-bar/ranked-bar.tsx
"use client";
import type { CSSProperties } from "react";
import { TBody, Td, Th, THead, Tr } from "@/components/display/table";
import type { AnimationProp } from "@/lib/motion";
import { useTweened } from "@/lib/motion/react";
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/cartesian-plot";
import { ChartInspector } from "../_shared/chart-inspector";
import { ChartData } from "../_shared/chart-data";
import chart from "../_shared/chart.module.css";
import { useChartMotion } from "../_shared/use-chart-motion";
export type { RankedDatum };
export type RankedBarProps = {
/** 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;
className?: string;
};
/** 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. */
export function RankedBar({
label,
data,
valueTitle = "Value",
color = 1,
formatValue = formatExact,
height,
animation,
inspect = true,
className,
}: RankedBarProps) {
const { rootProps, update } = useChartMotion(animation, {
enter: "grow",
axis: "y",
});
const { measured, unmeasured } = rank(data);
const { domain, step } = niceDomain(measured.map((item) => item.value));
const target = { ...rankedTarget(measured), __hi: domain[1] };
const shown = useTweened(target, update);
const { y, ticks } = yAxis([0, shown.__hi], step, formatTick);
const slots = band(Math.max(1, measured.length), 0.28);
const zero = y(0);
const bars = measured.flatMap((item) => {
const value = shown[`${item.id}|v`];
const index = shown[`${item.id}|i`];
if (value === undefined || index === undefined) return [];
return [{ item, x: slots.start(index), top: y(value) }];
});
return (
<div
{...rootProps}
className={cn(chart.root, className)}
style={{ "--series": colorVar(color) } as CSSProperties}
>
{data.length === 0 ? (
<p className={chart.empty}>No data to display.</p>
) : measured.length === 0 ? (
<p className={chart.empty}>Measurements unavailable.</p>
) : (
<CartesianPlot
label={label}
ticks={ticks}
height={height}
angled
overlay={
inspect ? (
<ChartInspector
label={label}
positions={measured.map((_, index) => px(slots.centre(index)))}
band={px(slots.step)}
readout={(index) =>
rankedReadout(measured[index], index, measured.length, {
title: valueTitle,
color: colorVar(color),
format: formatValue,
})
}
/>
) : null
}
xLabels={measured.map((item, index) => ({
key: item.id,
at: px(slots.centre(index)),
label: item.label,
align: "middle",
tier: 2,
}))}
notes={bars.map((bar) => ({
key: bar.item.id,
x: (bar.x + slots.width / 2) / PLOT,
y: bar.top / PLOT,
text: formatValue(bar.item.value),
}))}
>
{bars.map((bar) => (
<rect
key={bar.item.id}
data-mark
className={chart.column}
x={px(bar.x)}
width={px(slots.width)}
y={px(bar.top)}
height={px(Math.max(0, zero - bar.top))}
/>
))}
</CartesianPlot>
)}
{data.length ? (
<ChartData label={label}>
<THead>
<Tr>
<Th>Rank</Th>
<Th>Item</Th>
<Th numeric>{valueTitle}</Th>
</Tr>
</THead>
<TBody>
{measured.map((item, index) => (
<Tr key={item.id}>
<Td>{index + 1}</Td>
<Th scope="row">{item.label}</Th>
<Td numeric>{formatValue(item.value)}</Td>
</Tr>
))}
{unmeasured.map((item) => (
<Tr key={item.id}>
<Td>—</Td>
<Th scope="row">{item.label}</Th>
<Td numeric>Unavailable</Td>
</Tr>
))}
</TBody>
</ChartData>
) : null}
</div>
);
}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.
Sourcecomponents/charts/matrix/doc.ts · components/charts/matrix/matrix.tsx · components/charts/matrix/matrix.module.css · components/charts/_kernel/matrix.ts
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 {};components/charts/matrix/matrix.tsx
"use client";
import {
useRef,
useState,
type CSSProperties,
type KeyboardEvent,
} from "react";
import { VisuallyHidden } from "@/components/utility/visually-hidden";
import type { AnimationProp } from "@/lib/motion";
import { useTweened } from "@/lib/motion/react";
import { cn } from "@/lib/utils/cn";
import { formatExact } from "../_kernel/format";
import {
cellFill,
cellState,
coverageOf,
describeCell,
matrixExtent,
sampleMatrix,
type Cell,
} from "../_kernel/matrix";
import chart from "../_shared/chart.module.css";
import { useChartMotion } from "../_shared/use-chart-motion";
import styles from "./matrix.module.css";
export type { Cell };
export type MatrixProps = {
/** 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); off, they are read by assistive
* technology only. */
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;
className?: string;
};
/** Every coverage figure: checked over applicable, or null when nothing
* applies. */
export function coverage(
rows: readonly string[],
columns: readonly string[],
cell: (row: string, column: string) => Cell,
) {
return coverageOf(sampleMatrix(rows, columns, cell));
}
/**
* 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.
*/
export function Matrix({
label,
rows,
columns,
cell,
ramp = "sequential",
midpoint = 0,
extent,
formatValue = formatExact,
showValues = true,
onSelect,
selected = null,
animation,
className,
}: MatrixProps) {
const { rootProps, update } = useChartMotion(animation, {
enter: "fade",
axis: "y",
});
const cells = sampleMatrix(rows, columns, cell);
const span = extent ?? matrixExtent(cells, midpoint);
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;
}),
);
const shown = useTweened(target, update);
// Roving focus: one tab stop for the whole grid; arrows move within it.
const [active, setActive] = useState<[number, number]>([0, 0]);
const table = useRef<HTMLTableElement>(null);
const 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();
setActive(to);
table.current
?.querySelector<HTMLButtonElement>(`[data-cell="${to[0]}-${to[1]}"]`)
?.focus();
};
if (!rows.length || !columns.length)
return (
<div className={cn(chart.root, className)}>
<p className={chart.empty}>No data to display.</p>
</div>
);
return (
<div {...rootProps} className={cn(chart.root, className)}>
<div className={styles.scroll}>
<table
ref={table}
className={styles.table}
style={
{
"--row-head-ch": Math.min(
24,
Math.max(...rows.map((r) => r.length)),
),
"--columns": columns.length,
} as CSSProperties
}
onKeyDown={onSelect ? move : undefined}
>
<caption>
<VisuallyHidden>{label}</VisuallyHidden>
</caption>
<thead>
<tr>
<td className={styles.corner} />
{columns.map((column) => (
<th key={column} scope="col" className={styles.columnHead}>
{column}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, r) => (
<tr key={row}>
<th scope="row" className={styles.rowHead} title={row}>
{row}
</th>
{columns.map((column, k) => {
const c = cells[r][k];
const state = cellState(c);
const value = shown[`${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 text = describeCell(c, formatValue);
const visible =
state === "value"
? showValues
? formatValue(value ?? 0)
: ""
: state === "absent"
? "0"
: "";
const content = (
<>
<span aria-hidden="true">{visible}</span>
<VisuallyHidden>{text}</VisuallyHidden>
</>
);
const props = {
"data-mark": true,
"data-state": state,
"data-strong": strong || undefined,
className: styles.swatch,
style: fill ? { background: fill } : undefined,
};
const key = `${row}|${column}`;
return (
<td key={column} className={styles.cell}>
{onSelect ? (
<button
type="button"
{...props}
data-cell={`${r}-${k}`}
tabIndex={active[0] === r && active[1] === k ? 0 : -1}
aria-pressed={selected === key}
onFocus={() => setActive([r, k])}
onClick={() => onSelect(row, column)}
>
{content}
</button>
) : (
<span {...props}>{content}</span>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
/** What the non-value cells mean. Any screen using Matrix shows it, or the
* distinctions the matrix keeps cannot be read. */
export function MatrixKey({ className }: { className?: string }) {
return (
<ul className={cn(styles.key, className)} aria-label="Cell key">
<li>
<span
className={styles.swatch}
data-state="absent"
aria-hidden="true"
/>
Checked, none found
</li>
<li>
<span
className={styles.swatch}
data-state="unattempted"
aria-hidden="true"
/>
Not checked
</li>
<li>
<span className={styles.swatch} data-state="na" aria-hidden="true" />
Not applicable — excluded from coverage
</li>
</ul>
);
}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;
}
}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.
Sourcecomponents/charts/matrix/doc.ts · components/charts/matrix/matrix.tsx · components/charts/matrix/matrix.module.css · components/charts/_kernel/matrix.ts
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 {};components/charts/matrix/matrix.tsx
"use client";
import {
useRef,
useState,
type CSSProperties,
type KeyboardEvent,
} from "react";
import { VisuallyHidden } from "@/components/utility/visually-hidden";
import type { AnimationProp } from "@/lib/motion";
import { useTweened } from "@/lib/motion/react";
import { cn } from "@/lib/utils/cn";
import { formatExact } from "../_kernel/format";
import {
cellFill,
cellState,
coverageOf,
describeCell,
matrixExtent,
sampleMatrix,
type Cell,
} from "../_kernel/matrix";
import chart from "../_shared/chart.module.css";
import { useChartMotion } from "../_shared/use-chart-motion";
import styles from "./matrix.module.css";
export type { Cell };
export type MatrixProps = {
/** 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); off, they are read by assistive
* technology only. */
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;
className?: string;
};
/** Every coverage figure: checked over applicable, or null when nothing
* applies. */
export function coverage(
rows: readonly string[],
columns: readonly string[],
cell: (row: string, column: string) => Cell,
) {
return coverageOf(sampleMatrix(rows, columns, cell));
}
/**
* 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.
*/
export function Matrix({
label,
rows,
columns,
cell,
ramp = "sequential",
midpoint = 0,
extent,
formatValue = formatExact,
showValues = true,
onSelect,
selected = null,
animation,
className,
}: MatrixProps) {
const { rootProps, update } = useChartMotion(animation, {
enter: "fade",
axis: "y",
});
const cells = sampleMatrix(rows, columns, cell);
const span = extent ?? matrixExtent(cells, midpoint);
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;
}),
);
const shown = useTweened(target, update);
// Roving focus: one tab stop for the whole grid; arrows move within it.
const [active, setActive] = useState<[number, number]>([0, 0]);
const table = useRef<HTMLTableElement>(null);
const 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();
setActive(to);
table.current
?.querySelector<HTMLButtonElement>(`[data-cell="${to[0]}-${to[1]}"]`)
?.focus();
};
if (!rows.length || !columns.length)
return (
<div className={cn(chart.root, className)}>
<p className={chart.empty}>No data to display.</p>
</div>
);
return (
<div {...rootProps} className={cn(chart.root, className)}>
<div className={styles.scroll}>
<table
ref={table}
className={styles.table}
style={
{
"--row-head-ch": Math.min(
24,
Math.max(...rows.map((r) => r.length)),
),
"--columns": columns.length,
} as CSSProperties
}
onKeyDown={onSelect ? move : undefined}
>
<caption>
<VisuallyHidden>{label}</VisuallyHidden>
</caption>
<thead>
<tr>
<td className={styles.corner} />
{columns.map((column) => (
<th key={column} scope="col" className={styles.columnHead}>
{column}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, r) => (
<tr key={row}>
<th scope="row" className={styles.rowHead} title={row}>
{row}
</th>
{columns.map((column, k) => {
const c = cells[r][k];
const state = cellState(c);
const value = shown[`${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 text = describeCell(c, formatValue);
const visible =
state === "value"
? showValues
? formatValue(value ?? 0)
: ""
: state === "absent"
? "0"
: "";
const content = (
<>
<span aria-hidden="true">{visible}</span>
<VisuallyHidden>{text}</VisuallyHidden>
</>
);
const props = {
"data-mark": true,
"data-state": state,
"data-strong": strong || undefined,
className: styles.swatch,
style: fill ? { background: fill } : undefined,
};
const key = `${row}|${column}`;
return (
<td key={column} className={styles.cell}>
{onSelect ? (
<button
type="button"
{...props}
data-cell={`${r}-${k}`}
tabIndex={active[0] === r && active[1] === k ? 0 : -1}
aria-pressed={selected === key}
onFocus={() => setActive([r, k])}
onClick={() => onSelect(row, column)}
>
{content}
</button>
) : (
<span {...props}>{content}</span>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
/** What the non-value cells mean. Any screen using Matrix shows it, or the
* distinctions the matrix keeps cannot be read. */
export function MatrixKey({ className }: { className?: string }) {
return (
<ul className={cn(styles.key, className)} aria-label="Cell key">
<li>
<span
className={styles.swatch}
data-state="absent"
aria-hidden="true"
/>
Checked, none found
</li>
<li>
<span
className={styles.swatch}
data-state="unattempted"
aria-hidden="true"
/>
Not checked
</li>
<li>
<span className={styles.swatch} data-state="na" aria-hidden="true" />
Not applicable — excluded from coverage
</li>
</ul>
);
}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;
}
}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.
Sourcecomponents/charts/colour-bar/doc.ts · components/charts/colour-bar/colour-bar.tsx · components/charts/colour-bar/colour-bar.module.css
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 {};components/charts/colour-bar/colour-bar.tsx
import type { CSSProperties } from "react";
import { divergingFill, sequentialFill } from "../_kernel/encode";
import { formatTick } from "../_kernel/format";
import { VisuallyHidden } from "@/components/utility/visually-hidden";
import { cn } from "@/lib/utils/cn";
import styles from "./colour-bar.module.css";
export type ColourBarProps = {
/** 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;
className?: string;
};
/** The scale a colour-coded chart uses, in steps rather than a smooth
* gradient: a gradient invites reading a precise value off a smear, and the
* eye resolves only a handful of levels anyway. */
export function ColourBar({
label,
kind = "sequential",
domain,
midpoint = 0,
steps: requested = 9,
formatValue = formatTick,
width,
className,
}: ColourBarProps) {
const steps = Math.max(2, Math.min(32, Math.floor(requested) || 9));
const [lo, hi] = domain;
const extent = Math.max(
1e-9,
Math.abs(lo - midpoint),
Math.abs(hi - midpoint),
);
const fills = 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 = hi === lo ? 0.5 : (midpoint - lo) / (hi - lo);
return (
<figure
className={cn(styles.root, className)}
style={{ "--bar-w": width } as CSSProperties}
>
<div className={styles.steps} aria-hidden="true">
{fills.map((fill, index) => (
<span key={index} style={{ background: fill }} />
))}
</div>
<div className={styles.ticks} aria-hidden="true">
<span style={{ left: 0 }}>{formatValue(lo)}</span>
{kind === "diverging" && mid > 0.15 && mid < 0.85 ? (
<span style={{ left: `${mid * 100}%` }}>{formatValue(midpoint)}</span>
) : null}
<span style={{ left: "100%" }}>{formatValue(hi)}</span>
</div>
<figcaption className={styles.caption}>
{label}
<VisuallyHidden>
{` — from ${formatValue(lo)}${kind === "diverging" ? ` through ${formatValue(midpoint)}` : ""} to ${formatValue(hi)}`}
</VisuallyHidden>
</figcaption>
</figure>
);
}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% |