A distribution per position (a box plot, box-and-whisker): quartiles, median and whiskers, per service, per group or per hour. Reach for it to compare spreads, not single values.
import { useChart } from "@umriss-ui/charts";
Every number is the caller's: median, the two quartiles and where each whisker ends. The library computes none of them and names no quartile method or whisker rule, so a box aggregated in a database draws as well as one computed in the browser.
A box stands on the x axis as a bar does: a group is a category the axis reads from the row, and a time axis carries a box per hour (ADR-0052). A missing median is a gap - no box is drawn there.
Five numbers per row, computed wherever the caller likes. The x axis reads the service's name: each one is a category with a place and a tick of its own.
import { Tooltip, useChart } from "@umriss-ui/charts";
interface ResponseTime {
service: string;
low: number;
q1: number;
median: number;
q3: number;
high: number;
}
/* Milliseconds per request over the last hour, aggregated by the database. */
const RESPONSE_TIMES: ResponseTime[] = [
{ service: "Checkout", low: 112, q1: 138, median: 149, q3: 161, high: 195 },
{ service: "Billing", low: 85, q1: 121, median: 140, q3: 173, high: 238 },
{ service: "Search", low: 140, q1: 152, median: 158, q3: 165, high: 181 },
];
export default function Minimal() {
const { Chart, XAxis, YAxis, BoxPlot } = useChart(RESPONSE_TIMES);
return (
<Chart height={260} ariaLabel="Response time per service over the last hour">
<XAxis value="service" />
<YAxis tickFormat={(v) => `${v} ms`} />
<BoxPlot
name="Response time"
median="median"
lowerQuartile="q1"
upperQuartile="q3"
lowerWhisker="low"
upperWhisker="high"
/>
<Tooltip />
</Chart>
);
}
Checkout's median latency, every five minutes, gathered into one box per hour. The quartiles and the whisker rule are the caller's - here the last value within 1.5 IQR - and the library draws what it is given.
import { Tooltip, useChart } from "@umriss-ui/charts";
/* Data from the operations world, written out here so the example runs on its own. */
/** A small LCG - the same numbers on every computer. */
function random(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
const MINUTE = 60_000;
/** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
const NOW = at(17, 10, 30);
interface Service {
id: string;
name: string;
team: string;
/** 1 is customer-facing and pages at night; 3 waits for the morning. */
tier: 1 | 2 | 3;
/** The latency objective: the 95th percentile stays below this, in ms. */
latencySlo: number;
/** The availability promised for a month, in per cent. */
availabilityTarget: number;
}
const SERVICES: readonly Service[] = [
{ id: "checkout", name: "Checkout", team: "Payments", tier: 1, latencySlo: 300, availabilityTarget: 99.95 },
{ id: "billing", name: "Billing", team: "Payments", tier: 1, latencySlo: 400, availabilityTarget: 99.9 },
{ id: "sign-in", name: "Sign-in", team: "Identity", tier: 1, latencySlo: 200, availabilityTarget: 99.95 },
{ id: "search", name: "Search", team: "Discovery", tier: 1, latencySlo: 250, availabilityTarget: 99.9 },
{ id: "images", name: "Image service", team: "Discovery", tier: 2, latencySlo: 500, availabilityTarget: 99.5 },
{ id: "notifications", name: "Notifications", team: "Messaging", tier: 2, latencySlo: 800, availabilityTarget: 99.5 },
{ id: "webhooks", name: "Webhooks", team: "Integrations", tier: 2, latencySlo: 1000, availabilityTarget: 99.5 },
{ id: "reports", name: "Reporting", team: "Insights", tier: 3, latencySlo: 2000, availabilityTarget: 99 },
];
interface MetricPoint {
t: number;
/** Median and 95th percentile latency, in ms. */
p50: number;
p95: number;
/** Failed requests, in per cent. */
errorRate: number;
/** Requests per minute. */
requests: number;
}
/** Requests per minute at the busiest hour, by tier. */
const VOLUME = { 1: 900, 2: 400, 3: 120 } as const;
/** A service's latency and error rate every five minutes of today, from
midnight up to now. Busy by day, quiet at night; Checkout's 95th percentile
climbs well past its objective from 09:40 to 10:10, and its errors with it. */
function metrics(serviceId: string): MetricPoint[] {
const index = SERVICES.findIndex((one) => one.id === serviceId);
const service = SERVICES[index];
if (service === undefined) throw new Error(`No service "${serviceId}".`);
const r = random(1000 + index * 37);
const points: MetricPoint[] = [];
let drift = 0;
for (let t = at(17, 0); t <= NOW; t += 5 * MINUTE) {
const hour = (t - at(17, 0)) / (60 * MINUTE);
const load = 0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16));
drift = 0.8 * drift + (r() - 0.5) * 0.08;
const base = service.latencySlo * (0.45 + 0.2 * load + drift);
const surge = serviceId === "checkout" && t >= at(17, 9, 40) && t < at(17, 10, 10) ? 1.9 : 1;
const p95 = Math.round(base * surge * (1 + r() * 0.08));
points.push({
t,
p50: Math.round(p95 * (0.42 + r() * 0.06)),
p95,
errorRate: Math.round((0.05 + r() * 0.12 + (surge > 1 ? 2.4 + r() : 0)) * 100) / 100,
requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
});
}
return points;
}
const HOUR = 3_600_000;
interface HourBox {
hour: number;
low: number;
q1: number;
median: number;
q3: number;
high: number;
}
/** The value at share p of the sorted values, between neighbours. */
function quantile(sorted: readonly number[], p: number): number {
const at = (sorted.length - 1) * p;
const lo = Math.floor(at);
return (sorted[lo] as number) + ((sorted[Math.ceil(at)] as number) - (sorted[lo] as number)) * (at - lo);
}
function hourBoxes(): HourBox[] {
const byHour = new Map<number, number[]>();
for (const point of metrics("checkout")) {
const hour = Math.floor(point.t / HOUR) * HOUR;
byHour.set(hour, [...(byHour.get(hour) ?? []), point.p50]);
}
return [...byHour].map(([hour, values]) => {
const sorted = values.sort((a, b) => a - b);
const q1 = quantile(sorted, 0.25);
const q3 = quantile(sorted, 0.75);
const reach = 1.5 * (q3 - q1);
const inside = sorted.filter((v) => v >= q1 - reach && v <= q3 + reach);
return { hour, low: inside[0] as number, q1, median: quantile(sorted, 0.5), q3, high: inside[inside.length - 1] as number };
});
}
const BOXES = hourBoxes();
export default function OverTime() {
const { Chart, XAxis, YAxis, BoxPlot } = useChart(BOXES);
return (
<Chart height={280} ariaLabel="Checkout median latency per hour today">
<XAxis value="hour" time />
<YAxis tickFormat={(v) => `${v} ms`} />
<BoxPlot
name="Checkout p50"
median="median"
lowerQuartile="q1"
upperQuartile="q3"
lowerWhisker="low"
upperWhisker="high"
/>
<Tooltip />
</Chart>
);
}
outliers returns the values beyond the whiskers, a list per box. They are drawn with their box and read in its tooltip, the first five written out; one more than three IQR beyond its box is a ring.
import { Tooltip, useChart } from "@umriss-ui/charts";
interface ResponseTime {
service: string;
low: number;
q1: number;
median: number;
q3: number;
high: number;
outliers: number[];
}
/* Milliseconds per request over the last hour. The whiskers end at the last
value within 1.5 IQR; the database returned everything beyond as outliers. */
const RESPONSE_TIMES: ResponseTime[] = [
{ service: "Checkout", low: 112, q1: 138, median: 149, q3: 161, high: 195, outliers: [224, 98] },
{ service: "Billing", low: 85, q1: 121, median: 140, q3: 173, high: 238, outliers: [281, 395] },
{ service: "Search", low: 140, q1: 152, median: 158, q3: 165, high: 181, outliers: [189, 214, 104] },
{ service: "Images", low: 99, q1: 126, median: 137, q3: 150, high: 186, outliers: [193, 198, 204, 210, 222, 235, 259, 71] },
];
export default function WithOutliers() {
const { Chart, XAxis, YAxis, BoxPlot } = useChart(RESPONSE_TIMES);
return (
<Chart height={300} ariaLabel="Response time per service over the last hour, with outliers">
<XAxis value="service" />
<YAxis tickFormat={(v) => `${v} ms`} />
<BoxPlot
name="Response time"
median="median"
lowerQuartile="q1"
upperQuartile="q3"
lowerWhisker="low"
upperWhisker="high"
outliers="outliers"
/>
<Tooltip />
</Chart>
);
}
Where two boxes' notches do not overlap, their medians differ. The bounds are yours - here the usual median ± 1.57 · IQR / √n. mean draws a small ×: beside the median it shows a skew. count is read, not drawn.
import { DataTable, Legend, Tooltip, useChart } from "@umriss-ui/charts";
interface Batch {
supplier: string;
low: number;
q1: number;
median: number;
q3: number;
high: number;
mean: number;
n: number;
}
/* Breaking strength of tiles, in newtons, per clay supplier. */
const BATCHES: Batch[] = [
{ supplier: "Hollen", low: 1310, q1: 1395, median: 1430, q3: 1468, high: 1540, mean: 1431, n: 120 },
{ supplier: "Marsk", low: 1290, q1: 1380, median: 1418, q3: 1462, high: 1555, mean: 1426, n: 64 },
{ supplier: "Velde", low: 1240, q1: 1322, median: 1352, q3: 1398, high: 1490, mean: 1371, n: 96 },
];
const notch = (d: Batch) => (1.57 * (d.q3 - d.q1)) / Math.sqrt(d.n);
export default function MeanAndNotches() {
const { Chart, XAxis, YAxis, BoxPlot } = useChart(BATCHES);
return (
<Chart height={300} ariaLabel="Breaking strength per clay supplier, with mean and notches">
<XAxis value="supplier" />
<YAxis tickFormat={(v) => `${v} N`} />
<BoxPlot
name="Breaking strength"
median="median"
lowerQuartile="q1"
upperQuartile="q3"
lowerWhisker="low"
upperWhisker="high"
mean="mean"
notchLower={(d) => d.median - notch(d)}
notchUpper={(d) => d.median + notch(d)}
count="n"
format={(v) => `${Math.round(v)} N`}
boxWidth={0.5}
/>
<Legend />
<Tooltip />
<DataTable />
</Chart>
);
}
Box series on one x axis stand beside each other, as bars do. A click in the legend hides one; it leaves the drawing and the y extent.
import { Legend, Tooltip, useChart } from "@umriss-ui/charts";
interface Spread {
service: string;
low: number;
q1: number;
median: number;
q3: number;
high: number;
}
/* Milliseconds per request, the week before and the week after the release. */
const BEFORE: Spread[] = [
{ service: "Checkout", low: 112, q1: 138, median: 149, q3: 161, high: 195 },
{ service: "Billing", low: 85, q1: 121, median: 140, q3: 173, high: 238 },
{ service: "Search", low: 140, q1: 152, median: 158, q3: 165, high: 181 },
{ service: "Images", low: 99, q1: 126, median: 137, q3: 150, high: 186 },
];
const AFTER: Spread[] = [
{ service: "Checkout", low: 118, q1: 135, median: 143, q3: 152, high: 176 },
{ service: "Billing", low: 101, q1: 119, median: 128, q3: 139, high: 164 },
{ service: "Search", low: 142, q1: 151, median: 157, q3: 164, high: 179 },
{ service: "Images", low: 103, q1: 124, median: 135, q3: 146, high: 172 },
];
export default function Grouped() {
const { Chart, XAxis, YAxis, BoxPlot } = useChart(BEFORE);
return (
<Chart height={280} ariaLabel="Response time per service before and after the release">
<XAxis value="service" />
<YAxis tickFormat={(v) => `${v} ms`} />
{[
{ name: "Before", data: BEFORE },
{ name: "After", data: AFTER },
].map((one) => (
<BoxPlot
key={one.name}
name={one.name}
data={one.data}
median="median"
lowerQuartile="q1"
upperQuartile="q3"
lowerWhisker="low"
upperWhisker="high"
/>
))}
<Legend />
<Tooltip />
</Chart>
);
}
A LimitLine shows what was promised. Whether a box breaks it is your judgement, not the library's: this week's whiskers reach past the objective in every region, so its series carries tone="alarm".
import { Legend, LimitLine, Tooltip, useChart } from "@umriss-ui/charts";
interface Spread {
region: string;
low: number;
q1: number;
median: number;
q3: number;
high: number;
}
/* Checkout's 95th percentile per five minutes, in ms, per region and week. */
const LAST_WEEK: Spread[] = [
{ region: "Europe", low: 182, q1: 204, median: 216, q3: 229, high: 262 },
{ region: "Americas", low: 191, q1: 213, median: 226, q3: 240, high: 276 },
{ region: "Asia", low: 176, q1: 199, median: 211, q3: 224, high: 258 },
];
const THIS_WEEK: Spread[] = [
{ region: "Europe", low: 196, q1: 231, median: 249, q3: 268, high: 318 },
{ region: "Americas", low: 205, q1: 242, median: 262, q3: 283, high: 339 },
{ region: "Asia", low: 190, q1: 226, median: 244, q3: 263, high: 311 },
];
export default function AgainstTheObjective() {
const { Chart, XAxis, YAxis, BoxPlot } = useChart(LAST_WEEK);
return (
<Chart height={280} ariaLabel="Checkout 95th percentile per region and week against its objective">
<XAxis value="region" />
<YAxis tickFormat={(v) => `${v} ms`} />
<BoxPlot
name="Last week"
median="median"
lowerQuartile="q1"
upperQuartile="q3"
lowerWhisker="low"
upperWhisker="high"
/>
<BoxPlot
name="This week"
data={THIS_WEEK}
tone="alarm"
median="median"
lowerQuartile="q1"
upperQuartile="q3"
lowerWhisker="low"
upperWhisker="high"
/>
<LimitLine value={300} severity="alarm" label="Objective" />
<Legend />
<Tooltip />
</Chart>
);
}
The tiles measured in each of six shifts, one box each, and a Line through the medians to read the drift. The tooltip reads box and line together; DataTable lists every number as text.
import { DataTable, Legend, Tooltip, useChart } from "@umriss-ui/charts";
/* Data from the plant world: tile length per shift in millimetres, as the
quality database sends it - quartiles and median, the whiskers at the last
length within 1.5 IQR. */
const BOXES = [
{ shift: "Mon early", low: 599.72, q1: 599.855, median: 599.97, q3: 600.1575, high: 600.4 },
{ shift: "Mon late", low: 599.53, q1: 599.875, median: 600.025, q3: 600.15, high: 600.38 },
{ shift: "Tue early", low: 599.6, q1: 599.88, median: 600.015, q3: 600.1925, high: 600.39 },
{ shift: "Tue late", low: 599.59, q1: 599.89, median: 599.975, q3: 600.1475, high: 600.36 },
{ shift: "Wed early", low: 599.61, q1: 599.8175, median: 600.01, q3: 600.1775, high: 600.45 },
{ shift: "Wed late", low: 599.68, q1: 599.8575, median: 600.035, q3: 600.16, high: 600.53 },
];
const mm = (v: number) => `${v.toFixed(2)} mm`;
export default function Detailed() {
const { Chart, XAxis, YAxis, BoxPlot, Line } = useChart(BOXES);
return (
<Chart height={320} ariaLabel="Tile length per shift, with the medians as a line">
<XAxis value="shift" label="Shift" />
<YAxis tickFormat={mm} />
<BoxPlot
name="Tile length"
median="median"
lowerQuartile="q1"
upperQuartile="q3"
lowerWhisker="low"
upperWhisker="high"
boxWidth={0.5}
/>
<Line value="median" name="Median" markers="always" />
<Legend />
<Tooltip />
<DataTable />
</Chart>
);
}
The keys of Chart apply here.
BoxPlotProps<T>| Name | Type | Default | Description |
|---|---|---|---|
medianrequired | Value<T> | — | The line across the box; null/undefined/NaN/±Infinity means a gap - no
box is drawn there, whatever the other numbers say. Shown in: One box per service, A box per hour on a time axis, With outliers and 4 more |
lowerQuartilerequired | Value<T> | — | The box's lower edge. Shown in: One box per service, A box per hour on a time axis, With outliers and 4 more |
upperQuartilerequired | Value<T> | — | The box's upper edge. Shown in: One box per service, A box per hour on a time axis, With outliers and 4 more |
lowerWhiskerrequired | Value<T> | — | Where the lower whisker ends - the caller's rule, not the minimum. Shown in: One box per service, A box per hour on a time axis, With outliers and 4 more |
upperWhiskerrequired | Value<T> | — | Where the upper whisker ends - the caller's rule, not the maximum. Shown in: One box per service, A box per hour on a time axis, With outliers and 4 more |
outliers | ListValue<T> | — | The values beyond the whiskers, per box: an array, empty or missing
where there are none. Drawn with their box in its colour - one beyond
three IQR of the box's own quartiles as a ring -, read in its tooltip
and table row, never hit on their own (ADR-0040). Shown in: With outliers |
mean | Value<T> | — | The mean, drawn as a small ×: beside the median it shows a skew. Shown in: Mean and notches: do two medians differ? |
notchLower | Value<T> | — | The notch's lower bound - usually of a confidence interval of the
median. Both bounds or neither: one alone warns in DEV and is not
drawn. Shown in: Mean and notches: do two medians differ? |
notchUpper | Value<T> | — | The notch's upper bound; see notchLower.Shown in: Mean and notches: do two medians differ? |
count | Value<T> | — | How many values stand behind the box; read in the tooltip and the
table, not drawn. Shown in: Mean and notches: do two medians differ? |
xAxisId | string | "x" | Binding to an x axis. |
yAxisId | string | "y" | Binding to a y axis. |
data | readonly T[] | — | Series-own data; overrides the container data. Shown in: Before and after, side by side, Against the objective |
name | string | "Series n", after its place in the chart | The name in legend and tooltip. Without one a warning stands in DEV. Shown in: One box per service, A box per hour on a time axis, With outliers and 4 more |
format | (value: number) => string | the y axis' tickFormat, else the built-in number format | Every number of the box as the tooltip and the table write it. Shown in: Mean and notches: do two medians differ? |
color | string | the palette's --uc-series-N | Any CSS colour value. |
tone | | "ok" | — | A role instead of a colour value; the theme resolves it. color beats
it. Set by the caller: a box out of its specification is the caller's
judgement, not the library's.Shown in: Against the objective |
boxWidth | number | 0.8 | Width as a fraction of the step, as a bar's: boxes and bars on one x axis
share this fraction and stand side by side. Shown in: Mean and notches: do two medians differ?, A box per shift, its medians as a line |
Value<T>A value read from the row (ADR-0048): the name of a number field, compared
by its name, or a function, compared by its source text (ValueFunction).
type Value<T> = NumberField<T> | ValueFunction<T>;ListValue<T>A list read from the row: the name of a list field or a function.
type ListValue<T> = ListField<T> | ListFunction<T>;NumberField<T>The fields of a row whose value is a number, or absent.
type NumberField<T> = NoInfer<
{
[K in keyof T & string]-?: T[K] extends number | null | undefined ? K : never;
}[keyof T & string]
>;ValueFunction<T>A value computed from the row - the function form of Value.
Compared by its source text, not its identity - an inline function is new
on every render. Known limit: one that reads a
changed closure variable under the same text is not run again; pass a new
data reference then. A native or bound function is compared by identity.
type ValueFunction<T> = (d: T, index: number) => number | null | undefined;ListField<T>The fields of a row whose value is a list of numbers, or absent.
type ListField<T> = NoInfer<
{
[K in keyof T & string]-?: T[K] extends readonly number[] | null | undefined ? K : never;
}[keyof T & string]
>;ListFunction<T>A list computed from the row - a box's outliers (ADR-0040), the
function form of ListValue. null/undefined or an
empty array is none; a value that is not finite is left out.
type ListFunction<T> = (d: T, index: number) => readonly number[] | null | undefined;What umriss deliberately does not build, and why: ADR-0032.