# @umriss-ui/charts 0.40.0
> React canvas chart library for data-dense dashboards - line, area, bar, box plot, scatter, control and Pareto charts. Few chart kinds, drawn well, fast.
Install with `npm install @umriss-ui/charts`. This text is generated from the package's demo (https://romanhaendler.github.io/umriss-ui/charts/): every page with its import line, its examples - the source exactly as it runs, with the package name where the demo imports its own source - its props tables generated from the code, and what it deliberately does not do. The pages index stands in https://romanhaendler.github.io/umriss-ui/charts/llms.txt.
## Scenarios
Composed, realistic screens built from the package. A numbered mark on the screen is an element with `data-callout`.
### Watch latency against its objective
An on-call engineer keeps this dashboard open: every service's latency against its own objective, and the one that breached it in detail.
1. Each tile is one service's 95th percentile latency now, read against that service's objective; the sparkline is the last two hours.
2. The open incident stands beside the service it is about, so the chart below is read with it in mind.
3. The objective is a limit line: Checkout crossed it at 09:40 and came back under it at 10:10.
4. The error rate shares the pointer with the latency chart: hover one and both draw their crosshair at the same minute.
Built from: Line, LimitLine, Tooltip, Stat, Badge, Card.
```tsx
import { Badge, Card, CardBody, CardHeader, Grid, Stack, Stat } from "@umriss-ui/core";
import type { LimitSet } from "@umriss-ui/core";
import { Legend, LimitLine, 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;
}
interface Incident {
id: string;
title: string;
service: string;
severity: "SEV1" | "SEV2" | "SEV3";
opened: number;
acknowledged?: number;
resolved?: number;
/** An engineer's id. */
assignee: string;
}
const INCIDENTS: readonly Incident[] = [
{ id: "INC-1048", title: "Checkout slow, card payments time out", service: "checkout", severity: "SEV1", opened: at(17, 9, 42), acknowledged: at(17, 9, 46), assignee: "jonas" },
{ id: "INC-1047", title: "Webhook deliveries delayed", service: "webhooks", severity: "SEV3", opened: at(17, 7, 15), acknowledged: at(17, 8, 2), assignee: "ines" },
{ id: "INC-1046", title: "Thumbnails missing for new uploads", service: "images", severity: "SEV2", opened: at(16, 22, 5), acknowledged: at(16, 22, 11), resolved: at(17, 0, 40), assignee: "tomasz" },
{ id: "INC-1045", title: "Sign-in codes arrive late", service: "sign-in", severity: "SEV2", opened: at(16, 14, 20), acknowledged: at(16, 14, 24), resolved: at(16, 15, 5), assignee: "ada" },
{ id: "INC-1044", title: "Monthly report export fails", service: "reports", severity: "SEV3", opened: at(15, 10, 0), acknowledged: at(16, 9, 12), resolved: at(16, 11, 30), assignee: "felix" },
{ id: "INC-1043", title: "Search returns no results for some regions", service: "search", severity: "SEV1", opened: at(14, 18, 30), acknowledged: at(14, 18, 33), resolved: at(14, 19, 55), assignee: "leila" },
{ id: "INC-1042", title: "Duplicate reminder e-mails", service: "notifications", severity: "SEV3", opened: at(13, 8, 45), acknowledged: at(13, 9, 30), resolved: at(13, 13, 0), assignee: "sam" },
{ id: "INC-1041", title: "Invoices generated twice", service: "billing", severity: "SEV2", opened: at(12, 16, 10), acknowledged: at(12, 16, 18), resolved: at(12, 18, 40), assignee: "priya" },
];
const CHECKOUT = SERVICES.find((one) => one.id === "checkout")!;
const DETAIL = metrics(CHECKOUT.id);
const TODAY: readonly [number, number] = [DETAIL[0]!.t, NOW];
const OPEN = INCIDENTS.find((one) => one.service === CHECKOUT.id && one.resolved === undefined);
/* An alert fires above 2 % failed requests (the world's alert types). */
const ERROR_ALERT = 2;
const objective = (latencySlo: number): LimitSet => ({
limits: [
{ value: latencySlo * 0.8, side: "upper", severity: "warning" },
{ value: latencySlo, side: "upper", severity: "alarm" },
],
});
/* The last two hours, every five minutes. */
const TILES = SERVICES.map((service) => {
const points = metrics(service.id);
return { service, now: points.at(-1)!.p95, history: points.slice(-24).map((one) => one.p95) };
});
export default function WatchLatency() {
const { Chart, XAxis, YAxis, Line } = useChart(DETAIL);
// One chart per useChart: the error rate is a second call over the same rows.
const { Chart: ErrorChart } = useChart(DETAIL);
return (
{TILES.map(({ service, now, history }) => (
))}
{OPEN.id} {OPEN.severity} open
)
}
/>
{/* Tick labels as wide as the error rate's keep the two crosshairs in one column. */}
v.toFixed(1)} />
);
}
```
### See where the vans stand idle
A fleet dispatcher checks mid-morning what every vehicle has done since loading, and which ones stand still more than they drive.
1. The time lost standing idle across the fleet this morning, counted in quarter hours from the bands below.
2. How many vehicles are driving right now: the state each band ends in at 10:30.
3. One lane per vehicle, one colour per state, the states named once in the legend; the lanes stop at now, the afternoon is still to come. Hover any minute and the tooltip lists what each vehicle was doing then.
4. The idle time per vehicle, summed from the same bands: the ones worth a call to the driver stand out.
Built from: StateBand, Bar, Axis, Tooltip, Stat, Card.
```tsx
import { Card, CardBody, CardHeader, Grid, Stack, Stat } from "@umriss-ui/core";
import { Legend, Tooltip, useChart } from "@umriss-ui/charts";
/* Data from the logistics 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 = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
const MINUTE = 60_000;
/** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
const NOW = at(10, 30);
const DEPOTS = [
{ id: "north", name: "North depot" },
{ id: "river", name: "Riverside depot" },
{ id: "east", name: "East Gate depot" },
] as const;
interface Vehicle {
id: string;
plate: string;
type: "van" | "e-van" | "truck";
/** Payload, in kg. */
capacity: number;
depot: string;
}
const VEHICLES: readonly Vehicle[] = [
{ id: "v1", plate: "FP 214 K", type: "van", capacity: 1200, depot: "north" },
{ id: "v2", plate: "FP 377 K", type: "e-van", capacity: 900, depot: "north" },
{ id: "v3", plate: "FP 118 R", type: "truck", capacity: 7500, depot: "north" },
{ id: "v4", plate: "FP 402 R", type: "van", capacity: 1200, depot: "river" },
{ id: "v5", plate: "FP 455 R", type: "e-van", capacity: 900, depot: "river" },
{ id: "v6", plate: "FP 290 E", type: "van", capacity: 1200, depot: "east" },
{ id: "v7", plate: "FP 311 E", type: "e-van", capacity: 900, depot: "east" },
{ id: "v8", plate: "FP 520 E", type: "truck", capacity: 7500, depot: "east" },
];
const DRIVERS = [
{ id: "d1", name: "Martin Hale", depot: "north" },
{ id: "d2", name: "Nadia Petrova", depot: "north" },
{ id: "d3", name: "Owen Carter", depot: "north" },
{ id: "d4", name: "Lucia Romero", depot: "river" },
{ id: "d5", name: "Ben Adeyemi", depot: "river" },
{ id: "d6", name: "Hanna Berg", depot: "east" },
{ id: "d7", name: "Yusuf Demir", depot: "east" },
{ id: "d8", name: "Clara Wendt", depot: "east" },
] as const;
const CUSTOMERS = [
"Holloway Garden Supplies",
"Marlow & Finch Books",
"Oakridge Pharmacy",
"Brixley Cycles",
"Tamsin's Bakery",
"Northfold Office",
"Pellham Hardware",
"Greywick Studio",
"Juniper Lane Florist",
"Ashcombe Dental",
] as const;
interface Stop {
id: string;
shipment: string;
customer: string;
/** The window the customer was promised, `[from, to]`. */
window: readonly [number, number];
/** When the tour plans to be there. */
arrival: number;
}
interface Tour {
id: string;
vehicle: string;
driver: string;
depot: string;
/** Leaves the depot, loaded; back at the depot. */
from: number;
to: number;
/** Minutes of loading before `from`. */
loading: number;
km: number;
stops: readonly Stop[];
}
/** One tour per vehicle. Stops every half hour or so, each with a two-hour
window; every seventh arrival misses its window - the late ones a
dispatcher looks for. */
function tour(index: number): Tour {
const r = random(700 + index);
const vehicle = VEHICLES[index]!;
const truck = vehicle.type === "truck";
const from = at(truck ? 6 : 7, index % 2 === 0 ? 0 : 30);
const count = truck ? 5 : 8 + Math.floor(r() * 4);
const stops: Stop[] = [];
let arrival = from + (20 + Math.floor(r() * 15)) * MINUTE;
for (let i = 0; i < count; i++) {
const n = index * 20 + i;
const slot = Math.floor((arrival - at(0)) / (2 * 60 * MINUTE)) * 2;
/* A late stop's window closed half an hour or more before the arrival. */
const late = i >= 2 && n % 7 === 3;
const halfHour = 30 * MINUTE;
const windowFrom = late ? Math.floor(arrival / halfHour) * halfHour - 5 * halfHour : at(slot);
stops.push({
id: `s-${index + 1}-${i + 1}`,
shipment: `FP-${(1_004_210 + n * 13).toString()}`,
customer: CUSTOMERS[(n * 3) % CUSTOMERS.length]!,
window: [windowFrom, windowFrom + 2 * 60 * MINUTE],
arrival,
});
arrival += (truck ? 45 : 22) * MINUTE + Math.floor(r() * 18) * MINUTE;
}
return {
id: `T-${String(index + 1).padStart(2, "0")}`,
vehicle: vehicle.id,
driver: DRIVERS[index]!.id,
depot: vehicle.depot,
from,
to: arrival + 30 * MINUTE,
loading: truck ? 45 : 30,
km: Math.round((truck ? 140 : 70) + r() * 40),
stops,
};
}
const TOURS: readonly Tour[] = VEHICLES.map((_, index) => tour(index));
/** The closed set of vehicle states. The order is the code. */
const VEHICLE_STATES = [
{ label: "Driving", color: "#2f6fb4" },
{ label: "Loading", color: "#c08a2e" },
{ label: "Idle", color: "#8a8f98" },
{ label: "Break", color: "#6b8e5a" },
] as const;
interface StatePoint {
t: number;
/** A code in `VEHICLE_STATES`; `null` before the tour and after it. */
state: number | null;
}
/** What a vehicle did from 05:00 to 19:00, quarter hour by quarter hour:
loading before its tour, driving between stops, idle at them now and then,
and a break around noon. */
function vehicleDay(vehicleId: string): StatePoint[] {
const one = TOURS.find((t) => t.vehicle === vehicleId);
if (one === undefined) throw new Error(`No vehicle "${vehicleId}".`);
const r = random(900 + TOURS.indexOf(one));
const points: StatePoint[] = [];
for (let t = at(5); t <= at(19); t += 15 * MINUTE) {
let state: number | null = null;
if (t >= one.from - one.loading * MINUTE && t < one.from) state = 1;
else if (t >= one.from && t < one.to) state = t >= at(12) && t < at(12, 30) ? 3 : r() < 0.18 ? 2 : 0;
points.push({ t, state });
}
return points;
}
const IDLE = VEHICLE_STATES.findIndex((one) => one.label === "Idle");
const DRIVING = VEHICLE_STATES.findIndex((one) => one.label === "Driving");
const DAY: readonly [number, number] = [new Date(NOW).setHours(5, 0, 0, 0), new Date(NOW).setHours(19, 0, 0, 0)];
/* The day so far: what happened before now, and a hole from now on - the band
of the last quarter hour ends at 10:30 instead of running into the future. */
const FLEET = VEHICLES.map((vehicle) => {
const points = vehicleDay(vehicle.id).filter((one) => one.t < NOW);
const idle = points.filter((one) => one.state === IDLE).length * 15;
return { vehicle, points: [...points, { t: NOW, state: null }], idle, driving: points.at(-1)?.state === DRIVING };
});
const depotOf = (id: string) => DEPOTS.find((one) => one.id === id)?.name ?? id;
const IDLE_TODAY = FLEET.reduce((sum, one) => sum + one.idle, 0) / 60;
const DRIVING_NOW = FLEET.filter((one) => one.driving).length;
function FleetLanes() {
const { Chart, XAxis, YAxis, StateBand } = useChart(FLEET[0]!.points);
return (
{FLEET.map((one) => (
))}
);
}
function IdleMinutes() {
const { Chart, XAxis, YAxis, Bar } = useChart(FLEET);
return (
d.vehicle.plate} />
);
}
export default function IdleVans() {
return (
);
}
```
### Compare the budget with the forecast
A controller prepares the monthly review: where the year will land against its budget, cost centre by cost centre, and why.
1. The year in three figures: the budget, the outlook (closed months at their actual, open months at their forecast) and the gap between them. As a whole, the company lands on its budget.
2. The total hides two gaps that cancel out: Marketing overspends by about as much as IT underspends.
3. Marketing month by month: the budget as quiet bars, the closed months' actuals beside them, the forecast as a line above the budget all year. Hover a month for the ledger's figures.
Built from: Bar, Line, Tooltip, Stat, Card.
```tsx
import { Card, CardBody, CardHeader, Grid, Stack, Stat } from "@umriss-ui/core";
import type { LimitSet } from "@umriss-ui/core";
import { Legend, Tooltip, useChart } from "@umriss-ui/charts";
const thousands = (euros: number) => euros / 1000;
const euros = (v: number) => `${v.toLocaleString("en-GB", { maximumFractionDigits: 1 })} k€`;
/* The year per cost centre, in euros, as the ledger sums it: the budget, and
the outlook - closed months at their actual, open months at their forecast. */
const YEAR = [
{ name: "Sales", budget: 1_706_840, outlook: 1_701_784 },
{ name: "Marketing", budget: 817_360, outlook: 934_111 },
{ name: "Engineering", budget: 2_259_760, outlook: 2_258_122 },
{ name: "Design", budget: 649_080, outlook: 649_484 },
{ name: "Customer service", budget: 733_220, outlook: 734_416 },
{ name: "Finance", budget: 564_940, outlook: 563_659 },
{ name: "People", budget: 468_780, outlook: 468_251 },
{ name: "IT", budget: 997_660, outlook: 881_804 },
{ name: "Facilities", budget: 865_440, outlook: 860_691 },
];
const gap = (one: { budget: number; outlook: number }) => one.outlook - one.budget;
const BUDGET = YEAR.reduce((sum, one) => sum + one.budget, 0);
const OUTLOOK = YEAR.reduce((sum, one) => sum + one.outlook, 0);
/* Past 1 % over budget is worth a word in the review, past 3 % a decision. */
const GAP_LIMITS: LimitSet = {
target: 0,
limits: [
{ value: 1, side: "upper", severity: "warning" },
{ value: 3, side: "upper", severity: "alarm" },
],
};
const MARKETING = { id: "CC-1200", owner: "Rafael Ortiz" };
/* Marketing's ledger month by month, in euros; `actual` is `null` while the
month's books are open. */
const MONTHLY = [
{ month: "2026-01", budget: 68_000, actual: 76_823, forecast: 76_900 },
{ month: "2026-02", budget: 68_000, actual: 76_588, forecast: 78_600 },
{ month: "2026-03", budget: 68_000, actual: null, forecast: 78_200 },
{ month: "2026-04", budget: 68_000, actual: null, forecast: 76_100 },
{ month: "2026-05", budget: 68_000, actual: null, forecast: 77_100 },
{ month: "2026-06", budget: 68_000, actual: null, forecast: 77_800 },
{ month: "2026-07", budget: 68_000, actual: null, forecast: 78_400 },
{ month: "2026-08", budget: 61_200, actual: null, forecast: 69_600 },
{ month: "2026-09", budget: 68_000, actual: null, forecast: 79_200 },
{ month: "2026-10", budget: 68_000, actual: null, forecast: 78_400 },
{ month: "2026-11", budget: 68_000, actual: null, forecast: 77_200 },
{ month: "2026-12", budget: 76_160, actual: null, forecast: 88_700 },
];
/** `"2026-03"` as the axis writes it. */
const monthName = (month: string) => new Date(`${month}-01T00:00`).toLocaleString("en-GB", { month: "short" });
function GapPerCentre() {
const { Chart, XAxis, YAxis, Bar } = useChart(YEAR);
return (
{/* One series per sign, stacked on one another so that each bar stands centred on its cost centre. */}
(gap(d) > 0 ? thousands(gap(d)) : null)} name="Over budget" tone="alarm" stack="gap" format={euros} />
(gap(d) <= 0 ? thousands(gap(d)) : null)}
name="Under budget"
color="var(--uc-color-text)"
stack="gap"
format={euros}
/>
);
}
function MarketingByMonth() {
const { Chart, XAxis, YAxis, Bar, Line } = useChart(MONTHLY);
return (
thousands(d.budget)} name="Budget" color="var(--uc-color-text)" barWidth={0.7} format={euros} />
(d.actual === null ? null : thousands(d.actual))} name="Actual" barWidth={0.7} format={euros} />
thousands(d.forecast)} name="Forecast" tone="warning" markers="always" format={euros} />
);
}
export default function BudgetAndForecast() {
return (
);
}
```
### Follow the sprint's burn-down
A team lead opens this at the stand-up: how many hours of the sprint are left, how that compares with an even pace, and who holds them.
1. The hours of estimated work not yet done, as booked at the end of yesterday.
2. The pace the remaining days would need, beside the pace so far: the gap between the two is the conversation of the stand-up.
3. The even pace burns to zero on the last day; the team's line stops at the last day booked and lies well above it. The x axis counts working days, so the weekend takes no room.
4. The items not yet done, per person, at their planned estimate: where help or a cut in scope would land.
Built from: Line, Bar, Axis, Tooltip, Stat, Card.
```tsx
import { Card, CardBody, CardHeader, Grid, Stack, Stat, Text } from "@umriss-ui/core";
import { Legend, Tooltip, useChart } from "@umriss-ui/charts";
/* Data from the planning world, written out here so the example runs on its own. */
const at = (day: number, hours = 9, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
interface Person {
id: string;
name: string;
role: "Developer" | "Designer" | "Product manager" | "QA engineer";
team: "Web" | "Apps";
/** Hours a week they can be planned for. */
capacity: number;
}
const PEOPLE: readonly Person[] = [
{ id: "maya", name: "Maya Lindgren", role: "Product manager", team: "Web", capacity: 32 },
{ id: "arjun", name: "Arjun Mehta", role: "Developer", team: "Web", capacity: 40 },
{ id: "chloe", name: "Chloe Durand", role: "Developer", team: "Web", capacity: 40 },
{ id: "noah", name: "Noah Fischer", role: "Designer", team: "Web", capacity: 24 },
{ id: "eva", name: "Eva Novak", role: "QA engineer", team: "Web", capacity: 40 },
{ id: "luis", name: "Luis Moreno", role: "Product manager", team: "Apps", capacity: 40 },
{ id: "hana", name: "Hana Sato", role: "Developer", team: "Apps", capacity: 40 },
{ id: "kofi", name: "Kofi Mensah", role: "Developer", team: "Apps", capacity: 32 },
{ id: "freya", name: "Freya Olsen", role: "Designer", team: "Apps", capacity: 40 },
{ id: "david", name: "David Kowalski", role: "QA engineer", team: "Apps", capacity: 20 },
];
interface Sprint {
id: string;
name: string;
from: number;
to: number;
goal: string;
}
/** Two weeks each, Monday 09:00 to the second Friday 17:00. */
const SPRINTS: readonly Sprint[] = [
{ name: "Sprint 12", day: 9 - 28, goal: "Shop checkout on the new design" },
{ name: "Sprint 13", day: 9 - 14, goal: "Booking app in the stores' beta" },
{ name: "Sprint 14", day: 9, goal: "Member portal sign-in and profile" },
{ name: "Sprint 15", day: 23, goal: "Shop search and filters" },
{ name: "Sprint 16", day: 37, goal: "Booking reminders" },
].map(({ name, day, goal }) => ({ id: name.toLowerCase().replace(" ", "-"), name, from: at(day), to: at(day + 11, 17), goal }));
/** A work item, shaped as `@umriss-ui/schedule`'s `Subtask`. */
interface WorkItem {
id: string;
/** The project. */
task: string;
/** The person. */
lane: string;
from: number;
to: number;
name: string;
sprint: string;
/** Hours, as estimated at planning. */
estimate: number;
status: "to do" | "in progress" | "in review" | "done";
}
const item = (id: string, task: string, lane: string, from: number, to: number, name: string, estimate: number, status: WorkItem["status"]): WorkItem =>
({ id, task, lane, from, to, name, sprint: "sprint-14", estimate, status });
const WORK: readonly WorkItem[] = [
item("w-101", "portal", "arjun", at(9), at(11, 17), "Sign-in with e-mail code", 18, "done"),
item("w-102", "portal", "arjun", at(12), at(17, 17), "Profile page", 26, "in progress"),
item("w-103", "portal", "chloe", at(9), at(10, 17), "Session handling", 12, "done"),
item("w-104", "shop", "chloe", at(11), at(13, 17), "Basket keeps items across devices", 20, "in review"),
item("w-105", "portal", "chloe", at(16), at(19, 17), "Change of address form", 24, "in progress"),
item("w-106", "portal", "noah", at(9), at(12, 13), "Profile page design", 14, "done"),
item("w-107", "shop", "noah", at(16), at(18, 17), "Search results layout", 12, "to do"),
item("w-108", "portal", "eva", at(12), at(13, 17), "Test plan for sign-in", 10, "done"),
item("w-109", "portal", "eva", at(17), at(20, 17), "Regression run", 20, "to do"),
item("w-110", "booking", "hana", at(9), at(13, 17), "Reminder scheduling service", 32, "done"),
item("w-111", "booking", "hana", at(16), at(20, 17), "Push notifications", 30, "in progress"),
item("w-112", "booking", "kofi", at(10), at(12, 17), "Calendar sync", 18, "done"),
item("w-113", "intranet", "kofi", at(16), at(18, 17), "News feed", 16, "in progress"),
item("w-114", "booking", "freya", at(9), at(11, 17), "Reminder settings screen", 16, "done"),
item("w-115", "booking", "freya", at(18), at(20, 17), "Store screenshots", 12, "to do"),
item("w-116", "booking", "david", at(16), at(19, 13), "Device test matrix", 14, "to do"),
];
interface BurndownPoint {
/** The working day, at 17:00. */
t: number;
/** Where the line would be, burning evenly. */
ideal: number;
/** Hours left, `null` for the days still to come. */
remaining: number | null;
}
/** Sprint 14's ten working days. The team fell behind in the first week, when
two were away, and is catching up. */
const BURNDOWN: readonly BurndownPoint[] = (() => {
const total = WORK.reduce((sum, one) => sum + one.estimate, 0);
const days = [9, 10, 11, 12, 13, 16, 17, 18, 19, 20];
const burnt = [0, 22, 38, 60, 71, 98];
return days.map((day, i) => ({
t: at(day, 17),
ideal: Math.round(total * (1 - i / (days.length - 1))),
remaining: burnt[i] === undefined ? null : total - burnt[i]!,
}));
})();
const SPRINT = SPRINTS.find((one) => one.id === "sprint-14")!;
const BOOKED = BURNDOWN.filter((one) => one.remaining !== null);
const LEFT = BOOKED.at(-1)!.remaining!;
const BURNT = BURNDOWN[0]!.remaining! - LEFT;
/* The first point is the sprint's start; each later one closes a working day. */
const DAYS_DONE = BOOKED.length - 1;
const DAYS_LEFT = BURNDOWN.length - BOOKED.length;
/** A working day as the axis names it: a category, so the weekend takes no room. */
const dayOf = (one: { t: number }) => new Date(one.t).toLocaleDateString("en-GB", { weekday: "short", day: "numeric" });
interface Holder {
name: string;
open: number;
}
const HOLDERS: readonly Holder[] = PEOPLE.map((person) => ({
name: person.name.split(" ")[0]!,
open: WORK.filter((one: WorkItem) => one.lane === person.id && one.status !== "done").reduce((sum, one) => sum + one.estimate, 0),
})).filter((one) => one.open > 0);
function SprintBurnDown() {
const { Chart, XAxis, YAxis, Line } = useChart(BURNDOWN);
return (
);
}
function OpenPerPerson() {
const { Chart, XAxis, YAxis, Bar } = useChart(HOLDERS);
return (
);
}
export default function BurnDown() {
return (
Ten working days, {dayOf(BURNDOWN[0]!)} to {dayOf(BURNDOWN.at(-1)!)}; the weekend between them is left out.
);
}
```
### Hold the tile length in control
A quality engineer at the tile works reviews the early shift: whether the fired tiles kept their length, and what the kiln did when they did not.
1. The specification, 600 ± 1.5 mm, is chosen and drawn dashed; the control limits are calculated from the first two hours, when the kiln ran steady.
2. Samples that break a rule are marked on the chart and listed here, with the time and the rules, for the shift report.
3. The kiln's zone 3 on the same minutes, with its tolerance and alarm limit: the tiles shrank while the kiln ran hot. Both charts share one pointer, so a marked sample and the kiln reading of its minute are read together.
Built from: ControlChart, LimitLine, Line, Tooltip, Badge, Card.
```tsx
import { Badge, Card, CardBody, CardHeader, Stack, Text } from "@umriss-ui/core";
import {
ControlChart,
LimitBand,
LimitLine,
Tooltip,
controlLimits,
useChart,
violations,
type RuleName,
} from "@umriss-ui/charts";
/* Data from the plant world, written out here so the example runs on its own. */
/** A small LCG - reproducible across runs and platforms. */
function random(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
/** The early shift, 06:00 to 14:00. */
const SHIFT_MINUTES = 480;
/** The kiln's zone 3, in °C: the numbers every part reads it against, once.
Between the two warnings lies the tolerance - a tile fired outside it is
scrap; `returnTo` is the alarm's dead band. */
const KILN = { target: 1200, tolerance: [1185, 1215], alarm: 1230, returnTo: 1222 } as const;
/** The kiln fires one tile every quarter of a minute when nothing holds it up -
the ideal cycle time of the OEE's performance. */
const IDEAL_CYCLE_MINUTES = 0.25;
/** One minute of the line. */
interface Reading {
/** Minutes since the start of the shift. */
minute: number;
/** Zone 3 of the kiln, in °C. */
kiln: number;
/** The exit pyrometer, in °C - `null` while it sends nothing. */
exit: number | null;
/** Whether the belt runs. */
running: boolean;
/** Tiles out of the kiln in this minute. */
fired: number;
/** Of them, the ones fired inside the tolerance. */
good: number;
}
/** A tile taken off the belt and measured - one every ten minutes while it runs. */
interface Sample {
minute: number;
/** The tile's length after firing, in mm (nominal 600). */
length: number;
}
/** A batch of tiles through the three stations. */
interface Batch {
id: string;
name: string;
/** How many tiles the batch is planned for. */
tiles: number;
/** Minutes since the start of the shift, per station. */
press: readonly [number, number];
dryer: readonly [number, number];
kiln: readonly [number, number];
}
interface Plant {
readings: readonly Reading[];
samples: readonly Sample[];
batches: readonly Batch[];
}
/* When the exit pyrometer goes silent, and for how long. Fixed rather than
seeded, so that a running control room shows its reading turn stale, then
lost, and come back. */
const SILENT_FROM = 288;
const SILENT_FOR = 50;
/** The shift, from a seed. */
function plant(seed: number): Plant {
const r = random(seed);
/* The one excursion: a burner overshoots somewhere between 02:30 and 03:30
into the shift, for half an hour, peaking near 1242 °C. The operator stops
the feed twenty-two minutes in, for eighteen minutes. The noise around it
is too small to reach a warning on its own, so this is the only crossing -
which is what the tests hold. */
const surgeFrom = 150 + Math.floor(r() * 60);
const surgeFor = 30;
const stopFrom = surgeFrom + 22;
const stopFor = 18;
/* The plan: batches through the kiln one after another, each pressed an
hour and dried half an hour before - the first ones during the night
shift. Tiles come out of the kiln only while a batch is in it, so the
OEE's count and the batches' counts are the same tiles. The plan is a
plan: a stop of the belt does not move it. */
const batches: Batch[] = [];
for (let at = 0, i = 0; at < SHIFT_MINUTES; i++) {
const length = 70 + Math.floor(r() * 25);
batches.push({
id: `b-${4121 + i}`,
name: `B-${4121 + i}`,
tiles: Math.round(length / IDEAL_CYCLE_MINUTES / 10) * 10,
press: [at - 60, at - 60 + length],
dryer: [at - 30, at - 30 + length],
kiln: [at, at + length],
});
at += length + 5;
}
const readings: Reading[] = [];
const samples: Sample[] = [];
let drift = 0;
for (let minute = 0; minute < SHIFT_MINUTES; minute++) {
drift = 0.9 * drift + (r() - 0.5) * 3;
const into = minute - surgeFrom;
const surge = into >= 0 && into <= surgeFor ? 42 * Math.sin((Math.PI * into) / surgeFor) : 0;
const kiln = Math.round((KILN.target + drift + surge) * 10) / 10;
const running = !(minute >= stopFrom && minute < stopFrom + stopFor);
const loaded = batches.some((batch) => batch.kiln[0] <= minute && minute < batch.kiln[1]);
const jam = r() < 0.12;
const fired = running && loaded ? (jam ? 3 : 4) : 0;
const inTolerance = kiln >= KILN.tolerance[0] && kiln <= KILN.tolerance[1];
const silent = minute >= SILENT_FROM && minute < SILENT_FROM + SILENT_FOR;
const exit = silent ? null : Math.round((kiln - 380 + (r() - 0.5) * 4) * 10) / 10;
readings.push({ minute, kiln, exit, running, fired, good: inTolerance ? fired : 0 });
if (running && minute % 10 === 0) {
/* Hotter shrinks more: the length follows the kiln, so the control
chart sees the excursion the trend sees. */
const length = 600 + (KILN.target - kiln) * 0.05 + (r() - 0.5) * 0.6;
samples.push({ minute, length: Math.round(length * 100) / 100 });
}
}
return { readings, samples, batches };
}
const SHIFT = plant(17);
const SPEC = { nominal: 600, tolerance: 1.5 };
/* Twelve samples, one every ten minutes: the first two hours. */
const REFERENCE = { kind: "referenceWindow", from: 0, to: 12 } as const;
const DOMAIN: readonly [number, number] = [0, SHIFT_MINUTES];
const HOURS = Array.from({ length: SHIFT_MINUTES / 60 + 1 }, (_, i) => i * 60);
const RULES: Record = {
outlier: "Beyond a control limit",
run: "Run on one side of the centre",
trend: "Steady rise or fall",
twoOfThree: "Two of three near a limit",
};
const lengths = SHIFT.samples.map((one) => one.length);
const BROKEN = violations(lengths, controlLimits(lengths, REFERENCE));
/* One line per sample, naming every rule it broke. */
const FOUND = [...new Set(BROKEN.flatMap((one) => one.indices))]
.sort((a, b) => a - b)
.map((index) => ({
sample: SHIFT.samples[index]!,
rules: BROKEN.filter((one) => one.indices.includes(index)).map((one) => RULES[one.rule]),
}));
/* The early shift begins at 06:00. */
const clock = (minute: number) => {
const m = Math.round(minute) + 6 * 60;
return `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
};
function LengthInControl() {
const { Chart, XAxis, YAxis } = useChart(SHIFT.samples);
return (
);
}
function KilnZone() {
const { Chart, XAxis, YAxis, Line } = useChart(SHIFT.readings);
return (
);
}
export default function TileLength() {
return (
0 ? "warning" : "success"}>{FOUND.length}} />
{FOUND.length === 0 && No sample broke a rule this shift.}
{FOUND.map(({ sample, rules }) => (
{clock(sample.minute)}
{" "}
{sample.length.toFixed(2)} mm · {rules.join(", ")}
))}
);
}
```
## Getting started
What to know before the first chart.
### Installation
Install the package, draw a first chart and size it in its container. Read this once before the component pages; everything after it assumes it.
```sh
npm install @umriss-ui/charts
```
Demo page: https://romanhaendler.github.io/umriss-ui/charts/installation/
The command installs the package alone: React 19 or newer is its only peer and stays the application's own, and the package depends on nothing else. The stylesheet comes with the JavaScript, so there is nothing to import - `@umriss-ui/charts/styles.css` is there for setups that link stylesheets by hand. Its colours, type and sizes are `--uc-` tokens that fall back onto core's; [core's Theming page](https://romanhaendler.github.io/umriss-ui/core/theming/#charts-tokens) lists them all.
A chart takes its container's width and follows it; give it a `height` (300 px without one). `useChart(rows)` hands out `Chart`, its axes and its series, typed at the row; each reads its value from a row through `value`, a field name or a function - a value that is `null`, `undefined` or not finite is a gap, never a zero.
An instant is a `Date`, milliseconds since 1970, or - where the x axis says `time` - the ISO string an API sends ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). On a time axis the ticks stand on the viewer's local clock, so a day begins at local midnight. The words are English; German comes from `@umriss-ui/charts/wording/de`.
The charts take German per chart: `wording={GERMAN_CHARTS_WORDING}` from `@umriss-ui/charts/wording/de`, as [the keyboard and screen reader example](#/chart/keyboard-and-screen-reader) shows. They read no language provider, so this demo has no EN/DE switch in its header.
#### Examples
##### Draw a first chart
Hand [`useChart`](#/api/useChart) the rows as they come, then give its `Chart` an axis per direction and a series; the x axis and the series read a row through `value`. With `time` the axis reads the instants an API sends as text.
```tsx
import { Tooltip, useChart } from "@umriss-ui/charts";
/* Sign-ins per hour, as the API sends them: the hour's start in local time. */
const SIGN_INS = [
{ t: "2026-03-17T00:00", signIns: 310 },
{ t: "2026-03-17T01:00", signIns: 220 },
{ t: "2026-03-17T02:00", signIns: 180 },
{ t: "2026-03-17T03:00", signIns: 170 },
{ t: "2026-03-17T04:00", signIns: 205 },
{ t: "2026-03-17T05:00", signIns: 340 },
{ t: "2026-03-17T06:00", signIns: 820 },
{ t: "2026-03-17T07:00", signIns: 1460 },
{ t: "2026-03-17T08:00", signIns: 2130 },
{ t: "2026-03-17T09:00", signIns: 2480 },
{ t: "2026-03-17T10:00", signIns: 2390 },
];
export default function FirstChart() {
const { Chart, XAxis, YAxis, Line } = useChart(SIGN_INS);
return (
);
}
```
##### Size a chart by its container
Without `width` a chart takes its container's width and follows it; `height` is always yours. Drag the corner, or collapse the pair.
```tsx
import { useState } from "react";
import { 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 SIGN_IN = metrics("sign-in");
function Requests({ width, height }: { width?: number; height: number }) {
const { Chart, XAxis, YAxis, Line } = useChart(SIGN_IN);
return (
);
}
export default function InItsContainer() {
const [collapsed, setCollapsed] = useState(false);
return (
<>
{/* `resize: horizontal` on the host - the chart measures what it finds. */}
{!collapsed && (
)}
>
);
}
```
#### Keyboard
The keys of [Chart](#/chart/keyboard-chart) apply here.
#### Props on this page
From `ChartProps` — the full table stands on [Chart](#/chart/type-ChartProps).
| Prop | Type | Default | Description |
|---|---|---|---|
| [`width`](#/chart/ChartProps-width) | `number \| "100%"` | `"100%"` | Width in CSS pixels, or `"100%"` for the host's: the chart measures its host and follows it through a ResizeObserver. |
| [`height`](#/chart/ChartProps-height) | `number` | fills the frame, at least 300 px | Height in CSS pixels, fixed. Left out, the chart fills its frame - a stretched grid cell, a card the grid stretches, a container of definite height - and never goes below 300 px, which is also its height in a frame without one. A different floor goes through `style`'s `minHeight` ([ADR-0050](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0050-a-chart-fills-its-frame-never-below-its-floor.md)). |
| [`children`](#/chart/ChartProps-children) | `ReactNode` | — | The axes, series and companions of this chart. They draw nothing themselves: each registers its configuration and the scene draws it, and the order in the JSX is the drawing order. |
###### Accessibility
| Prop | Type | Default | Description |
|---|---|---|---|
| [`ariaLabel`](#/chart/ChartProps-ariaLabel) | `string` | — | Required in DEV through a warning. |
From `XAxisProps` — the full table stands on [Axis](#/axis/type-XAxisProps).
| Prop | Type | Default | Description |
|---|---|---|---|
| [`value`](#/axis/XAxisProps-value) *required* | [`AxisField`](#/axis/type-AxisField)` \| ((d: T, index: number) => `[`AxisValue`](#/axis/type-AxisValue)`)` | — | Where a row lies on this axis: a field of the row, compared by its name, or a function of it, compared by its source text as a series' is (`ValueFunction`) - with the same closure limit. A number is a position, a `Date` an instant, a string a category: every distinct one gets a place of its own, in the order the rows bring them, a tick and its name as the label ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). The type decides, never the content - `"2024"` is a category, `2024` a number - and a row without a value has no place. The axis reads the rows of every series bound to it; where those bring their own `data`, name their row: ` value="hour" />`. |
| [`time`](#/axis/XAxisProps-time) | `boolean` | — | The values are instants: milliseconds since the epoch, or with `time` said ISO 8601 strings as an API sends them, read by the platform's rule - a date with a time and no offset is local time, a date alone is UTC; a string that is none is not drawn. Rows that hold a `Date` put the axis in time without `time`. Ticks on local boundaries from the minute to the month, labels by level in en-GB with a 24-hour clock - `15:00`, `17 Mar`, `Mar 2026` - and the date on the first tick and on the first of a new day (`17 Mar 00:00`); a day tick there carries the year. The tooltip's x value carries the seconds where the readings lie less than a minute apart. Another language is a `tickFormat`, which is handed the instant in milliseconds. `calendar` implies it. |
| [`label`](#/axis/XAxisProps-label) | `string` | — | Axis title. |
From `YAxisProps` — the full table stands on [Axis](#/axis/type-YAxisProps).
| Prop | Type | Default | Description |
|---|---|---|---|
| [`label`](#/axis/YAxisProps-label) | `string` | — | Axis title. |
From `LineProps` — the full table stands on [Line](#/line/type-LineProps).
| Prop | Type | Default | Description |
|---|---|---|---|
| [`value`](#/line/LineProps-value) *required* | [`Value`](#/line/type-Value)`` | — | Y value - a number field of the row or a function of it; null/undefined/NaN/±Infinity means a gap. |
| [`name`](#/line/LineProps-name) | `string` | "Series n", after its place in the chart | The name in legend and tooltip. Without one a warning stands in DEV - an unnamed series is a colour nobody can look up. |
## Chart
The container and its axes: what is drawn on, and what the drawing is measured against.
### Chart
Holds the rows and draws the series on shared axes, one chart for every kind a screen needs (a plot, a graph). Reach for it whenever values are read over time or across categories.
```ts
import { useChart } from "@umriss-ui/charts";
```
Demo page: https://romanhaendler.github.io/umriss-ui/charts/chart/
Series and axes are children, and their order in the JSX is the drawing order and the colour order. The marks are drawn on canvas; axes, legend and tooltip are elements a screen reader and a test can read ([ADR-0001](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0001-affine-scale-contract.md)).
With a `Tooltip` the plot is one tab stop: the keys walk its values and a screen reader hears them ([ADR-0030](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0030-a-chart-is-one-tab-stop-that-walks-its-hits.md)). `syncId` shares the pointer between charts; zoom is shared through the view, on [Zoom and pan](#/zoom-and-pan).
#### Examples
##### Mix series kinds
Bars, a range, a line and single counts share one pair of axes; the order of the children is the drawing order and the colour order.
```tsx
import { Legend, Tooltip, useChart } from "@umriss-ui/charts";
/* Data from the logistics 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;
};
}
interface DepotDay {
/** Day of March. */
day: number;
/** Pallets that arrived, and that left on the tours. */
arrived: number;
dispatched: number;
/** Pallets on hand at the end of the day. */
onHand: number;
/** The range the stock is meant to stay in; `null` while it was reset
after the stocktake. */
targetLow: number | null;
targetHigh: number | null;
/** A pallet count by hand - it never quite agrees with the books. */
counted: number;
}
/** The North depot's last fourteen days, 2 to 15 March. */
const DEPOT_DAYS: readonly DepotDay[] = (() => {
const r = random(2026);
let onHand = 62;
return Array.from({ length: 14 }, (_, i) => {
const arrived = 30 + r() * 25;
const dispatched = 22 + r() * 20;
onHand += (arrived - dispatched) * 0.35;
const reset = i >= 6 && i <= 8;
return {
day: i + 2,
arrived: Math.round(arrived),
dispatched: Math.round(dispatched),
onHand: Math.round(onHand),
targetLow: reset ? null : Math.round(onHand - 9 - r() * 3),
targetHigh: reset ? null : Math.round(onHand + 9 + r() * 3),
counted: Math.round(onHand + (r() - 0.5) * 26),
};
});
})();
export default function Mixed() {
const { Chart, XAxis, YAxis, Bar, Area, Line, Scatter } = useChart(DEPOT_DAYS);
return (
);
}
```
##### Say there is nothing to show
With no data, only gaps or every series hidden, the frame stays and the plot says so; `empty` puts your own words there.
```tsx
import { useChart } from "@umriss-ui/charts";
interface Reading {
t: number;
p95: number;
}
const NOTHING: Reading[] = [];
function Latency({ empty }: { empty?: string }) {
const { Chart, XAxis, YAxis, Line } = useChart(NOTHING);
return (
);
}
export default function Empty() {
return (
no empty - the default
empty="Refunds reports no metrics yet"
);
}
```
##### Read it by keyboard and screen reader
A chart with a `Tooltip` is one tab stop: the keys walk its values, and a screen reader hears them; `wording` gives the German words, numbers and times.
```tsx
import { Tooltip, useChart, type ChartParts } from "@umriss-ui/charts";
import { GERMAN_CHARTS_WORDING } from "@umriss-ui/charts/wording/de";
/* 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;
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;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
/** Monday, 9 March 2026, 00:00 - the start of last week. */
const LAST_WEEK = at(9, 0);
/** Search returned nothing for some regions (INC-1043): Saturday, 18:30 to 19:55. */
const SEARCH_OUTAGE = [at(14, 18, 30) - LAST_WEEK, at(14, 19, 55) - LAST_WEEK] as const;
/** A service's last week, Monday to Monday, one point every `step`
milliseconds: busy by day, quieter at the weekend. During INC-1043 Search's
95th percentile more than doubles and its errors climb. The hour is counted,
not asked of a Date - a week of seconds is 604,800 points. */
function week(serviceId: string, step: number): 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(2000 + index * 37);
const n = Math.floor((7 * DAY) / step);
const points = new Array(n);
/* The drift wanders at the same pace whatever the step. */
const keep = Math.pow(0.8, step / (5 * MINUTE));
const kick = 0.08 * Math.sqrt(1 - keep * keep) / 0.6;
let drift = 0;
for (let i = 0; i < n; i++) {
const offset = i * step;
const day = Math.floor(offset / DAY);
const hour = (offset % DAY) / HOUR;
const load = (day >= 5 ? 0.85 : 1) * (0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16)));
drift = keep * drift + (r() - 0.5) * kick;
const outage = serviceId === "search" && offset >= SEARCH_OUTAGE[0] && offset < SEARCH_OUTAGE[1];
const p95 = Math.round(service.latencySlo * (0.45 + 0.2 * load + drift) * (outage ? 2.3 : 1) * (1 + r() * 0.08));
points[i] = {
t: LAST_WEEK + offset,
p50: Math.round(p95 * (0.42 + r() * 0.06)),
p95,
errorRate: Math.round((0.05 + r() * 0.12 + (outage ? 3 : 0)) * 100) / 100,
requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
};
}
return points;
}
const SEARCH_WEEK = week("search", 5 * 60_000);
export default function KeyboardAndScreenReader() {
// The two charts zoom together: each sends the other what the reader did to it.
const english: ChartParts = useChart(SEARCH_WEEK, { onViewChange: (v) => german.setView(v) });
const german: ChartParts = useChart(SEARCH_WEEK, { onViewChange: (v) => english.setView(v) });
const { Chart, XAxis, YAxis, Line } = english;
const { Chart: Diagramm } = german;
return (
);
}
```
##### Tell series apart without colour
With `encoding="marks"` each series also carries a dash and a marker shape by its palette place, for readers and printouts without colour.
```tsx
import { Legend, Tooltip, useChart } from "@umriss-ui/charts";
/* Data from the controlling 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;
};
}
interface CostCentre {
id: string;
name: string;
owner: string;
/** The budget of one month. */
monthlyBudget: number;
}
const COST_CENTRES: readonly CostCentre[] = [
{ id: "CC-1100", name: "Sales", owner: "Helen Marsh", monthlyBudget: 142_000 },
{ id: "CC-1200", name: "Marketing", owner: "Rafael Ortiz", monthlyBudget: 68_000 },
{ id: "CC-2100", name: "Engineering", owner: "Anika Sørensen", monthlyBudget: 188_000 },
{ id: "CC-2200", name: "Design", owner: "Paul Whitaker", monthlyBudget: 54_000 },
{ id: "CC-3100", name: "Customer service", owner: "Grace Obi", monthlyBudget: 61_000 },
{ id: "CC-4100", name: "Finance", owner: "Martina Vogel", monthlyBudget: 47_000 },
{ id: "CC-4200", name: "People", owner: "Daniel Frost", monthlyBudget: 39_000 },
{ id: "CC-4300", name: "IT", owner: "Kenji Arai", monthlyBudget: 83_000 },
{ id: "CC-4400", name: "Facilities", owner: "Olga Ivanova", monthlyBudget: 72_000 },
];
/** The year's months, as `"2026-01"` … `"2026-12"`. */
const MONTHS: readonly string[] = Array.from({ length: 12 }, (_, i) => `2026-${String(i + 1).padStart(2, "0")}`);
/** The months whose books are closed: they have an actual. */
const CLOSED_MONTHS = 2;
interface LedgerRow {
costCentre: string;
month: string;
budget: number;
/** `null` while the month is open. */
actual: number | null;
forecast: number;
}
/* Where a cost centre lands against its budget, as a factor: Marketing's
spring campaign overspends, IT's delayed licences underspend. */
const TENDENCY: Readonly> = { "CC-1200": 1.14, "CC-4300": 0.88 };
/** Budget, actual and forecast of every cost centre in every month. */
const LEDGER: readonly LedgerRow[] = COST_CENTRES.flatMap((centre, c) => {
const r = random(310 + c);
const tendency = TENDENCY[centre.id] ?? 1;
return MONTHS.map((month, m) => {
/* December pays the bonuses, August is the quiet month. */
const season = m === 11 ? 1.12 : m === 7 ? 0.9 : 1;
const budget = Math.round(centre.monthlyBudget * season);
const forecast = Math.round((budget * (tendency + (r() - 0.5) * 0.06)) / 100) * 100;
const actual = m < CLOSED_MONTHS ? Math.round(forecast * (1 + (r() - 0.5) * 0.08)) : null;
return { costCentre: centre.id, month, budget, actual, forecast };
});
});
const CENTRES = [
{ id: "CC-1100", name: "Sales" },
{ id: "CC-1200", name: "Marketing" },
{ id: "CC-3100", name: "Customer service" },
{ id: "CC-4400", name: "Facilities" },
];
const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/** `"2026-03"` as the axis writes it. */
const monthName = (month: string) => MONTH_NAMES[Number(month.slice(5)) - 1] ?? month;
function Forecasts({ encoding }: { encoding?: "marks" }) {
const { Chart, XAxis, YAxis, Line } = useChart(LEDGER);
return (
{CENTRES.map((c) => (
row.costCentre === c.id)}
value="forecast"
name={c.name}
/>
))}
);
}
export default function ToldApartWithoutColour() {
return (
by colour - the default
encoding="marks"
);
}
```
##### Mark every kind
Under `encoding="marks"` bars and ranges take a hatch, lines a dash, points a shape - the same pairing of colour and mark in every chart.
```tsx
import { Legend, Tooltip, useChart } from "@umriss-ui/charts";
/* Data from the logistics 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;
};
}
interface DepotDay {
/** Day of March. */
day: number;
/** Pallets that arrived, and that left on the tours. */
arrived: number;
dispatched: number;
/** Pallets on hand at the end of the day. */
onHand: number;
/** The range the stock is meant to stay in; `null` while it was reset
after the stocktake. */
targetLow: number | null;
targetHigh: number | null;
/** A pallet count by hand - it never quite agrees with the books. */
counted: number;
}
/** The North depot's last fourteen days, 2 to 15 March. */
const DEPOT_DAYS: readonly DepotDay[] = (() => {
const r = random(2026);
let onHand = 62;
return Array.from({ length: 14 }, (_, i) => {
const arrived = 30 + r() * 25;
const dispatched = 22 + r() * 20;
onHand += (arrived - dispatched) * 0.35;
const reset = i >= 6 && i <= 8;
return {
day: i + 2,
arrived: Math.round(arrived),
dispatched: Math.round(dispatched),
onHand: Math.round(onHand),
targetLow: reset ? null : Math.round(onHand - 9 - r() * 3),
targetHigh: reset ? null : Math.round(onHand + 9 + r() * 3),
counted: Math.round(onHand + (r() - 0.5) * 26),
};
});
})();
export default function MarksOnEveryKind() {
const { Chart, XAxis, YAxis, Bar, Area, Line, Scatter } = useChart(DEPOT_DAYS);
return (
);
}
```
##### Mark bands, limits and cells
What is otherwise said in colour alone - a state, a limit band, a verdict per cell - gets a hatch by its place as well.
```tsx
import { Legend, LimitBand, LimitLine, Tooltip, useChart } from "@umriss-ui/charts";
/* Data from the logistics 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 = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
const MINUTE = 60_000;
interface Vehicle {
id: string;
plate: string;
type: "van" | "e-van" | "truck";
/** Payload, in kg. */
capacity: number;
depot: string;
}
const VEHICLES: readonly Vehicle[] = [
{ id: "v1", plate: "FP 214 K", type: "van", capacity: 1200, depot: "north" },
{ id: "v2", plate: "FP 377 K", type: "e-van", capacity: 900, depot: "north" },
{ id: "v3", plate: "FP 118 R", type: "truck", capacity: 7500, depot: "north" },
{ id: "v4", plate: "FP 402 R", type: "van", capacity: 1200, depot: "river" },
{ id: "v5", plate: "FP 455 R", type: "e-van", capacity: 900, depot: "river" },
{ id: "v6", plate: "FP 290 E", type: "van", capacity: 1200, depot: "east" },
{ id: "v7", plate: "FP 311 E", type: "e-van", capacity: 900, depot: "east" },
{ id: "v8", plate: "FP 520 E", type: "truck", capacity: 7500, depot: "east" },
];
const DRIVERS = [
{ id: "d1", name: "Martin Hale", depot: "north" },
{ id: "d2", name: "Nadia Petrova", depot: "north" },
{ id: "d3", name: "Owen Carter", depot: "north" },
{ id: "d4", name: "Lucia Romero", depot: "river" },
{ id: "d5", name: "Ben Adeyemi", depot: "river" },
{ id: "d6", name: "Hanna Berg", depot: "east" },
{ id: "d7", name: "Yusuf Demir", depot: "east" },
{ id: "d8", name: "Clara Wendt", depot: "east" },
] as const;
const CUSTOMERS = [
"Holloway Garden Supplies",
"Marlow & Finch Books",
"Oakridge Pharmacy",
"Brixley Cycles",
"Tamsin's Bakery",
"Northfold Office",
"Pellham Hardware",
"Greywick Studio",
"Juniper Lane Florist",
"Ashcombe Dental",
] as const;
interface Stop {
id: string;
shipment: string;
customer: string;
/** The window the customer was promised, `[from, to]`. */
window: readonly [number, number];
/** When the tour plans to be there. */
arrival: number;
}
interface Tour {
id: string;
vehicle: string;
driver: string;
depot: string;
/** Leaves the depot, loaded; back at the depot. */
from: number;
to: number;
/** Minutes of loading before `from`. */
loading: number;
km: number;
stops: readonly Stop[];
}
/** One tour per vehicle. Stops every half hour or so, each with a two-hour
window; every seventh arrival misses its window - the late ones a
dispatcher looks for. */
function tour(index: number): Tour {
const r = random(700 + index);
const vehicle = VEHICLES[index]!;
const truck = vehicle.type === "truck";
const from = at(truck ? 6 : 7, index % 2 === 0 ? 0 : 30);
const count = truck ? 5 : 8 + Math.floor(r() * 4);
const stops: Stop[] = [];
let arrival = from + (20 + Math.floor(r() * 15)) * MINUTE;
for (let i = 0; i < count; i++) {
const n = index * 20 + i;
const slot = Math.floor((arrival - at(0)) / (2 * 60 * MINUTE)) * 2;
/* A late stop's window closed half an hour or more before the arrival. */
const late = i >= 2 && n % 7 === 3;
const halfHour = 30 * MINUTE;
const windowFrom = late ? Math.floor(arrival / halfHour) * halfHour - 5 * halfHour : at(slot);
stops.push({
id: `s-${index + 1}-${i + 1}`,
shipment: `FP-${(1_004_210 + n * 13).toString()}`,
customer: CUSTOMERS[(n * 3) % CUSTOMERS.length]!,
window: [windowFrom, windowFrom + 2 * 60 * MINUTE],
arrival,
});
arrival += (truck ? 45 : 22) * MINUTE + Math.floor(r() * 18) * MINUTE;
}
return {
id: `T-${String(index + 1).padStart(2, "0")}`,
vehicle: vehicle.id,
driver: DRIVERS[index]!.id,
depot: vehicle.depot,
from,
to: arrival + 30 * MINUTE,
loading: truck ? 45 : 30,
km: Math.round((truck ? 140 : 70) + r() * 40),
stops,
};
}
const TOURS: readonly Tour[] = VEHICLES.map((_, index) => tour(index));
/** The closed set of vehicle states. The order is the code. */
const VEHICLE_STATES = [
{ label: "Driving", color: "#2f6fb4" },
{ label: "Loading", color: "#c08a2e" },
{ label: "Idle", color: "#8a8f98" },
{ label: "Break", color: "#6b8e5a" },
] as const;
interface StatePoint {
t: number;
/** A code in `VEHICLE_STATES`; `null` before the tour and after it. */
state: number | null;
}
/** What a vehicle did from 05:00 to 19:00, quarter hour by quarter hour:
loading before its tour, driving between stops, idle at them now and then,
and a break around noon. */
function vehicleDay(vehicleId: string): StatePoint[] {
const one = TOURS.find((t) => t.vehicle === vehicleId);
if (one === undefined) throw new Error(`No vehicle "${vehicleId}".`);
const r = random(900 + TOURS.indexOf(one));
const points: StatePoint[] = [];
for (let t = at(5); t <= at(19); t += 15 * MINUTE) {
let state: number | null = null;
if (t >= one.from - one.loading * MINUTE && t < one.from) state = 1;
else if (t >= one.from && t < one.to) state = t >= at(12) && t < at(12, 30) ? 3 : r() < 0.18 ? 2 : 0;
points.push({ t, state });
}
return points;
}
interface BatteryPoint {
t: number;
/** State of charge, in per cent; `null` before the tour and after it. */
charge: number | null;
}
/** An e-van's battery through the day, with `vehicleDay`'s quarter hours:
full when the tour leaves, draining while it drives, a little while it
idles, and topped up at the depot's charger over the noon break. */
function batteryDay(vehicleId: string): BatteryPoint[] {
let charge = 100;
return vehicleDay(vehicleId).map(({ t, state }) => {
if (state === null) return { t, charge: null };
const now = charge;
charge = Math.max(0, Math.min(100, charge + [-4.8, 0, -0.6, 3][state]!));
return { t, charge: Math.round(now * 10) / 10 };
});
}
/* Data from the operations world, written out here so the example runs on its own. */
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 SuccessCell {
hour: number;
/** The row: the service's name. */
service: string;
/** Requests that succeeded, in per cent; `null` where there were none. */
success: number | null;
}
/** Yesterday, per service and hour: the share of requests that succeeded.
Webhooks has a bad late morning, 03:00 is bad everywhere (a DNS change),
and Reporting takes no requests before 06:00 - no requests, no share. */
const SUCCESS_BY_HOUR: readonly SuccessCell[] = (() => {
const r = random(23);
const cells: SuccessCell[] = [];
SERVICES.forEach((one) => {
for (let hour = 0; hour < 24; hour++) {
let success: number | null = 99.2 + r() * 0.8;
if (one.id === "webhooks" && hour >= 9 && hour < 14) success = 96 + r() * 1.5;
if (hour === 3) success = 97.4 + r() * 1;
if (one.id === "reports" && hour < 6) success = null;
cells.push({ hour, service: one.name, success: success === null ? null : Math.round(success * 100) / 100 });
}
});
return cells;
})();
const BATTERY = batteryDay("v2");
const STATES = vehicleDay("v2");
function BatteryAndStates() {
const { Chart, XAxis, YAxis, Line, StateBand } = useChart(BATTERY);
return (
{/* One lane, and four places of room above it for the course. */}
);
}
function SuccessCells() {
const { Chart, XAxis, YAxis, Matrix } = useChart(SUCCESS_BY_HOUR);
return (
`${v}:00`} label="Hour" />
);
}
export default function BandsLimitsAndCells() {
return (
);
}
```
##### Show that rows are on their way
`loading`, as the table has it: before the first rows the frame stands and a silhouette of the chart to come shimmers in it, shaped by the first series; over a course already drawn the course stays, dims after a moment and takes no pointer until the answer is in.
```tsx
import { useEffect, useMemo, useRef, useState } from "react";
import { useChart } from "@umriss-ui/charts";
interface Reading {
t: number;
p95: number;
}
const HOUR = 3_600_000;
const START = Date.UTC(2026, 9, 5, 6);
/** A morning of latency readings; each `answer` a little different. */
function readings(answer: number): Reading[] {
return Array.from({ length: 48 }, (_, i) => ({
t: START + i * (HOUR / 8),
p95: Math.round(180 + 40 * Math.sin((i + answer * 7) / 6) + ((i * 37 + answer * 11) % 23)),
}));
}
const NOTHING: Reading[] = [];
function Latency({ rows, loading, label }: { rows: Reading[]; loading: boolean; label: string }) {
const { Chart, XAxis, YAxis, Line } = useChart(rows);
return (
);
}
export default function Loading() {
const [answers, setAnswers] = useState(0);
const [loading, setLoading] = useState(false);
const pending = useRef | undefined>(undefined);
useEffect(() => () => clearTimeout(pending.current), []);
const rows = useMemo(() => readings(answers), [answers]);
const reload = () => {
setLoading(true);
clearTimeout(pending.current);
pending.current = setTimeout(() => {
setAnswers((n) => n + 1);
setLoading(false);
}, 1500);
};
return (
loading, before the first rows
loading over a course - the answer takes 1.5 s
);
}
```
##### Draw a series per group of long rows
A chart draws one series per element. Rows that come one per service and hour are grouped before it - a `Set` names the groups, a `filter` picks each one's rows - and each group is a series with its own `data`. Which rows belong together is yours to say.
```tsx
import { Legend, Tooltip, useChart } from "@umriss-ui/charts";
/* Failed requests in per cent, per service and hour - long rows, as a query
returns them. */
const ERRORS = [
{ t: "2026-03-17T06:00", service: "Checkout", errors: 0.12 },
{ t: "2026-03-17T06:00", service: "Billing", errors: 0.21 },
{ t: "2026-03-17T06:00", service: "Search", errors: 0.08 },
{ t: "2026-03-17T07:00", service: "Checkout", errors: 0.15 },
{ t: "2026-03-17T07:00", service: "Billing", errors: 0.19 },
{ t: "2026-03-17T07:00", service: "Search", errors: 0.09 },
{ t: "2026-03-17T08:00", service: "Checkout", errors: 0.14 },
{ t: "2026-03-17T08:00", service: "Billing", errors: 0.24 },
{ t: "2026-03-17T08:00", service: "Search", errors: 0.11 },
{ t: "2026-03-17T09:00", service: "Checkout", errors: 1.86 },
{ t: "2026-03-17T09:00", service: "Billing", errors: 0.92 },
{ t: "2026-03-17T09:00", service: "Search", errors: 0.1 },
{ t: "2026-03-17T10:00", service: "Checkout", errors: 2.41 },
{ t: "2026-03-17T10:00", service: "Billing", errors: 1.13 },
{ t: "2026-03-17T10:00", service: "Search", errors: 0.12 },
{ t: "2026-03-17T11:00", service: "Checkout", errors: 0.18 },
{ t: "2026-03-17T11:00", service: "Billing", errors: 0.22 },
{ t: "2026-03-17T11:00", service: "Search", errors: 0.09 },
];
/* Once, outside the component: a series' `data` is compared by reference. */
const BY_SERVICE = [...new Set(ERRORS.map((row) => row.service))].map((service) => ({
service,
rows: ERRORS.filter((row) => row.service === service),
}));
export default function SeriesFromLongRows() {
const { Chart, XAxis, YAxis, Line } = useChart(ERRORS);
return (
{BY_SERVICE.map(({ service, rows }) => (
))}
);
}
```
#### When to use something else
- A single number with a small trend beside it → `Sparkline` or `Stat` from @umriss-ui/core
- Work on lanes with a start and an end → `Schedule` from @umriss-ui/schedule
#### Keyboard
| Key | Action |
|---|---|
| `Tab` | Moves into the plot; the active point starts at the newest value. |
| `← →` | Walk the values of the series read first; in a matrix, move from cell to cell (↑ ↓ too). |
| `↑ ↓` | Choose which series is read first. |
| `Page Up Page Down` | Jump a page of values back or on. |
| `Home End` | Go to the first or the last value. |
| `+ −` | Zoom in and out, where the x axis is `zoomable`. |
| `Shift+← Shift+→` | Pan, where the x axis is `zoomable`. |
| `0` | Go back to the x axis' own domain, where it is `zoomable`. |
| `Escape` | Clears the active point. |
#### Accessibility
The plot is an image named by `ariaLabel` and described by a summary: the series, the visible stretch and each series' lowest and highest value in it. With a `Tooltip` it becomes one tab stop, the role `application` read as "chart", and the summary adds the keys. Pass `ariaLabel`: without it the plot has no name, and the development build warns.
When a key comes to rest, a polite live region beside the plot reads the active position and every series' value there, as the tooltip shows it; a pointer announces nothing. A reader who wants every value at once opens the `DataTable` (on [Tooltip](#/tooltip)), a plain table of what the chart shows.
Under forced colours the chart draws in the system colours and tells its series apart by their marks instead of by colour. With reduced motion the tooltip appears without fading in.
#### API
##### `ChartProps`
| Prop | Type | Default | Description |
|---|---|---|---|
| `width` | `number \| "100%"` | `"100%"` | Width in CSS pixels, or `"100%"` for the host's: the chart measures its host and follows it through a ResizeObserver. Shown in: [Size a chart by its container (Installation)](#/installation/in-its-container) |
| `height` | `number` | fills the frame, at least 300 px | Height in CSS pixels, fixed. Left out, the chart fills its frame - a stretched grid cell, a card the grid stretches, a container of definite height - and never goes below 300 px, which is also its height in a frame without one. A different floor goes through `style`'s `minHeight` ([ADR-0050](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0050-a-chart-fills-its-frame-never-below-its-floor.md)). Shown in: [Mix series kinds](#/chart/mixed), [Say there is nothing to show](#/chart/empty), [Read it by keyboard and screen reader](#/chart/keyboard-and-screen-reader) and 74 more |
| `padding` | `number \| Partial<`[`Padding`](#type-Padding)`>` | — | Outer spacing of the plot area in CSS pixels. Shown in: [Fix the domain and name the ticks (Axis)](#/axis/configuration) |
| `empty` | `ReactNode` | the wording's `empty` | Shown in the middle of the plot area when no visible series has a point to show - no data, only gaps, or every series hidden. Axes and frame stay. Not while `loading`. Shown in: [Say there is nothing to show](#/chart/empty) |
| `loading` | `boolean` | `false` | The rows are on their way, as the table's `loading` says ([ADR-0042](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0042-a-rows-height-never-follows-what-it-shows.md)). A course already drawn stays: it dims after a moment and takes no pointer until the answer is in. Where there is nothing to show, the empty message waits. The plot is `aria-busy` meanwhile. Shown in: [Show that rows are on their way](#/chart/loading) |
| `wording` | `Partial<`[`ChartsWording`](#type-ChartsWording)`>` | `DEFAULT_CHARTS_WORDING` | The chart's words ([ADR-0031](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0031-the-charts-carry-their-own-wording.md)); entries left out fall back to English. Its `locale` also formats every number and every date or time label the chart makes itself (`en-GB`, `de-DE`; the browser's language is not read) - a `tickFormat` or `format` of your own stays in charge of its axis or series. German: `GERMAN_CHARTS_WORDING` from `@umriss-ui/charts/wording/de`. Shown in: [Read it by keyboard and screen reader](#/chart/keyboard-and-screen-reader) |
| `encoding` | `"color" \| "marks"` | `"color"` | How series are told apart. `"color"`: by their colour. `"marks"`: by a dash and a marker shape as well - a line, an area and a scatter -, and by a hatch - a bar, a state, a matrix cell, a limit band -, chosen by the same palette place as the colour, so that a reader who does not see the colours still tells them apart; the legend's chips show the same. A caller's own `dash` wins over the place's. Shown in: [Tell series apart without colour](#/chart/told-apart-without-colour), [Mark every kind](#/chart/marks-on-every-kind), [Mark bands, limits and cells](#/chart/bands-limits-and-cells) |
| `syncId` | `string` | — | Charts with the same id share the pointer's x position, in domain units: each draws its crosshair there, the tooltip stays with the chart under the pointer. Zoom is not shared this way: charts zoom together by sending each other their view (`useChart`'s `onViewChange` and `setView`). Shown in: [Keep charts in step (Zoom and pan)](#/zoom-and-pan/cursor-sync), [Watch latency against its objective (Scenarios)](#/scenarios/watch-latency-against-its-objective), [Hold the tile length in control (Scenarios)](#/scenarios/hold-the-tile-length-in-control) |
| `children` | `ReactNode` | — | The axes, series and companions of this chart. They draw nothing themselves: each registers its configuration and the scene draws it, and the order in the JSX is the drawing order. Shown in: [Mix series kinds](#/chart/mixed), [Say there is nothing to show](#/chart/empty), [Read it by keyboard and screen reader](#/chart/keyboard-and-screen-reader) and 74 more |
###### Events
| Prop | Type | Default | Description |
|---|---|---|---|
| `onPerf` | `(perf: `[`ChartPerf`](#type-ChartPerf)`) => void` | — | Instrumentation for the benchmark page; not needed otherwise. Shown in: [Measure a million points (Benchmark)](#/benchmark/benchmark), [Draw a week of seconds (Benchmark)](#/benchmark/a-week-of-seconds) |
###### Accessibility
| Prop | Type | Default | Description |
|---|---|---|---|
| `ariaLabel` | `string` | — | Required in DEV through a warning. Shown in: [Mix series kinds](#/chart/mixed), [Say there is nothing to show](#/chart/empty), [Read it by keyboard and screen reader](#/chart/keyboard-and-screen-reader) and 74 more |
###### Styling
| Prop | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Goes to the root element, as it does everywhere in this workspace. |
| `style` | `CSSProperties` | — | Goes to the root element, after the chart's own styles: a `height` or `minHeight` set here wins. The plot area is observed in both directions and the scene follows it. |
##### Types on this page
###### `Padding`
Space in CSS pixels on each side of a rectangle.
| Prop | Type | Default | Description |
|---|---|---|---|
| `top` *required* | `number` | — | Shown in: [Fix the domain and name the ticks (Axis)](#/axis/configuration) |
| `right` *required* | `number` | — | Shown in: [Fix the domain and name the ticks (Axis)](#/axis/configuration) |
| `bottom` *required* | `number` | — | Shown in: [Fix the domain and name the ticks (Axis)](#/axis/configuration) |
| `left` *required* | `number` | — | Shown in: [Fix the domain and name the ticks (Axis)](#/axis/configuration) |
###### `ChartsWording`
Everything the charts put out as text.
| Prop | Type | Default | Description |
|---|---|---|---|
| `locale` *required* | `string` | — | The BCP 47 tag every number and every date or time label of the chart is formatted in (`en-GB`, `de-DE`). The one input for the locale: the browser's language is not read. The clock stays 24-hour in both. |
| `roleDescription` *required* | `string` | — | What a screen reader calls the plot area that takes the keys (`aria-roledescription`). |
| `empty` *required* | `string` | — | Shown in the plot area when no visible series has a point to show. |
| `seriesList` *required* | `(names: readonly string[]) => string` | — | The summary's visible series, by name. |
| `visibleRange` *required* | `(from: string, to: string) => string` | — | The summary's visible stretch of the x axis, both ends formatted. |
| `seriesExtent` *required* | `(name: string, min: string, max: string) => string` | — | The summary's lowest and highest value of one series in that stretch. |
| `limitList` *required* | `(limits: readonly { name: string; value: string }[]) => string` | — | The summary's labelled limits, each with its value in its axis' format (a band's two edges as one value). |
| `readout` *required* | `(x: string, rows: readonly { name: string; value: string; x: string }[]) => string` | — | What is read after a key: the position, then each series with its value (and its own x where it stands on another x axis), the emphasised one first. |
| `stackTotal` *required* | `string` | — | The last row of a stack in the tooltip and the readout: the sum of its members at the point. |
| `percent` *required* | `(value: string) => string` | — | A share in a normalised stack, its number already formatted. |
| `walkHelp` *required* | `string` | — | The keys that walk the chart. |
| `selectHelp` *required* | `string` | — | The key that reports the Active point - only where `onSelect` is given. |
| `zoomHelp` *required* | `string` | — | The keys that zoom and pan it - only where the chart can be zoomed. |
| `legendHelp` *required* | `string` | — | The keys of the legend's entries - only where it has one to press. |
| `allShown` *required* | `string` | — | Read out where hiding would leave nothing to see, and every series is shown instead. |
| `showAll` *required* | `string` | — | The control over a zoomed plot that brings every zoomed axis back to its own domain (component-view 04). |
| `showData` *required* | `string` | — | The data table's disclosure key, closed and open. |
| `hideData` *required* | `string` | — | |
| `positionColumn` *required* | `string` | — | The heading of the table's x column where its axis has no `label`, and of a matrix' row column where its y axis has none. |
| `rowColumn` *required* | `string` | — | |
| `tableCaption` *required* | `(from: string, to: string) => string` | — | The table's caption: the visible stretch it lists, both ends formatted. |
| `upperWhisker` *required* | `string` | — | A box's numbers, top to bottom as drawn: the tooltip's rows, the readout's parts and the data table's columns (box-plot B9). |
| `upperQuartile` *required* | `string` | — | |
| `median` *required* | `string` | — | |
| `lowerQuartile` *required* | `string` | — | |
| `lowerWhisker` *required* | `string` | — | |
| `outliers` *required* | `string` | — | A box's outliers: the row's name, and the row's value - their count and the first few values, the rest counted (box-plot B9). |
| `mean` *required* | `string` | — | A box's optional readings: its mean, its notch's two bounds as one row, and how many values stand behind it. |
| `notch` *required* | `string` | — | |
| `count` *required* | `string` | — | |
| `outlierList` *required* | `(values: readonly string[], more: number) => string` | — | |
| `downsampled` *required* | `(readings: number) => string` | — | Added to the caption where the table shows the downsampled course: how many readings its rows stand for. |
###### `ChartPerf`
Measurements of the instrumentation for the benchmark page.
| Prop | Type | Default | Description |
|---|---|---|---|
| `materializeMs` *required* | `number` | — | Shown in: [Measure a million points (Benchmark)](#/benchmark/benchmark) |
| `seriesDrawMs` *required* | `number` | — | Shown in: [Measure a million points (Benchmark)](#/benchmark/benchmark), [Draw a week of seconds (Benchmark)](#/benchmark/a-week-of-seconds) |
| `points` *required* | `number` | — | Shown in: [Measure a million points (Benchmark)](#/benchmark/benchmark) |
#### Known limits
- No error state of its own: where the rows could not be loaded, show an `Alert` from @umriss-ui/core. Waiting for them is `loading`.
- No pie, donut, radar or candlestick, no smoothing, animation or export, no WebGL ([ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/)).
What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/).
### Axis
What a value is measured against: extent, ticks and labels, categories, several axes per side, time on the local clock, and a working-time axis that leaves out the hours nobody works (a scale).
```ts
import { useChart, workingCalendar } from "@umriss-ui/charts";
```
Demo page: https://romanhaendler.github.io/umriss-ui/charts/axis/
Every scale is linear. An axis reads what its rows hold: a number is a position, a string a category, which gets a place, a tick and its name ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)); a working calendar maps time before it is drawn and marks every seam it took out ([ADR-0001](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0001-affine-scale-contract.md)).
An x axis zooms where it is `zoomable`; `domain` is then where it starts. That has a page of its own, [Zoom and pan](#/zoom-and-pan).
#### Examples
##### Fix the domain and name the ticks
A fixed `domain` holds the axis still, `tickFormat` writes the labels, and a typed `Tooltip` renders its own content.
```tsx
import { Legend, Tooltip, useChart } from "@umriss-ui/charts";
import type { TooltipHit } from "@umriss-ui/charts";
interface Week {
week: number;
/** Share of the team's capacity booked on client work, in per cent. */
web: number;
apps: number;
}
const BOOKED: Week[] = [
{ week: 1, web: 62, apps: 71 },
{ week: 2, web: 74, apps: 78 },
{ week: 3, web: 81, apps: 76 },
{ week: 4, web: 79, apps: 69 },
{ week: 5, web: 86, apps: 72 },
{ week: 6, web: 91, apps: 80 },
{ week: 7, web: 88, apps: 84 },
{ week: 8, web: 70, apps: 88 },
{ week: 9, web: 77, apps: 93 },
{ week: 10, web: 83, apps: 90 },
{ week: 11, web: 85, apps: 82 },
{ week: 12, web: 80, apps: 86 },
];
const percent = (v: number) => `${v.toFixed(0)} %`;
function WeekTooltip({ hit }: { hit: TooltipHit }) {
const point = hit.points[0];
return (
);
}
export default function Configuration() {
const { Chart, XAxis, YAxis, Line } = useChart(BOOKED);
return (
{/* The type argument makes the hit fully typed in the render prop. */}
mode="nearest" render={(hit) => } />
);
}
```
##### Add axes for other magnitudes
Several y axes stack outwards on their side, and a series picks its axes by `yAxisId` and `xAxisId`; only the first axis draws a grid.
```tsx
import { Legend, 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;
};
}
/** When this morning's load test of Search began. */
interface LoadTestPoint {
/** Minutes since the test began. */
minute: number;
/** CPU load of the busiest node, in per cent. */
cpu: number;
requestsPerHour: number;
/** 95th percentile latency, in ms. */
p95: number;
}
/** An hour of load test, minute by minute: three magnitudes far apart. */
const LOAD_TEST: readonly LoadTestPoint[] = (() => {
const r = random(99);
let cpu = 21;
let requestsPerHour = 128_000;
let p95 = 640;
return Array.from({ length: 60 }, (_, minute) => {
cpu += (r() - 0.5) * 0.9;
requestsPerHour += (r() - 0.5) * 9000;
p95 += (r() - 0.5) * 40;
return { minute, cpu, requestsPerHour, p95 };
});
})();
export default function Axes() {
const { Chart, XAxis, YAxis, Line } = useChart(LOAD_TEST);
return (
d.minute * 60} label="Seconds into the test" />
);
}
```
##### Leave out the hours nobody works
Pass the working hours as `calendar` and the axis drops nights and the weekend, marking every seam where time was taken out.
```tsx
import { Tooltip, useChart } from "@umriss-ui/charts";
/* Data from the logistics 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 MINUTE = 60_000;
const on = (month: number, day: number, hours = 0) => new Date(2026, month - 1, day, hours).getTime();
/** The North depot's sorting hours: Monday to Friday, 06:00 to 22:00. */
const SORTING_HOURS = Array.from({ length: 5 }, (_, day) => ({ from: on(3, 9 + day, 6), to: on(3, 9 + day, 22) }));
interface SortedPoint {
t: number;
/** Parcels sorted per hour. */
parcels: number;
}
/** Last week's sorting at the North depot, quarter hour by quarter hour -
only in its sorting hours. */
const SORTED: readonly SortedPoint[] = (() => {
const r = random(1963);
const points: SortedPoint[] = [];
let parcels = 420;
for (const { from } of SORTING_HOURS) {
for (let quarter = 0; quarter < 4 * 16; quarter++) {
parcels += (r() - 0.5) * 26;
points.push({ t: from + quarter * 15 * MINUTE, parcels: Math.round(parcels) });
}
}
return points;
})();
const weekdayAndTime = (v: number) =>
new Date(v).toLocaleString("en-GB", { weekday: "short", hour: "2-digit", minute: "2-digit" });
export default function WorkingTime() {
const { Chart, XAxis, YAxis, Line } = useChart(SORTED);
return (
);
}
```
##### Plot instants on a time axis
Rows that hold a `Date` put the axis in time; `time` does the same for milliseconds and for ISO strings. Ticks stand on the local clock, labelled by hour, with the date where a day begins.
```tsx
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: Date;
/** 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: new Date(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 CHECKOUT = metrics("checkout");
export default function TimeAxis() {
const { Chart, XAxis, YAxis, Line } = useChart(CHECKOUT);
return (
);
}
```
##### Align a second axis to the grid
With `alignTicks` a further y axis widens its domain until its ticks, in 1-2-5 steps, fall on the first axis' grid lines.
```tsx
import { Legend, 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;
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;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
/** Monday, 9 March 2026, 00:00 - the start of last week. */
const LAST_WEEK = at(9, 0);
/** Search returned nothing for some regions (INC-1043): Saturday, 18:30 to 19:55. */
const SEARCH_OUTAGE = [at(14, 18, 30) - LAST_WEEK, at(14, 19, 55) - LAST_WEEK] as const;
/** A service's last week, Monday to Monday, one point every `step`
milliseconds: busy by day, quieter at the weekend. During INC-1043 Search's
95th percentile more than doubles and its errors climb. The hour is counted,
not asked of a Date - a week of seconds is 604,800 points. */
function week(serviceId: string, step: number): 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(2000 + index * 37);
const n = Math.floor((7 * DAY) / step);
const points = new Array(n);
/* The drift wanders at the same pace whatever the step. */
const keep = Math.pow(0.8, step / (5 * MINUTE));
const kick = 0.08 * Math.sqrt(1 - keep * keep) / 0.6;
let drift = 0;
for (let i = 0; i < n; i++) {
const offset = i * step;
const day = Math.floor(offset / DAY);
const hour = (offset % DAY) / HOUR;
const load = (day >= 5 ? 0.85 : 1) * (0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16)));
drift = keep * drift + (r() - 0.5) * kick;
const outage = serviceId === "search" && offset >= SEARCH_OUTAGE[0] && offset < SEARCH_OUTAGE[1];
const p95 = Math.round(service.latencySlo * (0.45 + 0.2 * load + drift) * (outage ? 2.3 : 1) * (1 + r() * 0.08));
points[i] = {
t: LAST_WEEK + offset,
p50: Math.round(p95 * (0.42 + r() * 0.06)),
p95,
errorRate: Math.round((0.05 + r() * 0.12 + (outage ? 3 : 0)) * 100) / 100,
requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
};
}
return points;
}
const SEARCH_WEEK = week("search", 5 * 60_000);
export default function AlignedTicks() {
const { Chart, XAxis, YAxis, Line } = useChart(SEARCH_WEEK);
return (
);
}
```
##### Bind every kind of series to a second axis
In the plant: `xAxisId` and `yAxisId` work alike on every series kind. An area, bars, a line and a scatter read hourly counts on the right and top axes; the kiln's minutes keep the first two.
```tsx
import { Legend, Tooltip, useChart } from "@umriss-ui/charts";
/* Data from the plant world, written out here so the example runs on its own. */
/** A small LCG - reproducible across runs and platforms. */
function random(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
/** The early shift, 06:00 to 14:00. */
const SHIFT_MINUTES = 480;
/** The kiln's zone 3, in °C: the numbers every part reads it against, once.
Between the two warnings lies the tolerance - a tile fired outside it is
scrap; `returnTo` is the alarm's dead band. */
const KILN = { target: 1200, tolerance: [1185, 1215], alarm: 1230, returnTo: 1222 } as const;
/** The kiln fires one tile every quarter of a minute when nothing holds it up -
the ideal cycle time of the OEE's performance. */
const IDEAL_CYCLE_MINUTES = 0.25;
/** One minute of the line. */
interface Reading {
/** Minutes since the start of the shift. */
minute: number;
/** Zone 3 of the kiln, in °C. */
kiln: number;
/** The exit pyrometer, in °C - `null` while it sends nothing. */
exit: number | null;
/** Whether the belt runs. */
running: boolean;
/** Tiles out of the kiln in this minute. */
fired: number;
/** Of them, the ones fired inside the tolerance. */
good: number;
}
/** A tile taken off the belt and measured - one every ten minutes while it runs. */
interface Sample {
minute: number;
/** The tile's length after firing, in mm (nominal 600). */
length: number;
}
/** A batch of tiles through the three stations. */
interface Batch {
id: string;
name: string;
/** How many tiles the batch is planned for. */
tiles: number;
/** Minutes since the start of the shift, per station. */
press: readonly [number, number];
dryer: readonly [number, number];
kiln: readonly [number, number];
}
interface Plant {
readings: readonly Reading[];
samples: readonly Sample[];
batches: readonly Batch[];
}
/* When the exit pyrometer goes silent, and for how long. Fixed rather than
seeded, so that a running control room shows its reading turn stale, then
lost, and come back. */
const SILENT_FROM = 288;
const SILENT_FOR = 50;
/** The shift, from a seed. */
function plant(seed: number): Plant {
const r = random(seed);
/* The one excursion: a burner overshoots somewhere between 02:30 and 03:30
into the shift, for half an hour, peaking near 1242 °C. The operator stops
the feed twenty-two minutes in, for eighteen minutes. The noise around it
is too small to reach a warning on its own, so this is the only crossing -
which is what the tests hold. */
const surgeFrom = 150 + Math.floor(r() * 60);
const surgeFor = 30;
const stopFrom = surgeFrom + 22;
const stopFor = 18;
/* The plan: batches through the kiln one after another, each pressed an
hour and dried half an hour before - the first ones during the night
shift. Tiles come out of the kiln only while a batch is in it, so the
OEE's count and the batches' counts are the same tiles. The plan is a
plan: a stop of the belt does not move it. */
const batches: Batch[] = [];
for (let at = 0, i = 0; at < SHIFT_MINUTES; i++) {
const length = 70 + Math.floor(r() * 25);
batches.push({
id: `b-${4121 + i}`,
name: `B-${4121 + i}`,
tiles: Math.round(length / IDEAL_CYCLE_MINUTES / 10) * 10,
press: [at - 60, at - 60 + length],
dryer: [at - 30, at - 30 + length],
kiln: [at, at + length],
});
at += length + 5;
}
const readings: Reading[] = [];
const samples: Sample[] = [];
let drift = 0;
for (let minute = 0; minute < SHIFT_MINUTES; minute++) {
drift = 0.9 * drift + (r() - 0.5) * 3;
const into = minute - surgeFrom;
const surge = into >= 0 && into <= surgeFor ? 42 * Math.sin((Math.PI * into) / surgeFor) : 0;
const kiln = Math.round((KILN.target + drift + surge) * 10) / 10;
const running = !(minute >= stopFrom && minute < stopFrom + stopFor);
const loaded = batches.some((batch) => batch.kiln[0] <= minute && minute < batch.kiln[1]);
const jam = r() < 0.12;
const fired = running && loaded ? (jam ? 3 : 4) : 0;
const inTolerance = kiln >= KILN.tolerance[0] && kiln <= KILN.tolerance[1];
const silent = minute >= SILENT_FROM && minute < SILENT_FROM + SILENT_FOR;
const exit = silent ? null : Math.round((kiln - 380 + (r() - 0.5) * 4) * 10) / 10;
readings.push({ minute, kiln, exit, running, fired, good: inTolerance ? fired : 0 });
if (running && minute % 10 === 0) {
/* Hotter shrinks more: the length follows the kiln, so the control
chart sees the excursion the trend sees. */
const length = 600 + (KILN.target - kiln) * 0.05 + (r() - 0.5) * 0.6;
samples.push({ minute, length: Math.round(length * 100) / 100 });
}
}
return { readings, samples, batches };
}
/** One hour of the shift, counted. */
interface HourCount {
/** The middle of the hour, in hours since the start of the shift. */
hour: number;
/** Tiles the plan expects while a batch is in the kiln. */
planned: number;
fired: number;
good: number;
/** Fired outside the tolerance. */
scrap: number;
}
/** The minutes of the shift, summed up hour by hour. */
function hourly(shift: Plant): HourCount[] {
return Array.from({ length: SHIFT_MINUTES / 60 }, (_, h) => {
const minutes = shift.readings.slice(h * 60, (h + 1) * 60);
const loaded = minutes.filter((m) => shift.batches.some((b) => b.kiln[0] <= m.minute && m.minute < b.kiln[1])).length;
const fired = minutes.reduce((sum, m) => sum + m.fired, 0);
const good = minutes.reduce((sum, m) => sum + m.good, 0);
return { hour: h + 0.5, planned: loaded / IDEAL_CYCLE_MINUTES, fired, good, scrap: fired - good };
});
}
const SHIFT = plant(7);
const HOURS = hourly(SHIFT);
export default function EveryKind() {
const { Chart, XAxis, YAxis, Line, Area, Bar, Scatter } = useChart(SHIFT.readings);
return (
{/* This axis reads the hourly rows its series bring, so it names their type. */}
id="hour"
position="top"
value="hour"
domain={[0, SHIFT_MINUTES / 60]}
ticks={HOURS.map((d) => d.hour)}
tickFormat={(v) => `Hour ${Math.ceil(v)}`}
/>
{/* The kiln above, the counts below: each axis keeps to its own half. */}
);
}
```
##### Fix the order and keep an empty place
`categories` is the order and the extent: a day without a row keeps its place, and the rows may come as they like. `categoryLabel` writes a name where the rows hold a key.
```tsx
import { Tooltip, useChart } from "@umriss-ui/charts";
/* Minutes late per working day, as the tour log sends them: only the days
that had a delay, the worst first. */
const LATE = [
{ day: "thu", minutes: 164 },
{ day: "mon", minutes: 71 },
{ day: "fri", minutes: 58 },
{ day: "tue", minutes: 43 },
];
const WEEK = ["mon", "tue", "wed", "thu", "fri"];
const NAMES: Record = { mon: "Monday", tue: "Tuesday", wed: "Wednesday", thu: "Thursday", fri: "Friday" };
export default function Categories() {
const { Chart, XAxis, YAxis, Bar } = useChart(LATE);
return (
NAMES[day] ?? day} />
);
}
```
#### Keyboard
The keys of [Chart](#/chart/keyboard-chart) apply here.
#### API
##### `XAxisProps`
| Prop | Type | Default | Description |
|---|---|---|---|
| `value` *required* | [`AxisField`](#type-AxisField)` \| ((d: T, index: number) => `[`AxisValue`](#type-AxisValue)`)` | — | Where a row lies on this axis: a field of the row, compared by its name, or a function of it, compared by its source text as a series' is (`ValueFunction`) - with the same closure limit. A number is a position, a `Date` an instant, a string a category: every distinct one gets a place of its own, in the order the rows bring them, a tick and its name as the label ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). The type decides, never the content - `"2024"` is a category, `2024` a number - and a row without a value has no place. The axis reads the rows of every series bound to it; where those bring their own `data`, name their row: ` value="hour" />`. Shown in: [Fix the domain and name the ticks](#/axis/configuration), [Add axes for other magnitudes](#/axis/axes), [Leave out the hours nobody works](#/axis/working-time) and 74 more |
| `domain` | `"nice" \| "data" \| readonly [number, number] \| readonly [Date, Date]` | `"nice"` | `"nice"` widens the data's extent to ticks - to named `ticks` where there are any, on a time axis to whole units of a step of hours or longer -, `"data"` keeps it, a pair is fixed: numbers, on a time axis milliseconds or two `Date`s, on a category axis places. On a `zoomable` axis it is the start: what the axis shows while the chart's view names no span for it. Shown in: [Bind every kind of series to a second axis](#/axis/every-kind-on-a-second-axis), [Read it by keyboard and screen reader (Chart)](#/chart/keyboard-and-screen-reader), [Gestures and keys (Zoom and pan)](#/zoom-and-pan/gestures-and-keys) and 11 more |
| `position` | `"bottom" \| "top"` | `"bottom"` | Which edge the axis stands at. A second x axis on the opposite edge is how a series counts in a unit of its own. Shown in: [Add axes for other magnitudes](#/axis/axes), [Bind every kind of series to a second axis](#/axis/every-kind-on-a-second-axis) |
| `time` | `boolean` | — | The values are instants: milliseconds since the epoch, or with `time` said ISO 8601 strings as an API sends them, read by the platform's rule - a date with a time and no offset is local time, a date alone is UTC; a string that is none is not drawn. Rows that hold a `Date` put the axis in time without `time`. Ticks on local boundaries from the minute to the month, labels by level in en-GB with a 24-hour clock - `15:00`, `17 Mar`, `Mar 2026` - and the date on the first tick and on the first of a new day (`17 Mar 00:00`); a day tick there carries the year. The tooltip's x value carries the seconds where the readings lie less than a minute apart. Another language is a `tickFormat`, which is handed the instant in milliseconds. `calendar` implies it. Shown in: [Align a second axis to the grid](#/axis/aligned-ticks), [Draw a first chart (Installation)](#/installation/first-chart), [Size a chart by its container (Installation)](#/installation/in-its-container) and 36 more |
| `calendar` | `readonly `[`WorkingInterval`](#type-WorkingInterval)`[]` | — | Working calendar: the intervals in which time counts. With it the axis stands in working time - nights and weekends are out, and every removed span gets a break mark. The scale stays affine; the mapping happens in materialisation ([ADR-0001](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0001-affine-scale-contract.md)). Shown in: [Leave out the hours nobody works](#/axis/working-time) |
| `zoomable` | `boolean` | `false` | Zoom and pan: Ctrl or ⌘ with the wheel - and a pinch - zoom around the pointer, a drag, a horizontal wheel or Shift with the wheel pan, a double click goes back to `domain`; with the focus on the chart, + and − zoom, Shift with ← or → pans and 0 goes back. The span shown is part of the chart's view, by this axis' `id` (`useChart`'s `initialView`, `onViewChange`, `setView`, `setDomain`). The plain wheel always scrolls the page. Shown in: [Read it by keyboard and screen reader (Chart)](#/chart/keyboard-and-screen-reader), [Gestures and keys (Zoom and pan)](#/zoom-and-pan/gestures-and-keys), [Limit the zoom (Zoom and pan)](#/zoom-and-pan/zoom-limits) and 6 more |
| `zoomLimits` | [`ZoomLimits`](#type-ZoomLimits) | at most the data's extent or this axis' own `domain`, whichever is wider, at least three data steps | The narrowest and the widest span zoom may reach, in the axis' units - milliseconds on a time axis. Shown in: [Limit the zoom (Zoom and pan)](#/zoom-and-pan/zoom-limits) |
| `id` | `string` | `"x"` | Axis id, through which series bind themselves. Shown in: [Add axes for other magnitudes](#/axis/axes), [Align a second axis to the grid](#/axis/aligned-ticks), [Bind every kind of series to a second axis](#/axis/every-kind-on-a-second-axis) and 7 more |
| `label` | `string` | — | Axis title. Shown in: [Fix the domain and name the ticks](#/axis/configuration), [Add axes for other magnitudes](#/axis/axes), [Leave out the hours nobody works](#/axis/working-time) and 66 more |
| `tickCount` | `number` | — | Target value; the 1-2-5 algorithm may deviate. Shown in: [Fix the domain and name the ticks](#/axis/configuration), [Leave out the hours nobody works](#/axis/working-time), [Mix series kinds (Chart)](#/chart/mixed) and 8 more |
| `tickFormat` | `(v: number) => string` | — | Label of a tick. Compared by its source text: one that reads a changed closure variable under the same text does not relabel the axis - give it a new text or remount. A bound `Intl.NumberFormat#format` is compared by identity. Shown in: [Fix the domain and name the ticks](#/axis/configuration), [Leave out the hours nobody works](#/axis/working-time), [Bind every kind of series to a second axis](#/axis/every-kind-on-a-second-axis) and 21 more |
| `grid` | `boolean` | only on the first axis of its orientation | Grid lines. Shown in: [One vehicle through the day (StateBand)](#/stateband/one-vehicle), [Give each one a lane (StateBand)](#/stateband/one-lane-each), [See where the vans stand idle (Scenarios)](#/scenarios/see-where-the-vans-stand-idle) |
| `ticks` | `readonly number[]` | — | Fixed tick values instead of the 1-2-5 algorithm, on a numeric axis: an hour axis' named hours, a lane grid. An axis that reads categories needs none - every category has its tick. Shown in: [Bind every kind of series to a second axis](#/axis/every-kind-on-a-second-axis), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells), [Compare progress with a plan (Line)](#/line/burn-down) and 7 more |
| `categories` | `readonly string[]` | — | The categories of the axis, in their order. Without it they stand in the order the rows first bring them. With it the list is the order and the extent: a category no row carries keeps its empty place, and a row whose category is not listed is not drawn - DEV names it. It makes the axis a category axis whatever the rows hold ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). Shown in: [Fix the order and keep an empty place](#/axis/categories) |
| `categoryLabel` | `(category: string) => string` | the category itself | What is written for a category - under its tick, in the tooltip, the readout and the data table - where the rows hold a key and not a name. Two categories may share a label and keep two places. Compared by its source text, as `tickFormat` is. Shown in: [Fix the order and keep an empty place](#/axis/categories), [Tell series apart without colour (Chart)](#/chart/told-apart-without-colour), [Legend above or below (Legend)](#/legend/above-or-below) and 1 more |
##### `YAxisProps`
| Prop | Type | Default | Description |
|---|---|---|---|
| `domain` | `"nice" \| "data" \| "visible" \| readonly [number, number]` | `"nice"` | As on the x axis, and `"visible"`: what the series show inside their x axis' domain - a zoomed hour gets the hour's range, not the week's -, widened to ticks as `"nice"` is. Where the x domain is not fixed that is every point. Shown in: [Fix the domain and name the ticks](#/axis/configuration), [Bind every kind of series to a second axis](#/axis/every-kind-on-a-second-axis), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells) and 11 more |
| `alignTicks` | `boolean` | — | On a further y axis: take the first y axis' tick count and widen this axis' domain until its ticks fall on that one's grid lines, the steps still 1-2-5 - one grid serves both. `tickCount` then does not apply; on the first y axis it changes nothing. Shown in: [Align a second axis to the grid](#/axis/aligned-ticks) |
| `position` | `"left" \| "right"` | `"left"` | Which edge the axis stands at. Several y axes per side are stacked outwards, so that extents of clearly different magnitude stay readable. Shown in: [Add axes for other magnitudes](#/axis/axes), [Align a second axis to the grid](#/axis/aligned-ticks), [Bind every kind of series to a second axis](#/axis/every-kind-on-a-second-axis) and 7 more |
| `id` | `string` | `"y"` | Axis id, through which series bind themselves. Shown in: [Add axes for other magnitudes](#/axis/axes), [Align a second axis to the grid](#/axis/aligned-ticks), [Bind every kind of series to a second axis](#/axis/every-kind-on-a-second-axis) and 7 more |
| `label` | `string` | — | Axis title. Shown in: [Fix the domain and name the ticks](#/axis/configuration), [Add axes for other magnitudes](#/axis/axes), [Leave out the hours nobody works](#/axis/working-time) and 66 more |
| `tickCount` | `number` | — | Target value; the 1-2-5 algorithm may deviate. Shown in: [Fix the domain and name the ticks](#/axis/configuration), [Leave out the hours nobody works](#/axis/working-time), [Mix series kinds (Chart)](#/chart/mixed) and 8 more |
| `tickFormat` | `(v: number) => string` | — | Label of a tick. Compared by its source text: one that reads a changed closure variable under the same text does not relabel the axis - give it a new text or remount. A bound `Intl.NumberFormat#format` is compared by identity. Shown in: [Fix the domain and name the ticks](#/axis/configuration), [Leave out the hours nobody works](#/axis/working-time), [Bind every kind of series to a second axis](#/axis/every-kind-on-a-second-axis) and 21 more |
| `grid` | `boolean` | only on the first axis of its orientation | Grid lines. Shown in: [One vehicle through the day (StateBand)](#/stateband/one-vehicle), [Give each one a lane (StateBand)](#/stateband/one-lane-each), [See where the vans stand idle (Scenarios)](#/scenarios/see-where-the-vans-stand-idle) |
| `ticks` | `readonly number[]` | — | Fixed tick values instead of the 1-2-5 algorithm, on a numeric axis: an hour axis' named hours, a lane grid. An axis that reads categories needs none - every category has its tick. Shown in: [Bind every kind of series to a second axis](#/axis/every-kind-on-a-second-axis), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells), [Compare progress with a plan (Line)](#/line/burn-down) and 7 more |
| `categories` | `readonly string[]` | — | The categories of the axis, in their order. Without it they stand in the order the rows first bring them. With it the list is the order and the extent: a category no row carries keeps its empty place, and a row whose category is not listed is not drawn - DEV names it. It makes the axis a category axis whatever the rows hold ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). Shown in: [Fix the order and keep an empty place](#/axis/categories) |
| `categoryLabel` | `(category: string) => string` | the category itself | What is written for a category - under its tick, in the tooltip, the readout and the data table - where the rows hold a key and not a name. Two categories may share a label and keep two places. Compared by its source text, as `tickFormat` is. Shown in: [Fix the order and keep an empty place](#/axis/categories), [Tell series apart without colour (Chart)](#/chart/told-apart-without-colour), [Legend above or below (Legend)](#/legend/above-or-below) and 1 more |
##### Types on this page
###### `AxisField`
The fields of a row an axis can read - or, with `V`, those that hold only
some of what an axis reads: a matrix' row is a position or a category,
never an instant.
```ts
type AxisField = NoInfer<
{
[K in keyof T & string]-?: T[K] extends V ? K : never;
}[keyof T & string]
>;
```
###### `AxisValue`
What an axis reads from a row ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)): a number - a position, or an
instant on a time axis -, a string, which is a category, or a `Date`,
which is an instant. The type of the value decides what kind the axis is,
never its content: `"2024"` is a category, `2024` a number. On an axis
that says `time` a string is an ISO 8601 instant. null/undefined is no
place.
```ts
type AxisValue = number | string | Date | null | undefined;
```
###### `WorkingInterval`
An interval in which time counts; thought of as half-open, but mappable from
both sides at its edges: both `from` and `to` have a working time.
| Prop | Type | Default | Description |
|---|---|---|---|
| `from` *required* | `number` | — | |
| `to` *required* | `number` | — | |
###### `ZoomLimits`
The narrowest and the widest span zoom may reach, in the axis' units - the
schedule's shape.
| Prop | Type | Default | Description |
|---|---|---|---|
| `min` *required* | `number` | — | Shown in: [Limit the zoom (Zoom and pan)](#/zoom-and-pan/zoom-limits) |
| `max` *required* | `number` | — | Shown in: [Limit the zoom (Zoom and pan)](#/zoom-and-pan/zoom-limits) |
#### Known limits
- No logarithmic and no band scale: compute the logarithm in `value`; a category is a place on the linear scale ([ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/), [ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)).
- umriss shows the reader's time ([ADR-0060](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0060-validation-and-a-layout-editor-are-never-time-zones-not-yet.md)).
What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/).
### Zoom and pan
Look closer at a stretch of a long series and move along it (zoom, pan, a viewport), by wheel, pinch, drag or key, and keep several charts on the same stretch.
```ts
import { useChart } from "@umriss-ui/charts";
```
Demo page: https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/
`zoomable` on an x axis turns the gestures and keys on; nothing else is needed. The span shown is part of the chart's view ([ADR-0047](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0047-a-component-holds-its-own-view.md)): `useChart(rows, { initialView, onViewChange })` takes a start and reports what the reader does, whole, at most once per frame; `setView(view)`, `domains` and `setDomain(axisId, span | null)` stand on what the hook returns. A lone x axis is `"x"`.
Once an axis is zoomed, the chart offers 'Show all' over a corner of its plot - a button of its own, a tab stop beside the plot's, that brings every zoomed axis back and goes. Its words are the wording's `showAll`.
Charts stay in step by sending each other what the reader did: each chart's `onViewChange` calls the other's `setView`. `setView` is not reported, so two charts linked both ways do not answer each other, and a pan does not jump back. `syncId` shares only the pointer.
A zoom stops at `zoomLimits`; without them at three readings and at the data's extent. A y axis with `domain="visible"` follows the stretch shown.
#### Examples
##### Gestures and keys
With `zoomable` the x axis zooms on Ctrl or ⌘ with the wheel, a pinch or + and −, pans on a drag or Shift with the arrows, and goes back on a double click, 0 or 'Show all' - without a line of state of yours.
```tsx
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;
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;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
/** Monday, 9 March 2026, 00:00 - the start of last week. */
const LAST_WEEK = at(9, 0);
/** Search returned nothing for some regions (INC-1043): Saturday, 18:30 to 19:55. */
const SEARCH_OUTAGE = [at(14, 18, 30) - LAST_WEEK, at(14, 19, 55) - LAST_WEEK] as const;
/** A service's last week, Monday to Monday, one point every `step`
milliseconds: busy by day, quieter at the weekend. During INC-1043 Search's
95th percentile more than doubles and its errors climb. The hour is counted,
not asked of a Date - a week of seconds is 604,800 points. */
function week(serviceId: string, step: number): 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(2000 + index * 37);
const n = Math.floor((7 * DAY) / step);
const points = new Array(n);
/* The drift wanders at the same pace whatever the step. */
const keep = Math.pow(0.8, step / (5 * MINUTE));
const kick = 0.08 * Math.sqrt(1 - keep * keep) / 0.6;
let drift = 0;
for (let i = 0; i < n; i++) {
const offset = i * step;
const day = Math.floor(offset / DAY);
const hour = (offset % DAY) / HOUR;
const load = (day >= 5 ? 0.85 : 1) * (0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16)));
drift = keep * drift + (r() - 0.5) * kick;
const outage = serviceId === "search" && offset >= SEARCH_OUTAGE[0] && offset < SEARCH_OUTAGE[1];
const p95 = Math.round(service.latencySlo * (0.45 + 0.2 * load + drift) * (outage ? 2.3 : 1) * (1 + r() * 0.08));
points[i] = {
t: LAST_WEEK + offset,
p50: Math.round(p95 * (0.42 + r() * 0.06)),
p95,
errorRate: Math.round((0.05 + r() * 0.12 + (outage ? 3 : 0)) * 100) / 100,
requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
};
}
return points;
}
const SEARCH_WEEK = week("search", 60_000);
export default function GesturesAndKeys() {
const { Chart, XAxis, YAxis, Line } = useChart(SEARCH_WEEK);
return (
{/* The span shown is the chart's own: `domain` is where it starts. */}
);
}
```
##### Limit the zoom
`zoomLimits` keeps the span between a narrowest and a widest; without it a zoom stops at three readings and at the data's extent.
```tsx
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;
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;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
/** Monday, 9 March 2026, 00:00 - the start of last week. */
const LAST_WEEK = at(9, 0);
/** Search returned nothing for some regions (INC-1043): Saturday, 18:30 to 19:55. */
const SEARCH_OUTAGE = [at(14, 18, 30) - LAST_WEEK, at(14, 19, 55) - LAST_WEEK] as const;
/** A service's last week, Monday to Monday, one point every `step`
milliseconds: busy by day, quieter at the weekend. During INC-1043 Search's
95th percentile more than doubles and its errors climb. The hour is counted,
not asked of a Date - a week of seconds is 604,800 points. */
function week(serviceId: string, step: number): 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(2000 + index * 37);
const n = Math.floor((7 * DAY) / step);
const points = new Array(n);
/* The drift wanders at the same pace whatever the step. */
const keep = Math.pow(0.8, step / (5 * MINUTE));
const kick = 0.08 * Math.sqrt(1 - keep * keep) / 0.6;
let drift = 0;
for (let i = 0; i < n; i++) {
const offset = i * step;
const day = Math.floor(offset / DAY);
const hour = (offset % DAY) / HOUR;
const load = (day >= 5 ? 0.85 : 1) * (0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16)));
drift = keep * drift + (r() - 0.5) * kick;
const outage = serviceId === "search" && offset >= SEARCH_OUTAGE[0] && offset < SEARCH_OUTAGE[1];
const p95 = Math.round(service.latencySlo * (0.45 + 0.2 * load + drift) * (outage ? 2.3 : 1) * (1 + r() * 0.08));
points[i] = {
t: LAST_WEEK + offset,
p50: Math.round(p95 * (0.42 + r() * 0.06)),
p95,
errorRate: Math.round((0.05 + r() * 0.12 + (outage ? 3 : 0)) * 100) / 100,
requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
};
}
return points;
}
const SEARCH_WEEK = week("search", 60_000);
export default function ZoomLimits() {
const { Chart, XAxis, YAxis, Line } = useChart(SEARCH_WEEK);
return (
{/* Never closer than an hour, never wider than two days - the week
shows at the start and once more after a double click. */}
);
}
```
##### Fit the y axis to what is visible
With `domain="visible"` the y axis fits the zoomed stretch - here Saturday evening's outage, handed in as `initialView` - and follows when you zoom out.
```tsx
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;
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;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
/** Monday, 9 March 2026, 00:00 - the start of last week. */
const LAST_WEEK = at(9, 0);
/** Search returned nothing for some regions (INC-1043): Saturday, 18:30 to 19:55. */
const SEARCH_OUTAGE = [at(14, 18, 30) - LAST_WEEK, at(14, 19, 55) - LAST_WEEK] as const;
/** A service's last week, Monday to Monday, one point every `step`
milliseconds: busy by day, quieter at the weekend. During INC-1043 Search's
95th percentile more than doubles and its errors climb. The hour is counted,
not asked of a Date - a week of seconds is 604,800 points. */
function week(serviceId: string, step: number): 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(2000 + index * 37);
const n = Math.floor((7 * DAY) / step);
const points = new Array(n);
/* The drift wanders at the same pace whatever the step. */
const keep = Math.pow(0.8, step / (5 * MINUTE));
const kick = 0.08 * Math.sqrt(1 - keep * keep) / 0.6;
let drift = 0;
for (let i = 0; i < n; i++) {
const offset = i * step;
const day = Math.floor(offset / DAY);
const hour = (offset % DAY) / HOUR;
const load = (day >= 5 ? 0.85 : 1) * (0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16)));
drift = keep * drift + (r() - 0.5) * kick;
const outage = serviceId === "search" && offset >= SEARCH_OUTAGE[0] && offset < SEARCH_OUTAGE[1];
const p95 = Math.round(service.latencySlo * (0.45 + 0.2 * load + drift) * (outage ? 2.3 : 1) * (1 + r() * 0.08));
points[i] = {
t: LAST_WEEK + offset,
p50: Math.round(p95 * (0.42 + r() * 0.06)),
p95,
errorRate: Math.round((0.05 + r() * 0.12 + (outage ? 3 : 0)) * 100) / 100,
requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
};
}
return points;
}
const SEARCH_WEEK = week("search", 60_000);
const SATURDAY = new Date(2026, 2, 14).getTime();
export default function VisibleDomain() {
const { Chart, XAxis, YAxis, Line } = useChart(SEARCH_WEEK, {
// The lone x axis is "x": its span is where the chart starts.
initialView: { domains: { x: [SATURDAY + 16 * HOUR, SATURDAY + 22 * HOUR] } },
});
return (
);
}
```
##### Keep charts in step
Charts with one `syncId` share the pointer; send each one's `onViewChange` to the others' `setView`, and a zoom in any of them zooms all.
```tsx
import { useEffect, useState } from "react";
import { Tooltip, useChart, type ChartView } 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;
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;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
/** Monday, 9 March 2026, 00:00 - the start of last week. */
const LAST_WEEK = at(9, 0);
/** Search returned nothing for some regions (INC-1043): Saturday, 18:30 to 19:55. */
const SEARCH_OUTAGE = [at(14, 18, 30) - LAST_WEEK, at(14, 19, 55) - LAST_WEEK] as const;
/** A service's last week, Monday to Monday, one point every `step`
milliseconds: busy by day, quieter at the weekend. During INC-1043 Search's
95th percentile more than doubles and its errors climb. The hour is counted,
not asked of a Date - a week of seconds is 604,800 points. */
function week(serviceId: string, step: number): 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(2000 + index * 37);
const n = Math.floor((7 * DAY) / step);
const points = new Array(n);
/* The drift wanders at the same pace whatever the step. */
const keep = Math.pow(0.8, step / (5 * MINUTE));
const kick = 0.08 * Math.sqrt(1 - keep * keep) / 0.6;
let drift = 0;
for (let i = 0; i < n; i++) {
const offset = i * step;
const day = Math.floor(offset / DAY);
const hour = (offset % DAY) / HOUR;
const load = (day >= 5 ? 0.85 : 1) * (0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16)));
drift = keep * drift + (r() - 0.5) * kick;
const outage = serviceId === "search" && offset >= SEARCH_OUTAGE[0] && offset < SEARCH_OUTAGE[1];
const p95 = Math.round(service.latencySlo * (0.45 + 0.2 * load + drift) * (outage ? 2.3 : 1) * (1 + r() * 0.08));
points[i] = {
t: LAST_WEEK + offset,
p50: Math.round(p95 * (0.42 + r() * 0.06)),
p95,
errorRate: Math.round((0.05 + r() * 0.12 + (outage ? 3 : 0)) * 100) / 100,
requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
};
}
return points;
}
const IMAGES_WEEK = week("images", 5 * 60_000);
const CHANNELS = [
{ name: "Requests", unit: "/min", value: "requests" },
{ name: "p95", unit: "ms", value: "p95" },
{ name: "p50", unit: "ms", value: "p50" },
] as const;
export default function CursorSync() {
// The three share a view: each sends the others what the reader did to it.
const [peers] = useState(() => new Map void>());
return (
{/* Each plot begins where its y axis ends: labels of one width - three
figures here - keep the crosshairs in one column. */}
{CHANNELS.map((c) => (
))}
);
}
function Channel({ channel: c, peers }: { channel: (typeof CHANNELS)[number]; peers: Map void> }) {
const { Chart, XAxis, YAxis, Line, setView } = useChart(IMAGES_WEEK, {
onViewChange: (view) => {
for (const [name, goTo] of peers) if (name !== c.name) goTo(view);
},
});
useEffect(() => {
peers.set(c.name, setView);
return () => void peers.delete(c.name);
}, [peers, c.name, setView]);
return (
);
}
```
#### Keyboard
The keys of [Chart](#/chart/keyboard-chart) apply here.
#### API
##### `XAxisProps`
| Prop | Type | Default | Description |
|---|---|---|---|
| `value` *required* | [`AxisField`](#type-AxisField)` \| ((d: T, index: number) => `[`AxisValue`](#type-AxisValue)`)` | — | Where a row lies on this axis: a field of the row, compared by its name, or a function of it, compared by its source text as a series' is (`ValueFunction`) - with the same closure limit. A number is a position, a `Date` an instant, a string a category: every distinct one gets a place of its own, in the order the rows bring them, a tick and its name as the label ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). The type decides, never the content - `"2024"` is a category, `2024` a number - and a row without a value has no place. The axis reads the rows of every series bound to it; where those bring their own `data`, name their row: ` value="hour" />`. Shown in: [Gestures and keys](#/zoom-and-pan/gestures-and-keys), [Limit the zoom](#/zoom-and-pan/zoom-limits), [Fit the y axis to what is visible](#/zoom-and-pan/visible-domain) and 74 more |
| `domain` | `"nice" \| "data" \| readonly [number, number] \| readonly [Date, Date]` | `"nice"` | `"nice"` widens the data's extent to ticks - to named `ticks` where there are any, on a time axis to whole units of a step of hours or longer -, `"data"` keeps it, a pair is fixed: numbers, on a time axis milliseconds or two `Date`s, on a category axis places. On a `zoomable` axis it is the start: what the axis shows while the chart's view names no span for it. Shown in: [Gestures and keys](#/zoom-and-pan/gestures-and-keys), [Limit the zoom](#/zoom-and-pan/zoom-limits), [Fit the y axis to what is visible](#/zoom-and-pan/visible-domain) and 11 more |
| `position` | `"bottom" \| "top"` | `"bottom"` | Which edge the axis stands at. A second x axis on the opposite edge is how a series counts in a unit of its own. Shown in: [Add axes for other magnitudes (Axis)](#/axis/axes), [Bind every kind of series to a second axis (Axis)](#/axis/every-kind-on-a-second-axis) |
| `time` | `boolean` | — | The values are instants: milliseconds since the epoch, or with `time` said ISO 8601 strings as an API sends them, read by the platform's rule - a date with a time and no offset is local time, a date alone is UTC; a string that is none is not drawn. Rows that hold a `Date` put the axis in time without `time`. Ticks on local boundaries from the minute to the month, labels by level in en-GB with a 24-hour clock - `15:00`, `17 Mar`, `Mar 2026` - and the date on the first tick and on the first of a new day (`17 Mar 00:00`); a day tick there carries the year. The tooltip's x value carries the seconds where the readings lie less than a minute apart. Another language is a `tickFormat`, which is handed the instant in milliseconds. `calendar` implies it. Shown in: [Gestures and keys](#/zoom-and-pan/gestures-and-keys), [Limit the zoom](#/zoom-and-pan/zoom-limits), [Fit the y axis to what is visible](#/zoom-and-pan/visible-domain) and 36 more |
| `calendar` | `readonly `[`WorkingInterval`](#type-WorkingInterval)`[]` | — | Working calendar: the intervals in which time counts. With it the axis stands in working time - nights and weekends are out, and every removed span gets a break mark. The scale stays affine; the mapping happens in materialisation ([ADR-0001](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0001-affine-scale-contract.md)). Shown in: [Leave out the hours nobody works (Axis)](#/axis/working-time) |
| `zoomable` | `boolean` | `false` | Zoom and pan: Ctrl or ⌘ with the wheel - and a pinch - zoom around the pointer, a drag, a horizontal wheel or Shift with the wheel pan, a double click goes back to `domain`; with the focus on the chart, + and − zoom, Shift with ← or → pans and 0 goes back. The span shown is part of the chart's view, by this axis' `id` (`useChart`'s `initialView`, `onViewChange`, `setView`, `setDomain`). The plain wheel always scrolls the page. Shown in: [Gestures and keys](#/zoom-and-pan/gestures-and-keys), [Limit the zoom](#/zoom-and-pan/zoom-limits), [Fit the y axis to what is visible](#/zoom-and-pan/visible-domain) and 6 more |
| `zoomLimits` | [`ZoomLimits`](#type-ZoomLimits) | at most the data's extent or this axis' own `domain`, whichever is wider, at least three data steps | The narrowest and the widest span zoom may reach, in the axis' units - milliseconds on a time axis. Shown in: [Limit the zoom](#/zoom-and-pan/zoom-limits) |
| `id` | `string` | `"x"` | Axis id, through which series bind themselves. Shown in: [Add axes for other magnitudes (Axis)](#/axis/axes), [Align a second axis to the grid (Axis)](#/axis/aligned-ticks), [Bind every kind of series to a second axis (Axis)](#/axis/every-kind-on-a-second-axis) and 7 more |
| `label` | `string` | — | Axis title. Shown in: [Gestures and keys](#/zoom-and-pan/gestures-and-keys), [Limit the zoom](#/zoom-and-pan/zoom-limits), [Fit the y axis to what is visible](#/zoom-and-pan/visible-domain) and 66 more |
| `tickCount` | `number` | — | Target value; the 1-2-5 algorithm may deviate. Shown in: [Keep charts in step](#/zoom-and-pan/cursor-sync), [Fix the domain and name the ticks (Axis)](#/axis/configuration), [Leave out the hours nobody works (Axis)](#/axis/working-time) and 8 more |
| `tickFormat` | `(v: number) => string` | — | Label of a tick. Compared by its source text: one that reads a changed closure variable under the same text does not relabel the axis - give it a new text or remount. A bound `Intl.NumberFormat#format` is compared by identity. Shown in: [Fix the domain and name the ticks (Axis)](#/axis/configuration), [Leave out the hours nobody works (Axis)](#/axis/working-time), [Bind every kind of series to a second axis (Axis)](#/axis/every-kind-on-a-second-axis) and 21 more |
| `grid` | `boolean` | only on the first axis of its orientation | Grid lines. Shown in: [One vehicle through the day (StateBand)](#/stateband/one-vehicle), [Give each one a lane (StateBand)](#/stateband/one-lane-each), [See where the vans stand idle (Scenarios)](#/scenarios/see-where-the-vans-stand-idle) |
| `ticks` | `readonly number[]` | — | Fixed tick values instead of the 1-2-5 algorithm, on a numeric axis: an hour axis' named hours, a lane grid. An axis that reads categories needs none - every category has its tick. Shown in: [Bind every kind of series to a second axis (Axis)](#/axis/every-kind-on-a-second-axis), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells), [Compare progress with a plan (Line)](#/line/burn-down) and 7 more |
| `categories` | `readonly string[]` | — | The categories of the axis, in their order. Without it they stand in the order the rows first bring them. With it the list is the order and the extent: a category no row carries keeps its empty place, and a row whose category is not listed is not drawn - DEV names it. It makes the axis a category axis whatever the rows hold ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). Shown in: [Fix the order and keep an empty place (Axis)](#/axis/categories) |
| `categoryLabel` | `(category: string) => string` | the category itself | What is written for a category - under its tick, in the tooltip, the readout and the data table - where the rows hold a key and not a name. Two categories may share a label and keep two places. Compared by its source text, as `tickFormat` is. Shown in: [Fix the order and keep an empty place (Axis)](#/axis/categories), [Tell series apart without colour (Chart)](#/chart/told-apart-without-colour), [Legend above or below (Legend)](#/legend/above-or-below) and 1 more |
##### `ChartOptions`
| Prop | Type | Default | Description |
|---|---|---|---|
| `initialView` | [`ChartView`](#/view/type-ChartView) | — | The view to start from, read once when the hook first runs; a different value on a later render is ignored, as React ignores a changed `defaultValue`. To go to a view later, call `setView`. The chart remembers none: where a view is kept is the application's decision. Shown in: [Fit the y axis to what is visible](#/zoom-and-pan/visible-domain), [Hide a series from the legend (Legend)](#/legend/hide-a-series) |
###### Events
| Prop | Type | Default | Description |
|---|---|---|---|
| `onViewChange` | `(view: `[`ChartView`](#/view/type-ChartView)`) => void` | — | Every change the reader makes, once, always the whole view - the one to keep, or to hand another chart's `setView`. A pan or zoom is reported at most once per frame. Not called for the view the chart starts with, nor for one `setView` applies. Shown in: [Keep charts in step](#/zoom-and-pan/cursor-sync), [Read it by keyboard and screen reader (Chart)](#/chart/keyboard-and-screen-reader) |
| `onSelect` | `(hit: `[`TooltipHit`](#type-TooltipHit)`) => void` | — | The reader chose a point: a click on a hit (a tap too), or Enter on the Active point - the same hit the tooltip's `render` receives. Once per click; the end of a drag does not report, and the second click of a double click does not either. The chart holds no selection and draws none: what is chosen is the application's state. Needs a ``, which makes the hits; the hit does not travel over `syncId`. Shown in: [Choose a hit (Tooltip)](#/tooltip/choose-a-hit) |
##### Types on this page
###### `AxisField`
The fields of a row an axis can read - or, with `V`, those that hold only
some of what an axis reads: a matrix' row is a position or a category,
never an instant.
```ts
type AxisField = NoInfer<
{
[K in keyof T & string]-?: T[K] extends V ? K : never;
}[keyof T & string]
>;
```
###### `AxisValue`
What an axis reads from a row ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)): a number - a position, or an
instant on a time axis -, a string, which is a category, or a `Date`,
which is an instant. The type of the value decides what kind the axis is,
never its content: `"2024"` is a category, `2024` a number. On an axis
that says `time` a string is an ISO 8601 instant. null/undefined is no
place.
```ts
type AxisValue = number | string | Date | null | undefined;
```
###### `WorkingInterval`
An interval in which time counts; thought of as half-open, but mappable from
both sides at its edges: both `from` and `to` have a working time.
| Prop | Type | Default | Description |
|---|---|---|---|
| `from` *required* | `number` | — | |
| `to` *required* | `number` | — | |
###### `ZoomLimits`
The narrowest and the widest span zoom may reach, in the axis' units - the
schedule's shape.
| Prop | Type | Default | Description |
|---|---|---|---|
| `min` *required* | `number` | — | Shown in: [Limit the zoom](#/zoom-and-pan/zoom-limits) |
| `max` *required* | `number` | — | Shown in: [Limit the zoom](#/zoom-and-pan/zoom-limits) |
###### `TooltipHit`
What the pointer hit: the x value under it and one point per series there.
A custom tooltip's `render` receives it.
| Prop | Type | Default | Description |
|---|---|---|---|
| `xValue` *required* | `number` | — | Shown in: [Fix the domain and name the ticks (Axis)](#/axis/configuration), [Write the tooltip yourself (Tooltip)](#/tooltip/own-content), [Choose a hit (Tooltip)](#/tooltip/choose-a-hit) |
| `points` *required* | `readonly `[`TooltipPoint`](#type-TooltipPoint)`[]` | — | Shown in: [Fix the domain and name the ticks (Axis)](#/axis/configuration), [Write the tooltip yourself (Tooltip)](#/tooltip/own-content), [Choose a hit (Tooltip)](#/tooltip/choose-a-hit) |
| `xPx` *required* | `number` | — | Pixel position of the crosshair (snapped to the data point). |
| `yPx` *required* | `number` | — | Pixel position of the primary hit. |
###### `TooltipPoint`
One hit per series in the tooltip.
| Prop | Type | Default | Description |
|---|---|---|---|
| `seriesName` *required* | `string` | — | Shown in: [Fix the domain and name the ticks (Axis)](#/axis/configuration), [Write the tooltip yourself (Tooltip)](#/tooltip/own-content), [Choose a hit (Tooltip)](#/tooltip/choose-a-hit) |
| `color` *required* | `string` | — | |
| `xValue` *required* | `number` | — | The point's x value on its own x axis - with several x axes it can differ from the hit's. |
| `yValue` *required* | `number` | — | The point's y value; in a stack its own value, not its top - in a normalised stack its share in percent. Shown in: [Fix the domain and name the ticks (Axis)](#/axis/configuration), [Choose a hit (Tooltip)](#/tooltip/choose-a-hit) |
| `datum` *required* | `T` | — | Shown in: [Write the tooltip yourself (Tooltip)](#/tooltip/own-content) |
| `index` *required* | `number` | — | |
| `level` | `number` | — | Only for the matrix: its level, out of the level channel. It stands here and not in yValue, because yValue is the position on the y axis - for a matrix therefore the row. Putting both into one field would be exactly the overloading [ADR-0011](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0011-named-channels-never-overloaded.md) is written against. |
| `segment` | `{ from: number; to: number; label: string }` | — | Only for state series: the section being pointed at. A duration does not stand here - it is the difference of two numbers the entry already carries, and formatting it would presuppose knowing what the x axis means. The caller knows that, not this library. |
| `box` | [`BoxNumbers`](#type-BoxNumbers)` & Partial<`[`BoxExtras`](#type-BoxExtras)`` > & { /** As the caller gave them; only where the series has `outliers`. */ outliers?: readonly number[]; } `` | — | Only for a box: its further numbers; yValue is its median. Fields of their own for the reason `level` has one ([ADR-0011](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0011-named-channels-never-overloaded.md)). |
###### `BoxNumbers`
A box's further numbers beside its median, by name.
| Prop | Type | Default | Description |
|---|---|---|---|
| `lowerQuartile` *required* | `V` | — | |
| `upperQuartile` *required* | `V` | — | |
| `lowerWhisker` *required* | `V` | — | |
| `upperWhisker` *required* | `V` | — | |
###### `BoxExtras`
The optional numbers of a box by name.
| Prop | Type | Default | Description |
|---|---|---|---|
| `mean` *required* | `V` | — | |
| `notchLower` *required* | `V` | — | |
| `notchUpper` *required* | `V` | — | |
| `count` *required* | `V` | — | |
#### Known limits
- No zoom along y, no rectangle zoom and no navigator: the x span is the one zoom there is.
What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/).
### View
How a reader is looking at a chart - the span in view and the hidden series - kept by the chart, reported whole, and gone back to with `setView` to restore it (saved state, a bookmark, a reload).
```ts
import { useChart } from "@umriss-ui/charts";
```
Demo page: https://romanhaendler.github.io/umriss-ui/charts/view/
A chart holds its own view ([ADR-0047](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0047-a-component-holds-its-own-view.md)): `{ domains, hidden }` - the span each zoomable x axis shows, by axis `id`, and the hidden series, by `name`. Whatever is at its default is absent, so a chart nobody touched reports `{}`.
`useChart(rows, { initialView, onViewChange })`: `onViewChange` hears every change the reader makes once, always the whole view; `initialView` is the start, read once - a different value on a later render is ignored; `setView(view)` goes to a whole view later and is not reported. An axis id or a series name that no longer occurs falls out. Where a view is kept - state, storage, an address - is the application's decision; the chart remembers none.
#### Examples
##### Keep and restore a view
`onViewChange` reports the span in view and the hidden series as one view; keep it - here in the browser's storage, so it outlives a reload - and go back to it with `setView`. Zoom, hide a series, keep, reload, restore.
```tsx
import { useState } from "react";
import { Legend, Tooltip, useChart, type ChartView } 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 SERIES = [
{ name: "Checkout", data: metrics("checkout") },
{ name: "Billing", data: metrics("billing") },
{ name: "Search", data: metrics("search") },
];
const KEPT = "umriss-charts-demo-view";
/* Storage can be missing or refuse - a private window, a blocked site. The
view is then simply not kept. */
function readKept(): ChartView | null {
try {
const stored = localStorage.getItem(KEPT);
return stored === null ? null : (JSON.parse(stored) as ChartView);
} catch {
return null;
}
}
function keep(view: ChartView): void {
try {
localStorage.setItem(KEPT, JSON.stringify(view));
} catch {
/* Not kept; the chart works on regardless. */
}
}
export default function KeepAndRestore() {
/* The chart holds its view; `view` is what it shows now, and `setView`
restores a kept one. */
const [kept, setKept] = useState(readKept);
const { Chart, XAxis, YAxis, Line, view, setView } = useChart(SERIES[0]!.data);
return (
<>
{SERIES.map((one) => (
))}
What the application would keep
{/* JSON has no space to break at: the class lets it break anywhere. */}
{JSON.stringify(view)}
>
);
}
```
#### Keyboard
The keys of [Chart](#/chart/keyboard-chart) apply here.
#### API
##### `ChartOptions`
| Prop | Type | Default | Description |
|---|---|---|---|
| `initialView` | [`ChartView`](#type-ChartView) | — | The view to start from, read once when the hook first runs; a different value on a later render is ignored, as React ignores a changed `defaultValue`. To go to a view later, call `setView`. The chart remembers none: where a view is kept is the application's decision. Shown in: [Fit the y axis to what is visible (Zoom and pan)](#/zoom-and-pan/visible-domain), [Hide a series from the legend (Legend)](#/legend/hide-a-series) |
###### Events
| Prop | Type | Default | Description |
|---|---|---|---|
| `onViewChange` | `(view: `[`ChartView`](#type-ChartView)`) => void` | — | Every change the reader makes, once, always the whole view - the one to keep, or to hand another chart's `setView`. A pan or zoom is reported at most once per frame. Not called for the view the chart starts with, nor for one `setView` applies. Shown in: [Keep charts in step (Zoom and pan)](#/zoom-and-pan/cursor-sync), [Read it by keyboard and screen reader (Chart)](#/chart/keyboard-and-screen-reader) |
| `onSelect` | `(hit: `[`TooltipHit`](#type-TooltipHit)`) => void` | — | The reader chose a point: a click on a hit (a tap too), or Enter on the Active point - the same hit the tooltip's `render` receives. Once per click; the end of a drag does not report, and the second click of a double click does not either. The chart holds no selection and draws none: what is chosen is the application's state. Needs a ``, which makes the hits; the hit does not travel over `syncId`. Shown in: [Choose a hit (Tooltip)](#/tooltip/choose-a-hit) |
##### `ChartView`
| Prop | Type | Default | Description |
|---|---|---|---|
| `domains` | [`AxisSpans`](#type-AxisSpans) | — | The span each zoomable x axis shows, by axis `id` - a lone x axis is `"x"`. An axis absent here shows its own `domain`. An id that names no zoomable x axis falls out. Shown in: [Fit the y axis to what is visible (Zoom and pan)](#/zoom-and-pan/visible-domain) |
| `hidden` | `readonly string[]` | — | The hidden series, by `name`; a series without one cannot be hidden. A name no series carries falls out. Shown in: [Hide a series from the legend (Legend)](#/legend/hide-a-series) |
##### Types on this page
###### `TooltipHit`
What the pointer hit: the x value under it and one point per series there.
A custom tooltip's `render` receives it.
| Prop | Type | Default | Description |
|---|---|---|---|
| `xValue` *required* | `number` | — | Shown in: [Fix the domain and name the ticks (Axis)](#/axis/configuration), [Write the tooltip yourself (Tooltip)](#/tooltip/own-content), [Choose a hit (Tooltip)](#/tooltip/choose-a-hit) |
| `points` *required* | `readonly `[`TooltipPoint`](#type-TooltipPoint)`[]` | — | Shown in: [Fix the domain and name the ticks (Axis)](#/axis/configuration), [Write the tooltip yourself (Tooltip)](#/tooltip/own-content), [Choose a hit (Tooltip)](#/tooltip/choose-a-hit) |
| `xPx` *required* | `number` | — | Pixel position of the crosshair (snapped to the data point). |
| `yPx` *required* | `number` | — | Pixel position of the primary hit. |
###### `AxisSpans`
The span each zoomable x axis shows, by axis `id`, in the axis' units.
```ts
type AxisSpans = Readonly>;
```
###### `TooltipPoint`
One hit per series in the tooltip.
| Prop | Type | Default | Description |
|---|---|---|---|
| `seriesName` *required* | `string` | — | Shown in: [Fix the domain and name the ticks (Axis)](#/axis/configuration), [Write the tooltip yourself (Tooltip)](#/tooltip/own-content), [Choose a hit (Tooltip)](#/tooltip/choose-a-hit) |
| `color` *required* | `string` | — | |
| `xValue` *required* | `number` | — | The point's x value on its own x axis - with several x axes it can differ from the hit's. |
| `yValue` *required* | `number` | — | The point's y value; in a stack its own value, not its top - in a normalised stack its share in percent. Shown in: [Fix the domain and name the ticks (Axis)](#/axis/configuration), [Choose a hit (Tooltip)](#/tooltip/choose-a-hit) |
| `datum` *required* | `T` | — | Shown in: [Write the tooltip yourself (Tooltip)](#/tooltip/own-content) |
| `index` *required* | `number` | — | |
| `level` | `number` | — | Only for the matrix: its level, out of the level channel. It stands here and not in yValue, because yValue is the position on the y axis - for a matrix therefore the row. Putting both into one field would be exactly the overloading [ADR-0011](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0011-named-channels-never-overloaded.md) is written against. |
| `segment` | `{ from: number; to: number; label: string }` | — | Only for state series: the section being pointed at. A duration does not stand here - it is the difference of two numbers the entry already carries, and formatting it would presuppose knowing what the x axis means. The caller knows that, not this library. |
| `box` | [`BoxNumbers`](#type-BoxNumbers)` & Partial<`[`BoxExtras`](#type-BoxExtras)`` > & { /** As the caller gave them; only where the series has `outliers`. */ outliers?: readonly number[]; } `` | — | Only for a box: its further numbers; yValue is its median. Fields of their own for the reason `level` has one ([ADR-0011](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0011-named-channels-never-overloaded.md)). |
###### `BoxNumbers`
A box's further numbers beside its median, by name.
| Prop | Type | Default | Description |
|---|---|---|---|
| `lowerQuartile` *required* | `V` | — | |
| `upperQuartile` *required* | `V` | — | |
| `lowerWhisker` *required* | `V` | — | |
| `upperWhisker` *required* | `V` | — | |
###### `BoxExtras`
The optional numbers of a box by name.
| Prop | Type | Default | Description |
|---|---|---|---|
| `mean` *required* | `V` | — | |
| `notchLower` *required* | `V` | — | |
| `notchUpper` *required* | `V` | — | |
| `count` *required* | `V` | — | |
#### Known limits
- The active point and the open data table are not part of the view: they are where the reader is, not how the chart is looked at.
What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/).
## Series
The kinds a chart can draw. They share the axes and mix freely; the order in the JSX decides what lies over what.
### Line
Joins the readings of a series into a course (a line chart, a trend). Reach for it for anything measured over time: latency, a balance, hours left.
```ts
import { useChart } from "@umriss-ui/charts";
```
Demo page: https://romanhaendler.github.io/umriss-ui/charts/line/
A missing reading breaks the line; nothing is drawn across a gap. With `step` a value holds until the next one, for things logged only when they change.
#### Examples
##### One series
A `Line` joins the readings of its `value` in the order of x; the first y axis draws the grid.
```tsx
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 CHECKOUT = metrics("checkout");
export default function OneSeries() {
const { Chart, XAxis, YAxis, Line } = useChart(CHECKOUT);
return (
);
}
```
##### Several series and a gap
Each `Line` may bring its own `data`; a `null` reading breaks the line instead of being bridged, and a lone reading keeps its marker.
```tsx
import { Legend, 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 CHECKOUT = metrics("checkout");
const BILLING = metrics("billing");
const SIGN_IN = metrics("sign-in");
/* The image service's exporter restarted from 04:25 to 05:45 and sent a
single reading at 05:05 in between. */
const IMAGES = metrics("images").map((d) => {
const minutes = new Date(d.t).getHours() * 60 + new Date(d.t).getMinutes();
const lost = minutes >= 4 * 60 + 25 && minutes <= 5 * 60 + 45 && minutes !== 5 * 60 + 5;
return lost ? { ...d, p95: null } : d;
});
export default function MultiSeries() {
const { Chart, XAxis, YAxis, Line } = useChart(CHECKOUT);
return (
);
}
```
##### Hold a value until it changes
A value logged only when it changes holds until the next entry; `step` draws it so, and a gap ends the hold.
```tsx
import { Legend, 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;
}
interface ReplicaChange {
t: number;
/** Checkout's instances from here on; `null` while the autoscaler was paused. */
replicas: number | null;
}
/** Checkout's instances today, logged only when the autoscaler changes them.
It was paused for a database upgrade from 03:00 to 04:00; the last entry,
at now, closes the hold. */
const REPLICAS: readonly ReplicaChange[] = [
{ t: at(17, 0), replicas: 4 },
{ t: at(17, 3), replicas: null },
{ t: at(17, 4), replicas: 3 },
{ t: at(17, 6, 30), replicas: 5 },
{ t: at(17, 8), replicas: 8 },
{ t: at(17, 9, 45), replicas: 12 },
{ t: at(17, 10, 15), replicas: 9 },
{ t: NOW, replicas: 9 },
];
const CHECKOUT = metrics("checkout");
export default function StepLine() {
const { Chart, XAxis, YAxis, Line } = useChart(CHECKOUT);
return (
);
}
```
##### Compare progress with a plan
A sprint's burn-down: the ideal as a quiet dashed line, the hours left as the course, which stops where the days to come begin.
```tsx
import { Legend, Tooltip, useChart } from "@umriss-ui/charts";
/* Data from the planning world, written out here so the example runs on its own. */
const at = (day: number, hours = 9, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
/** A work item, shaped as `@umriss-ui/schedule`'s `Subtask`. */
interface WorkItem {
id: string;
/** The project. */
task: string;
/** The person. */
lane: string;
from: number;
to: number;
name: string;
sprint: string;
/** Hours, as estimated at planning. */
estimate: number;
status: "to do" | "in progress" | "in review" | "done";
}
const item = (id: string, task: string, lane: string, from: number, to: number, name: string, estimate: number, status: WorkItem["status"]): WorkItem =>
({ id, task, lane, from, to, name, sprint: "sprint-14", estimate, status });
const WORK: readonly WorkItem[] = [
item("w-101", "portal", "arjun", at(9), at(11, 17), "Sign-in with e-mail code", 18, "done"),
item("w-102", "portal", "arjun", at(12), at(17, 17), "Profile page", 26, "in progress"),
item("w-103", "portal", "chloe", at(9), at(10, 17), "Session handling", 12, "done"),
item("w-104", "shop", "chloe", at(11), at(13, 17), "Basket keeps items across devices", 20, "in review"),
item("w-105", "portal", "chloe", at(16), at(19, 17), "Change of address form", 24, "in progress"),
item("w-106", "portal", "noah", at(9), at(12, 13), "Profile page design", 14, "done"),
item("w-107", "shop", "noah", at(16), at(18, 17), "Search results layout", 12, "to do"),
item("w-108", "portal", "eva", at(12), at(13, 17), "Test plan for sign-in", 10, "done"),
item("w-109", "portal", "eva", at(17), at(20, 17), "Regression run", 20, "to do"),
item("w-110", "booking", "hana", at(9), at(13, 17), "Reminder scheduling service", 32, "done"),
item("w-111", "booking", "hana", at(16), at(20, 17), "Push notifications", 30, "in progress"),
item("w-112", "booking", "kofi", at(10), at(12, 17), "Calendar sync", 18, "done"),
item("w-113", "intranet", "kofi", at(16), at(18, 17), "News feed", 16, "in progress"),
item("w-114", "booking", "freya", at(9), at(11, 17), "Reminder settings screen", 16, "done"),
item("w-115", "booking", "freya", at(18), at(20, 17), "Store screenshots", 12, "to do"),
item("w-116", "booking", "david", at(16), at(19, 13), "Device test matrix", 14, "to do"),
];
interface BurndownPoint {
/** The working day, at 17:00. */
t: number;
/** Where the line would be, burning evenly. */
ideal: number;
/** Hours left, `null` for the days still to come. */
remaining: number | null;
}
/** Sprint 14's ten working days. The team fell behind in the first week, when
two were away, and is catching up. */
const BURNDOWN: readonly BurndownPoint[] = (() => {
const total = WORK.reduce((sum, one) => sum + one.estimate, 0);
const days = [9, 10, 11, 12, 13, 16, 17, 18, 19, 20];
const burnt = [0, 22, 38, 60, 71, 98];
return days.map((day, i) => ({
t: at(day, 17),
ideal: Math.round(total * (1 - i / (days.length - 1))),
remaining: burnt[i] === undefined ? null : total - burnt[i]!,
}));
})();
const day = (v: number) => new Date(v).toLocaleDateString("en-GB", { weekday: "short", day: "numeric" });
export default function BurnDown() {
const { Chart, XAxis, YAxis, Line } = useChart(BURNDOWN);
return (
d.t)} tickFormat={day} />
);
}
```
#### When to use something else
- A quantity read from its foot → Area
- Single samples with nothing measured between them → Scatter
#### Keyboard
The keys of [Chart](#/chart/keyboard-chart) apply here.
#### API
##### `LineProps`
| Prop | Type | Default | Description |
|---|---|---|---|
| `value` *required* | [`Value`](#type-Value)`` | — | Y value - a number field of the row or a function of it; null/undefined/NaN/±Infinity means a gap. Shown in: [One series](#/line/one-series), [Several series and a gap](#/line/multi-series), [Hold a value until it changes](#/line/step) and 47 more |
| `xAxisId` | `string` | `"x"` | Binding to an x axis. Shown in: [Add axes for other magnitudes (Axis)](#/axis/axes), [Bind every kind of series to a second axis (Axis)](#/axis/every-kind-on-a-second-axis) |
| `yAxisId` | `string` | `"y"` | Binding to a y axis. Shown in: [Hold a value until it changes](#/line/step), [Add axes for other magnitudes (Axis)](#/axis/axes), [Align a second axis to the grid (Axis)](#/axis/aligned-ticks) and 4 more |
| `data` | `readonly T[]` | — | Series-own data; overrides the container data. Shown in: [Several series and a gap](#/line/multi-series), [Hold a value until it changes](#/line/step), [Tell series apart without colour (Chart)](#/chart/told-apart-without-colour) and 6 more |
| `name` | `string` | "Series n", after its place in the chart | The name in legend and tooltip. Without one a warning stands in DEV - an unnamed series is a colour nobody can look up. Shown in: [One series](#/line/one-series), [Several series and a gap](#/line/multi-series), [Hold a value until it changes](#/line/step) and 47 more |
| `format` | `(value: number) => string` | the y axis' `tickFormat`, else the built-in number format | The value as the tooltip writes it. Shown in: [Write each value in its unit (Tooltip)](#/tooltip/value-format), [Show the values as a table (Tooltip)](#/tooltip/data-table), [Table a long series (Tooltip)](#/tooltip/a-week-as-a-table) and 1 more |
| `color` | `string` | the palette's `--uc-series-N` | Any CSS colour value. Shown in: [Compare progress with a plan](#/line/burn-down), [Fix the domain and name the ticks (Axis)](#/axis/configuration), [Watch latency against its objective (Scenarios)](#/scenarios/watch-latency-against-its-objective) and 1 more |
| `tone` | `"ok" \| "warning" \| "alarm"` | — | A role instead of a colour value; the theme resolves it. Shown in: [Compare the budget with the forecast (Scenarios)](#/scenarios/compare-budget-with-forecast) |
| `strokeWidth` | `number` | `1.5` | Line width in CSS pixels. Shown in: [Hold a value until it changes](#/line/step), [Compare progress with a plan](#/line/burn-down), [Mix series kinds (Chart)](#/chart/mixed) and 9 more |
| `dash` | `readonly number[]` | a solid line | Dash pattern as a run of lengths in CSS pixels. Shown in: [Several series and a gap](#/line/multi-series), [Compare progress with a plan](#/line/burn-down), [Add axes for other magnitudes (Axis)](#/axis/axes) and 1 more |
| `markers` | `"auto" \| "always" \| "never"` | `"auto"` | When the individual points are drawn as marks: `auto` every point up to 60 points and above that only a point between two gaps - it has no line to be seen by - `always`, or `never`, not even that one. Shown in: [Compare progress with a plan](#/line/burn-down), [Draw a series per group of long rows (Chart)](#/chart/series-from-long-rows), [Fix the domain and name the ticks (Axis)](#/axis/configuration) and 4 more |
| `step` | `boolean` | — | Sample-and-hold: each value holds as a horizontal until the next sample and jumps there - a target, a digital signal. A gap ends the hold at its x. The tooltip reports the sample the hold began with. Shown in: [Hold a value until it changes](#/line/step) |
##### Types on this page
###### `Value`
A value read from the row ([ADR-0048](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0048-charts-are-bound-to-their-rows-by-the-hook.md)): the name of a number field, compared
by its name, or a function, compared by its source text (`ValueFunction`).
```ts
type Value = NumberField | ValueFunction;
```
###### `NumberField`
The fields of a row whose value is a number, or absent.
```ts
type NumberField = NoInfer<
{
[K in keyof T & string]-?: T[K] extends number | null | undefined ? K : never;
}[keyof T & string]
>;
```
###### `ValueFunction`
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.
```ts
type ValueFunction = (d: T, index: number) => number | null | undefined;
```
#### Known limits
- No smoothing: a curve between two readings would invent values nobody measured ([ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/)).
What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/).
### Area
A filled course (an area chart): down to zero for a quantity, between two edges for a range, stacked for parts of a whole.
```ts
import { useChart } from "@umriss-ui/charts";
```
Demo page: https://romanhaendler.github.io/umriss-ui/charts/area/
Without `baseline` the foot is 0 and stays in the y extent. A range's lower edge is the `baseline` of the same series, not a second series - where either edge is missing, the fill has a hole.
#### Examples
##### Fill down to zero
Without `baseline` an area is filled down to 0, and 0 stays in the y extent - a quantity is read from its foot.
```tsx
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 CHECKOUT = metrics("checkout");
export default function Filled() {
const { Chart, XAxis, YAxis, Area } = useChart(CHECKOUT);
return (
);
}
```
##### Fill a range between two edges
Give `baseline` the lower edge and the area becomes a range; where either edge is missing, the fill has a hole. `dash` breaks the outline, never the fill.
```tsx
import { Legend, 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 = 60 * MINUTE;
interface ExpectedPoint {
t: number;
/** The range the requests per minute are expected in; `null` where the
job that computes it did not run. */
low: number | null;
high: number | null;
}
/** The range a service's requests per minute are expected in today, every
five minutes - the day's usual curve, give or take 15 %. The job that
computes it skipped 03:00 to 04:00. */
function expected(serviceId: string): ExpectedPoint[] {
const service = SERVICES.find((one) => one.id === serviceId);
if (service === undefined) throw new Error(`No service "${serviceId}".`);
const points: ExpectedPoint[] = [];
for (let t = at(17, 0); t <= NOW; t += 5 * MINUTE) {
const hour = (t - at(17, 0)) / HOUR;
const usual = VOLUME[service.tier] * (0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16)));
const skipped = hour >= 3 && hour < 4;
points.push({ t, low: skipped ? null : Math.round(usual * 0.85), high: skipped ? null : Math.round(usual * 1.15) });
}
return points;
}
const CHECKOUT = metrics("checkout");
const EXPECTED = expected("checkout");
export default function Corridor() {
const { Chart, XAxis, YAxis, Area, Line } = useChart(CHECKOUT);
return (
{/* The outline runs along the upper edge; dashed, it reads as expected, not measured. */}
);
}
```
##### Stack areas
Areas with the same `stack` stand on the ones before them: the top edge is the whole, each band one depot's part. Hide a depot from the legend, and the stack closes over the gap.
```tsx
import { Legend, Tooltip, useChart } from "@umriss-ui/charts";
/* Data from the logistics 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 MINUTE = 60_000;
const on = (month: number, day: number, hours = 0) => new Date(2026, month - 1, day, hours).getTime();
const HOUR = 60 * MINUTE;
interface DepotHour {
t: number;
/** Parcels loaded per hour at each depot. */
north: number;
river: number;
east: number;
}
/** Monday 06:00 to Tuesday 06:00, hour by hour. East Gate closes at night and
loads nothing - 0, not nothing. */
const PARCELS_PER_HOUR: readonly DepotHour[] = (() => {
const r = random(906);
return Array.from({ length: 25 }, (_, i) => {
const hour = (6 + i) % 24;
const night = hour >= 22 || hour < 6;
return {
t: on(3, 16, 6) + i * HOUR,
north: Math.round(120 + r() * 30),
river: Math.round((night ? 60 : 90) + r() * 25),
east: night ? 0 : Math.round(70 + r() * 30),
};
});
})();
export default function StackedAreas() {
const { Chart, XAxis, YAxis, Area } = useChart(PARCELS_PER_HOUR);
return (
);
}
```
##### Show shares of the whole over time
`normalize` on a stack makes every hour sum to 100 %: the axis reads in per cent, and `format` still writes the tooltip's readings in tonnes.
```tsx
import { Legend, Tooltip, useChart } from "@umriss-ui/charts";
/* Data from the logistics 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 HOUR = 3_600_000;
const on = (month: number, day: number, hours = 0) => new Date(2026, month - 1, day, hours).getTime();
interface DepotLoad {
t: number;
/** Freight loaded per hour at each depot, in tonnes. */
north: number;
river: number;
east: number;
}
/** Monday 06:00 to Tuesday 06:00, hour by hour. East Gate closes at night, and
Riverside's night trucks take over its share. */
const TONNES_PER_HOUR: readonly DepotLoad[] = (() => {
const r = random(911);
return Array.from({ length: 25 }, (_, i) => {
const hour = (6 + i) % 24;
const night = hour >= 22 || hour < 6;
return {
t: on(3, 16, 6) + i * HOUR,
north: Math.round((6 + r() * 2) * 10) / 10,
river: Math.round(((night ? 7 : 4) + r() * 1.5) * 10) / 10,
east: night ? 0 : Math.round((3.5 + r() * 2) * 10) / 10,
};
});
})();
const tonnes = (v: number) => `${v.toFixed(1)} t`;
export default function Shares() {
const { Chart, XAxis, YAxis, Area } = useChart(TONNES_PER_HOUR);
return (
);
}
```
#### When to use something else
- A value without a natural zero → Line
#### Keyboard
The keys of [Chart](#/chart/keyboard-chart) apply here.
#### API
##### `AreaProps`
| Prop | Type | Default | Description |
|---|---|---|---|
| `value` *required* | [`Value`](#type-Value)`` | — | Upper edge - a number field of the row or a function of it; null/undefined/NaN/±Infinity means a gap. Shown in: [Fill down to zero](#/area/filled), [Fill a range between two edges](#/area/corridor), [Stack areas](#/area/stacked) and 5 more |
| `baseline` | [`Value`](#type-Value)`` | a fixed baseline at 0 | Lower edge - a number field of the row or a function of it. In a `stack` the stack below is the lower edge, and this is not read. Shown in: [Fill a range between two edges](#/area/corridor), [Mix series kinds (Chart)](#/chart/mixed), [Mark every kind (Chart)](#/chart/marks-on-every-kind) |
| `xAxisId` | `string` | `"x"` | Binding to an x axis. Shown in: [Bind every kind of series to a second axis (Axis)](#/axis/every-kind-on-a-second-axis) |
| `yAxisId` | `string` | `"y"` | Binding to a y axis. Shown in: [Bind every kind of series to a second axis (Axis)](#/axis/every-kind-on-a-second-axis) |
| `data` | `readonly T[]` | — | Series-own data; overrides the container data. Shown in: [Fill a range between two edges](#/area/corridor), [Bind every kind of series to a second axis (Axis)](#/axis/every-kind-on-a-second-axis) |
| `name` | `string` | "Series n", after its place in the chart | The name in legend and tooltip. Without one a warning stands in DEV - an unnamed series is a colour nobody can look up. Shown in: [Fill down to zero](#/area/filled), [Fill a range between two edges](#/area/corridor), [Stack areas](#/area/stacked) and 5 more |
| `format` | `(value: number) => string` | the y axis' `tickFormat`, else the built-in number format | The value as the tooltip writes it. Shown in: [Show shares of the whole over time](#/area/shares) |
| `color` | `string` | the palette's `--uc-series-N` | Any CSS colour value. |
| `tone` | `"ok" \| "warning" \| "alarm"` | — | A role instead of a colour value; the theme resolves it. `color` beats it. |
| `stack` | `string` | — | The stack this series stands in: every Bar and Area with the same id on the same x and y axis stands on the ones registered before it. A gap stacks as zero; negative values stack downward from zero. The tooltip names each series' own value and the stack's total. Shown in: [Stack areas](#/area/stacked), [Show shares of the whole over time](#/area/shares) |
| `normalize` | `boolean` | — | On any member of a stack, every x of the stack sums to 100 %: each value becomes its share, and the y axis reads in percent unless it has a `tickFormat`. The total stays the readings' own sum, written in the series' `format`. Shown in: [Show shares of the whole over time](#/area/shares) |
| `fillOpacity` | `number` | `0.18` | Opacity of the fill, from 0 to 1; the outline stays fully opaque. Shown in: [Fill down to zero](#/area/filled), [Stack areas](#/area/stacked), [Show shares of the whole over time](#/area/shares) |
| `strokeWidth` | `number` | `1.5` | Width of the outline along the upper edge in CSS pixels; 0 leaves it out. Shown in: [Fill down to zero](#/area/filled), [Fill a range between two edges](#/area/corridor), [Stack areas](#/area/stacked) and 2 more |
| `dash` | `readonly number[]` | a solid outline | Dash pattern of the outline as a run of lengths in CSS pixels. The fill stays whole. Shown in: [Fill a range between two edges](#/area/corridor) |
##### Types on this page
###### `Value`
A value read from the row ([ADR-0048](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0048-charts-are-bound-to-their-rows-by-the-hook.md)): the name of a number field, compared
by its name, or a function, compared by its source text (`ValueFunction`).
```ts
type Value = NumberField | ValueFunction;
```
###### `NumberField`
The fields of a row whose value is a number, or absent.
```ts
type NumberField = NoInfer<
{
[K in keyof T & string]-?: T[K] extends number | null | undefined ? K : never;
}[keyof T & string]
>;
```
###### `ValueFunction`
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.
```ts
type ValueFunction = (d: T, index: number) => number | null | undefined;
```
### Bar
Bars along the x axis (a bar or column chart): one per category, grouped side by side, stacked, or as shares of a whole.
```ts
import { useChart, bin } from "@umriss-ui/charts";
```
Demo page: https://romanhaendler.github.io/umriss-ui/charts/bar/
The x axis reads the category from the row - a cost centre, a weekday - and gives each a place, a tick and its name ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)); a number or an instant places a bar as well. A bar's foot is 0, so one series carries both signs. `bin()` counts values into the rows of a histogram: each bin's `from`, `to`, middle `x` and `count`, a value on an edge in the bin that edge starts, the last bin closed, values that are not numbers skipped; a `domain` is taken as given and what lies outside it is left out.
#### Examples
##### One bar per category
Give the x axis the field that holds the category: each one gets a place, a tick and its name, in the order of the rows ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)).
```tsx
import { Tooltip, useChart } from "@umriss-ui/charts";
/* February's spend per cost centre, as the ledger sends it. */
const SPEND = [
{ costCentre: "Sales", actual: 134_447 },
{ costCentre: "Marketing", actual: 76_588 },
{ costCentre: "Engineering", actual: 183_157 },
{ costCentre: "Design", actual: 51_761 },
{ costCentre: "Customer service", actual: 61_160 },
{ costCentre: "Finance", actual: 46_353 },
{ costCentre: "People", actual: 37_886 },
{ costCentre: "IT", actual: 74_313 },
{ costCentre: "Facilities", actual: 71_848 },
];
export default function PerCategory() {
const { Chart, XAxis, YAxis, Bar } = useChart(SPEND);
return (
);
}
```
##### Group bars side by side
Bar series on one x axis stand beside each other; give each the same `barWidth`, and the reference a quiet `color`.
```tsx
import { Legend, Tooltip, useChart } from "@umriss-ui/charts";
/* February's budget and spend per cost centre, as the ledger sends them. */
const FEBRUARY = [
{ costCentre: "Sales", budget: 142_000, actual: 134_447 },
{ costCentre: "Marketing", budget: 68_000, actual: 76_588 },
{ costCentre: "Engineering", budget: 188_000, actual: 183_157 },
{ costCentre: "Design", budget: 54_000, actual: 51_761 },
{ costCentre: "Customer service", budget: 61_000, actual: 61_160 },
{ costCentre: "Finance", budget: 47_000, actual: 46_353 },
{ costCentre: "People", budget: 39_000, actual: 37_886 },
{ costCentre: "IT", budget: 83_000, actual: 74_313 },
{ costCentre: "Facilities", budget: 72_000, actual: 71_848 },
];
export default function Grouped() {
const { Chart, XAxis, YAxis, Bar } = useChart(FEBRUARY);
return (
{/* A token, so that it follows the colour scheme. */}
);
}
```
##### Show bars above and below zero
A bar's foot is 0, not the bottom of the axis, so one series carries both signs - here the year's forecast against budget.
```tsx
import { Tooltip, useChart } from "@umriss-ui/charts";
/* The year's forecast less its budget per cost centre, in euros - summed
where the ledger lives, before the chart. */
const DEVIATION = [
{ costCentre: "Sales", euros: 60 },
{ costCentre: "Marketing", euros: 118_840 },
{ costCentre: "Engineering", euros: 1_440 },
{ costCentre: "Design", euros: 720 },
{ costCentre: "Customer service", euros: 980 },
{ costCentre: "Finance", euros: -1_940 },
{ costCentre: "People", euros: -1_480 },
{ costCentre: "IT", euros: -112_560 },
{ costCentre: "Facilities", euros: -2_240 },
];
const signed = (v: number) => (v > 0 ? `+${(v / 1000).toFixed(0)}k` : `${(v / 1000).toFixed(0)}k`);
export default function Deviation() {
const { Chart, XAxis, YAxis, Bar } = useChart(DEVIATION);
return (
);
}
```
##### Stack bars
Bars with the same `stack` stand on the ones before them; a missing part stacks as nothing, and the tooltip names what is there. A cause hidden from the legend leaves no gap in the stack.
```tsx
import { Legend, Tooltip, useChart } from "@umriss-ui/charts";
/* Minutes late across all tours per working day, by cause - two weeks, 2 to
13 March. On the first Wednesday the loading log was not kept: a gap, not
a zero. On the second Thursday a closed bridge takes the day. */
const DELAYS = [
{ day: "Mon 2", traffic: 61, loading: 32, access: 10 },
{ day: "Tue 3", traffic: 47, loading: 46, access: 27 },
{ day: "Wed 4", traffic: 49, loading: null, access: 22 },
{ day: "Thu 5", traffic: 40, loading: 44, access: 7 },
{ day: "Fri 6", traffic: 48, loading: 44, access: 27 },
{ day: "Mon 9", traffic: 67, loading: 41, access: 8 },
{ day: "Tue 10", traffic: 58, loading: 50, access: 13 },
{ day: "Wed 11", traffic: 58, loading: 18, access: 14 },
{ day: "Thu 12", traffic: 168, loading: 26, access: 10 },
{ day: "Fri 13", traffic: 57, loading: 18, access: 18 },
];
export default function Stacked() {
const { Chart, XAxis, YAxis, Bar } = useChart(DELAYS);
return (
);
}
```
##### Show shares of a whole
`normalize` makes each stack sum to 100 %, so the question moves from how long to of what; `format` still writes minutes.
```tsx
import { Legend, Tooltip, useChart } from "@umriss-ui/charts";
/* Minutes late across all tours per working day, by cause - two weeks, 2 to
13 March. On the first Wednesday the loading log was not kept: a gap, not
a zero. On the second Thursday a closed bridge takes the day. */
const DELAYS = [
{ day: "Mon 2", traffic: 61, loading: 32, access: 10 },
{ day: "Tue 3", traffic: 47, loading: 46, access: 27 },
{ day: "Wed 4", traffic: 49, loading: null, access: 22 },
{ day: "Thu 5", traffic: 40, loading: 44, access: 7 },
{ day: "Fri 6", traffic: 48, loading: 44, access: 27 },
{ day: "Mon 9", traffic: 67, loading: 41, access: 8 },
{ day: "Tue 10", traffic: 58, loading: 50, access: 13 },
{ day: "Wed 11", traffic: 58, loading: 18, access: 14 },
{ day: "Thu 12", traffic: 168, loading: 26, access: 10 },
{ day: "Fri 13", traffic: 57, loading: 18, access: 18 },
];
const minutes = (v: number) => `${v} min`;
export default function Percent() {
const { Chart, XAxis, YAxis, Bar } = useChart(DELAYS);
return (
);
}
```
##### Count readings into a histogram
`bin()` counts values into bins of one width on a tidy grid and returns each bin's bounds and count; a `Bar` on the numeric x axis draws them at `x`.
```tsx
import { useMemo } from "react";
import { Tooltip, bin, useChart } from "@umriss-ui/charts";
/* How long 40 support tickets took to answer, in minutes, as the helpdesk exports them. */
const MINUTES_TO_ANSWER = [
4, 7, 9, 11, 12, 12, 14, 15, 15, 17, 18, 19, 21, 22, 22, 23, 24, 26, 27, 28,
29, 31, 33, 34, 36, 38, 41, 43, 47, 52, 55, 58, 63, 71, 78, 84, 96, 112, 135, 171,
];
export default function Histogram() {
const bins = useMemo(() => bin(MINUTES_TO_ANSWER, { width: 20 }), []);
const { Chart, XAxis, YAxis, Bar } = useChart(bins);
return (
);
}
```
#### When to use something else
- Causes ranked by how much they contribute → Pareto
#### Keyboard
The keys of [Chart](#/chart/keyboard-chart) apply here.
#### API
##### `BarProps`
| Prop | Type | Default | Description |
|---|---|---|---|
| `value` *required* | [`Value`](#type-Value)`` | — | Height - a number field of the row or a function of it; null/undefined/NaN/±Infinity means a gap. Shown in: [One bar per category](#/bar/per-category), [Group bars side by side](#/bar/grouped), [Show bars above and below zero](#/bar/deviation) and 13 more |
| `xAxisId` | `string` | `"x"` | Binding to an x axis. Shown in: [Bind every kind of series to a second axis (Axis)](#/axis/every-kind-on-a-second-axis) |
| `yAxisId` | `string` | `"y"` | Binding to a y axis. Shown in: [Bind every kind of series to a second axis (Axis)](#/axis/every-kind-on-a-second-axis) |
| `data` | `readonly T[]` | — | Series-own data; overrides the container data. Shown in: [Bind every kind of series to a second axis (Axis)](#/axis/every-kind-on-a-second-axis) |
| `name` | `string` | "Series n", after its place in the chart | The name in legend and tooltip. Without one a warning stands in DEV - an unnamed series is a colour nobody can look up. Shown in: [One bar per category](#/bar/per-category), [Group bars side by side](#/bar/grouped), [Show bars above and below zero](#/bar/deviation) and 13 more |
| `format` | `(value: number) => string` | the y axis' `tickFormat`, else the built-in number format | The value as the tooltip writes it. Shown in: [Show shares of a whole](#/bar/percent), [Compare the budget with the forecast (Scenarios)](#/scenarios/compare-budget-with-forecast) |
| `color` | `string` | the palette's `--uc-series-N` | Any CSS colour value. Shown in: [Group bars side by side](#/bar/grouped), [See where the vans stand idle (Scenarios)](#/scenarios/see-where-the-vans-stand-idle), [Compare the budget with the forecast (Scenarios)](#/scenarios/compare-budget-with-forecast) |
| `tone` | `"ok" \| "warning" \| "alarm"` | — | A role instead of a colour value; the theme resolves it. `color` beats it. Shown in: [Compare the budget with the forecast (Scenarios)](#/scenarios/compare-budget-with-forecast) |
| `stack` | `string` | — | The stack this series stands in: every Bar and Area with the same id on the same x and y axis stands on the ones registered before it. A gap stacks as zero; negative values stack downward from zero. The tooltip names each series' own value and the stack's total. Shown in: [Stack bars](#/bar/stacked), [Show shares of a whole](#/bar/percent), [Read a stack's total in the table (Tooltip)](#/tooltip/total-of-a-stack) and 1 more |
| `normalize` | `boolean` | — | On any member of a stack, every x of the stack sums to 100 %: each value becomes its share, and the y axis reads in percent unless it has a `tickFormat`. The total stays the readings' own sum, written in the series' `format`. Shown in: [Show shares of a whole](#/bar/percent) |
| `barWidth` | `number` | `0.8` | Width as a fraction of the step; just under 1, so that neighbours do not touch. Several bar series share this fraction. Shown in: [Group bars side by side](#/bar/grouped), [Count readings into a histogram](#/bar/histogram), [Sort causes and collect the tail (Pareto)](#/pareto/pareto) and 3 more |
##### Types on this page
###### `Value`
A value read from the row ([ADR-0048](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0048-charts-are-bound-to-their-rows-by-the-hook.md)): the name of a number field, compared
by its name, or a function, compared by its source text (`ValueFunction`).
```ts
type Value = NumberField | ValueFunction;
```
###### `NumberField`
The fields of a row whose value is a number, or absent.
```ts
type NumberField = NoInfer<
{
[K in keyof T & string]-?: T[K] extends number | null | undefined ? K : never;
}[keyof T & string]
>;
```
###### `ValueFunction`
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.
```ts
type ValueFunction = (d: T, index: number) => number | null | undefined;
```
#### Known limits
- Horizontal bars are the next kind to come ([ADR-0059](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0059-the-set-of-chart-kinds-is-open.md)); until then a `Matrix` or a `Table` ranks by name.
What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/).
### Scatter
Single readings as points, joined by nothing (a scatter plot, dots). Reach for it for samples, checks and events - anything that is not a course.
```ts
import { useChart } from "@umriss-ui/charts";
```
Demo page: https://romanhaendler.github.io/umriss-ui/charts/scatter/
#### Examples
##### Plot single measurements
A `Scatter` draws each reading as a point and joins nothing: here every stop of today's tours, late above zero.
```tsx
import { Tooltip, useChart } from "@umriss-ui/charts";
/* Data from the logistics 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 = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
const MINUTE = 60_000;
interface Vehicle {
id: string;
plate: string;
type: "van" | "e-van" | "truck";
/** Payload, in kg. */
capacity: number;
depot: string;
}
const VEHICLES: readonly Vehicle[] = [
{ id: "v1", plate: "FP 214 K", type: "van", capacity: 1200, depot: "north" },
{ id: "v2", plate: "FP 377 K", type: "e-van", capacity: 900, depot: "north" },
{ id: "v3", plate: "FP 118 R", type: "truck", capacity: 7500, depot: "north" },
{ id: "v4", plate: "FP 402 R", type: "van", capacity: 1200, depot: "river" },
{ id: "v5", plate: "FP 455 R", type: "e-van", capacity: 900, depot: "river" },
{ id: "v6", plate: "FP 290 E", type: "van", capacity: 1200, depot: "east" },
{ id: "v7", plate: "FP 311 E", type: "e-van", capacity: 900, depot: "east" },
{ id: "v8", plate: "FP 520 E", type: "truck", capacity: 7500, depot: "east" },
];
const DRIVERS = [
{ id: "d1", name: "Martin Hale", depot: "north" },
{ id: "d2", name: "Nadia Petrova", depot: "north" },
{ id: "d3", name: "Owen Carter", depot: "north" },
{ id: "d4", name: "Lucia Romero", depot: "river" },
{ id: "d5", name: "Ben Adeyemi", depot: "river" },
{ id: "d6", name: "Hanna Berg", depot: "east" },
{ id: "d7", name: "Yusuf Demir", depot: "east" },
{ id: "d8", name: "Clara Wendt", depot: "east" },
] as const;
const CUSTOMERS = [
"Holloway Garden Supplies",
"Marlow & Finch Books",
"Oakridge Pharmacy",
"Brixley Cycles",
"Tamsin's Bakery",
"Northfold Office",
"Pellham Hardware",
"Greywick Studio",
"Juniper Lane Florist",
"Ashcombe Dental",
] as const;
interface Stop {
id: string;
shipment: string;
customer: string;
/** The window the customer was promised, `[from, to]`. */
window: readonly [number, number];
/** When the tour plans to be there. */
arrival: number;
}
interface Tour {
id: string;
vehicle: string;
driver: string;
depot: string;
/** Leaves the depot, loaded; back at the depot. */
from: number;
to: number;
/** Minutes of loading before `from`. */
loading: number;
km: number;
stops: readonly Stop[];
}
/** One tour per vehicle. Stops every half hour or so, each with a two-hour
window; every seventh arrival misses its window - the late ones a
dispatcher looks for. */
function tour(index: number): Tour {
const r = random(700 + index);
const vehicle = VEHICLES[index]!;
const truck = vehicle.type === "truck";
const from = at(truck ? 6 : 7, index % 2 === 0 ? 0 : 30);
const count = truck ? 5 : 8 + Math.floor(r() * 4);
const stops: Stop[] = [];
let arrival = from + (20 + Math.floor(r() * 15)) * MINUTE;
for (let i = 0; i < count; i++) {
const n = index * 20 + i;
const slot = Math.floor((arrival - at(0)) / (2 * 60 * MINUTE)) * 2;
/* A late stop's window closed half an hour or more before the arrival. */
const late = i >= 2 && n % 7 === 3;
const halfHour = 30 * MINUTE;
const windowFrom = late ? Math.floor(arrival / halfHour) * halfHour - 5 * halfHour : at(slot);
stops.push({
id: `s-${index + 1}-${i + 1}`,
shipment: `FP-${(1_004_210 + n * 13).toString()}`,
customer: CUSTOMERS[(n * 3) % CUSTOMERS.length]!,
window: [windowFrom, windowFrom + 2 * 60 * MINUTE],
arrival,
});
arrival += (truck ? 45 : 22) * MINUTE + Math.floor(r() * 18) * MINUTE;
}
return {
id: `T-${String(index + 1).padStart(2, "0")}`,
vehicle: vehicle.id,
driver: DRIVERS[index]!.id,
depot: vehicle.depot,
from,
to: arrival + 30 * MINUTE,
loading: truck ? 45 : 30,
km: Math.round((truck ? 140 : 70) + r() * 40),
stops,
};
}
const TOURS: readonly Tour[] = VEHICLES.map((_, index) => tour(index));
interface Arrival {
t: number;
/** Minutes after the delivery window closed; below zero, in time. */
late: number;
}
const ARRIVALS: Arrival[] = TOURS.flatMap((tour) =>
tour.stops.map((stop) => ({ t: stop.arrival, late: Math.round((stop.arrival - stop.window[1]) / 60_000) })),
).sort((a, b) => a.t - b.t);
export default function Arrivals() {
const { Chart, XAxis, YAxis, Scatter } = useChart(ARRIVALS);
return (
);
}
```
##### Mark samples that miss a target
Put the misses in a series of their own with `tone="alarm"`: a scatter has one colour, and the tone is the theme's alarm red.
```tsx
import { Legend, LimitLine, 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 Trace {
t: number;
/** How long the request took, end to end, in ms. */
ms: number;
}
/** Single Checkout requests traced end to end this morning, one every few
minutes from 06:00 - individual measurements, nothing in between. Slow
during the bad half hour, and now and then on their own. */
const TRACES: readonly Trace[] = (() => {
const r = random(4040);
const traces: Trace[] = [];
for (let t = at(17, 6, 2); t <= NOW; t += Math.round(1 + r() * 4) * MINUTE) {
const bad = t >= at(17, 9, 40) && t < at(17, 10, 10);
const ms = 150 + r() * 90 + (bad ? 110 + r() * 120 : r() < 0.05 ? 150 : 0);
traces.push({ t, ms: Math.round(ms) });
}
return traces;
})();
const OBJECTIVE = SERVICES.find((one) => one.id === "checkout")!.latencySlo;
export default function AgainstATarget() {
const { Chart, XAxis, YAxis, Scatter } = useChart(TRACES);
return (
{/* The two channels never both carry a value, so no trace is drawn twice. */}
(d.ms <= OBJECTIVE ? d.ms : null)} name="Request" radius={2.5} />
(d.ms > OBJECTIVE ? d.ms : null)} name="Too slow" tone="alarm" radius={4} />
);
}
```
#### Keyboard
The keys of [Chart](#/chart/keyboard-chart) apply here.
#### API
##### `ScatterProps`
| Prop | Type | Default | Description |
|---|---|---|---|
| `value` *required* | [`Value`](#type-Value)`` | — | Y value - a number field of the row or a function of it; null/undefined/NaN/±Infinity means a gap. Shown in: [Plot single measurements](#/scatter/arrivals), [Mark samples that miss a target](#/scatter/against-a-target), [Mix series kinds (Chart)](#/chart/mixed) and 3 more |
| `xAxisId` | `string` | `"x"` | Binding to an x axis. Shown in: [Bind every kind of series to a second axis (Axis)](#/axis/every-kind-on-a-second-axis) |
| `yAxisId` | `string` | `"y"` | Binding to a y axis. Shown in: [Bind every kind of series to a second axis (Axis)](#/axis/every-kind-on-a-second-axis) |
| `data` | `readonly T[]` | — | Series-own data; overrides the container data. Shown in: [Bind every kind of series to a second axis (Axis)](#/axis/every-kind-on-a-second-axis) |
| `name` | `string` | "Series n", after its place in the chart | The name in legend and tooltip. Without one a warning stands in DEV - an unnamed series is a colour nobody can look up. Shown in: [Plot single measurements](#/scatter/arrivals), [Mark samples that miss a target](#/scatter/against-a-target), [Mix series kinds (Chart)](#/chart/mixed) and 3 more |
| `format` | `(value: number) => string` | the y axis' `tickFormat`, else the built-in number format | The value as the tooltip writes it. |
| `color` | `string` | the palette's `--uc-series-N` | Any CSS colour value. |
| `tone` | `"ok" \| "warning" \| "alarm"` | — | A role instead of a colour value; the theme resolves it. Shown in: [Mark samples that miss a target](#/scatter/against-a-target) |
| `radius` | `number` | `3` | Point radius in CSS pixels. Shown in: [Plot single measurements](#/scatter/arrivals), [Mark samples that miss a target](#/scatter/against-a-target) |
##### Types on this page
###### `Value`
A value read from the row ([ADR-0048](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0048-charts-are-bound-to-their-rows-by-the-hook.md)): the name of a number field, compared
by its name, or a function, compared by its source text (`ValueFunction`).
```ts
type Value = NumberField | ValueFunction;
```
###### `NumberField`
The fields of a row whose value is a number, or absent.
```ts
type NumberField = NoInfer<
{
[K in keyof T & string]-?: T[K] extends number | null | undefined ? K : never;
}[keyof T & string]
>;
```
###### `ValueFunction`
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.
```ts
type ValueFunction = (d: T, index: number) => number | null | undefined;
```
### BoxPlot
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.
```ts
import { useChart } from "@umriss-ui/charts";
```
Demo page: https://romanhaendler.github.io/umriss-ui/charts/boxplot/
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](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). A missing median is a gap - no box is drawn there.
#### Examples
##### One box per service
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.
```tsx
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 (
`${v} ms`} />
);
}
```
##### A box per hour on a time axis
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.
```tsx
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();
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 (
`${v} ms`} />
);
}
```
##### With outliers
`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.
```tsx
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 (
`${v} ms`} />
);
}
```
##### Mean and notches: do two medians differ?
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.
```tsx
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 (
`${v} N`} />
d.median - notch(d)}
notchUpper={(d) => d.median + notch(d)}
count="n"
format={(v) => `${Math.round(v)} N`}
boxWidth={0.5}
/>
);
}
```
##### Before and after, side by side
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.
```tsx
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 (
`${v} ms`} />
{[
{ name: "Before", data: BEFORE },
{ name: "After", data: AFTER },
].map((one) => (
))}
);
}
```
##### Against the objective
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"`.
```tsx
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 (
`${v} ms`} />
);
}
```
##### A box per shift, its medians as a line
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.
```tsx
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 (
);
}
```
#### Keyboard
The keys of [Chart](#/chart/keyboard-chart) apply here.
#### API
##### `BoxPlotProps`
| Prop | Type | Default | Description |
|---|---|---|---|
| `median` *required* | [`Value`](#type-Value)`` | — | 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](#/boxplot/minimal), [A box per hour on a time axis](#/boxplot/over-time), [With outliers](#/boxplot/outliers) and 4 more |
| `lowerQuartile` *required* | [`Value`](#type-Value)`` | — | The box's lower edge. Shown in: [One box per service](#/boxplot/minimal), [A box per hour on a time axis](#/boxplot/over-time), [With outliers](#/boxplot/outliers) and 4 more |
| `upperQuartile` *required* | [`Value`](#type-Value)`` | — | The box's upper edge. Shown in: [One box per service](#/boxplot/minimal), [A box per hour on a time axis](#/boxplot/over-time), [With outliers](#/boxplot/outliers) and 4 more |
| `lowerWhisker` *required* | [`Value`](#type-Value)`` | — | Where the lower whisker ends - the caller's rule, not the minimum. Shown in: [One box per service](#/boxplot/minimal), [A box per hour on a time axis](#/boxplot/over-time), [With outliers](#/boxplot/outliers) and 4 more |
| `upperWhisker` *required* | [`Value`](#type-Value)`` | — | Where the upper whisker ends - the caller's rule, not the maximum. Shown in: [One box per service](#/boxplot/minimal), [A box per hour on a time axis](#/boxplot/over-time), [With outliers](#/boxplot/outliers) and 4 more |
| `outliers` | [`ListValue`](#type-ListValue)`` | — | 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](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0040-a-box-carries-its-outliers.md)). Shown in: [With outliers](#/boxplot/outliers) |
| `mean` | [`Value`](#type-Value)`` | — | The mean, drawn as a small ×: beside the median it shows a skew. Shown in: [Mean and notches: do two medians differ?](#/boxplot/mean-and-notches) |
| `notchLower` | [`Value`](#type-Value)`` | — | 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?](#/boxplot/mean-and-notches) |
| `notchUpper` | [`Value`](#type-Value)`` | — | The notch's upper bound; see `notchLower`. Shown in: [Mean and notches: do two medians differ?](#/boxplot/mean-and-notches) |
| `count` | [`Value`](#type-Value)`` | — | 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?](#/boxplot/mean-and-notches) |
| `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](#/boxplot/grouped), [Against the objective](#/boxplot/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](#/boxplot/minimal), [A box per hour on a time axis](#/boxplot/over-time), [With outliers](#/boxplot/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?](#/boxplot/mean-and-notches) |
| `color` | `string` | the palette's `--uc-series-N` | Any CSS colour value. |
| `tone` | `"ok" \| "warning" \| "alarm"` | — | 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](#/boxplot/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?](#/boxplot/mean-and-notches), [A box per shift, its medians as a line](#/boxplot/detailed) |
##### Types on this page
###### `Value`
A value read from the row ([ADR-0048](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0048-charts-are-bound-to-their-rows-by-the-hook.md)): the name of a number field, compared
by its name, or a function, compared by its source text (`ValueFunction`).
```ts
type Value = NumberField | ValueFunction;
```
###### `ListValue`
A list read from the row: the name of a list field or a function.
```ts
type ListValue = ListField | ListFunction;
```
###### `NumberField`
The fields of a row whose value is a number, or absent.
```ts
type NumberField = NoInfer<
{
[K in keyof T & string]-?: T[K] extends number | null | undefined ? K : never;
}[keyof T & string]
>;
```
###### `ValueFunction`
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.
```ts
type ValueFunction = (d: T, index: number) => number | null | undefined;
```
###### `ListField`
The fields of a row whose value is a list of numbers, or absent.
```ts
type ListField = NoInfer<
{
[K in keyof T & string]-?: T[K] extends readonly number[] | null | undefined ? K : never;
}[keyof T & string]
>;
```
###### `ListFunction`
A list computed from the row - a box's outliers ([ADR-0040](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0040-a-box-carries-its-outliers.md)), the
function form of `ListValue`. null/undefined or an
empty array is none; a value that is not finite is left out.
```ts
type ListFunction = (d: T, index: number) => readonly number[] | null | undefined;
```
#### Known limits
- No horizontal boxes, no violin or jittered points, no box width by count: a density is smoothing, a jitter a picture that does not repeat.
What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/).
### StateBand
What something was doing over time, as a band of coloured segments (a status timeline, a state chart). Reach for it for a vehicle, a service or a device.
```ts
import { useChart } from "@umriss-ui/charts";
```
Demo page: https://romanhaendler.github.io/umriss-ui/charts/stateband/
`value` gives a state's place in `states`, which fixes the order, the name and the colour in one place ([ADR-0007](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0007-a-state-is-a-number.md)). Each segment runs to the next reading; a `null` leaves a hole, never a colour for unknown.
Several bands share one axis as lanes: each names its `lane`, and the y axis lists one per name, the first at the top ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). On a numeric y axis `laneFrom` and `laneTo` give a lane its edges instead.
#### Examples
##### One vehicle through the day
`value` gives a state's place in `states`; each segment runs to the next reading, and a `null` leaves a hole.
```tsx
import { Legend, Tooltip, useChart } from "@umriss-ui/charts";
/* Data from the logistics 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 = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
const MINUTE = 60_000;
interface Vehicle {
id: string;
plate: string;
type: "van" | "e-van" | "truck";
/** Payload, in kg. */
capacity: number;
depot: string;
}
const VEHICLES: readonly Vehicle[] = [
{ id: "v1", plate: "FP 214 K", type: "van", capacity: 1200, depot: "north" },
{ id: "v2", plate: "FP 377 K", type: "e-van", capacity: 900, depot: "north" },
{ id: "v3", plate: "FP 118 R", type: "truck", capacity: 7500, depot: "north" },
{ id: "v4", plate: "FP 402 R", type: "van", capacity: 1200, depot: "river" },
{ id: "v5", plate: "FP 455 R", type: "e-van", capacity: 900, depot: "river" },
{ id: "v6", plate: "FP 290 E", type: "van", capacity: 1200, depot: "east" },
{ id: "v7", plate: "FP 311 E", type: "e-van", capacity: 900, depot: "east" },
{ id: "v8", plate: "FP 520 E", type: "truck", capacity: 7500, depot: "east" },
];
const DRIVERS = [
{ id: "d1", name: "Martin Hale", depot: "north" },
{ id: "d2", name: "Nadia Petrova", depot: "north" },
{ id: "d3", name: "Owen Carter", depot: "north" },
{ id: "d4", name: "Lucia Romero", depot: "river" },
{ id: "d5", name: "Ben Adeyemi", depot: "river" },
{ id: "d6", name: "Hanna Berg", depot: "east" },
{ id: "d7", name: "Yusuf Demir", depot: "east" },
{ id: "d8", name: "Clara Wendt", depot: "east" },
] as const;
const CUSTOMERS = [
"Holloway Garden Supplies",
"Marlow & Finch Books",
"Oakridge Pharmacy",
"Brixley Cycles",
"Tamsin's Bakery",
"Northfold Office",
"Pellham Hardware",
"Greywick Studio",
"Juniper Lane Florist",
"Ashcombe Dental",
] as const;
interface Stop {
id: string;
shipment: string;
customer: string;
/** The window the customer was promised, `[from, to]`. */
window: readonly [number, number];
/** When the tour plans to be there. */
arrival: number;
}
interface Tour {
id: string;
vehicle: string;
driver: string;
depot: string;
/** Leaves the depot, loaded; back at the depot. */
from: number;
to: number;
/** Minutes of loading before `from`. */
loading: number;
km: number;
stops: readonly Stop[];
}
/** One tour per vehicle. Stops every half hour or so, each with a two-hour
window; every seventh arrival misses its window - the late ones a
dispatcher looks for. */
function tour(index: number): Tour {
const r = random(700 + index);
const vehicle = VEHICLES[index]!;
const truck = vehicle.type === "truck";
const from = at(truck ? 6 : 7, index % 2 === 0 ? 0 : 30);
const count = truck ? 5 : 8 + Math.floor(r() * 4);
const stops: Stop[] = [];
let arrival = from + (20 + Math.floor(r() * 15)) * MINUTE;
for (let i = 0; i < count; i++) {
const n = index * 20 + i;
const slot = Math.floor((arrival - at(0)) / (2 * 60 * MINUTE)) * 2;
/* A late stop's window closed half an hour or more before the arrival. */
const late = i >= 2 && n % 7 === 3;
const halfHour = 30 * MINUTE;
const windowFrom = late ? Math.floor(arrival / halfHour) * halfHour - 5 * halfHour : at(slot);
stops.push({
id: `s-${index + 1}-${i + 1}`,
shipment: `FP-${(1_004_210 + n * 13).toString()}`,
customer: CUSTOMERS[(n * 3) % CUSTOMERS.length]!,
window: [windowFrom, windowFrom + 2 * 60 * MINUTE],
arrival,
});
arrival += (truck ? 45 : 22) * MINUTE + Math.floor(r() * 18) * MINUTE;
}
return {
id: `T-${String(index + 1).padStart(2, "0")}`,
vehicle: vehicle.id,
driver: DRIVERS[index]!.id,
depot: vehicle.depot,
from,
to: arrival + 30 * MINUTE,
loading: truck ? 45 : 30,
km: Math.round((truck ? 140 : 70) + r() * 40),
stops,
};
}
const TOURS: readonly Tour[] = VEHICLES.map((_, index) => tour(index));
/** The closed set of vehicle states. The order is the code. */
const VEHICLE_STATES = [
{ label: "Driving", color: "#2f6fb4" },
{ label: "Loading", color: "#c08a2e" },
{ label: "Idle", color: "#8a8f98" },
{ label: "Break", color: "#6b8e5a" },
] as const;
interface StatePoint {
t: number;
/** A code in `VEHICLE_STATES`; `null` before the tour and after it. */
state: number | null;
}
/** What a vehicle did from 05:00 to 19:00, quarter hour by quarter hour:
loading before its tour, driving between stops, idle at them now and then,
and a break around noon. */
function vehicleDay(vehicleId: string): StatePoint[] {
const one = TOURS.find((t) => t.vehicle === vehicleId);
if (one === undefined) throw new Error(`No vehicle "${vehicleId}".`);
const r = random(900 + TOURS.indexOf(one));
const points: StatePoint[] = [];
for (let t = at(5); t <= at(19); t += 15 * MINUTE) {
let state: number | null = null;
if (t >= one.from - one.loading * MINUTE && t < one.from) state = 1;
else if (t >= one.from && t < one.to) state = t >= at(12) && t < at(12, 30) ? 3 : r() < 0.18 ? 2 : 0;
points.push({ t, state });
}
return points;
}
const DAY = vehicleDay("v1");
export default function OneVehicle() {
const { Chart, XAxis, YAxis, StateBand } = useChart(DAY);
return (
{/* Without a lane the band fills its y axis; one tick names it. */}
"FP 214 K"} grid={false} />
);
}
```
##### Lay a state under a course
Put the band on a second y axis with `laneFrom` and `laneTo`: one x axis, and the cause stands right under its effect. Its legend entries are its states, and a click on any of them hides the whole band.
```tsx
import { Legend, Tooltip, useChart } from "@umriss-ui/charts";
/* Data from the logistics 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 = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
const MINUTE = 60_000;
interface Vehicle {
id: string;
plate: string;
type: "van" | "e-van" | "truck";
/** Payload, in kg. */
capacity: number;
depot: string;
}
const VEHICLES: readonly Vehicle[] = [
{ id: "v1", plate: "FP 214 K", type: "van", capacity: 1200, depot: "north" },
{ id: "v2", plate: "FP 377 K", type: "e-van", capacity: 900, depot: "north" },
{ id: "v3", plate: "FP 118 R", type: "truck", capacity: 7500, depot: "north" },
{ id: "v4", plate: "FP 402 R", type: "van", capacity: 1200, depot: "river" },
{ id: "v5", plate: "FP 455 R", type: "e-van", capacity: 900, depot: "river" },
{ id: "v6", plate: "FP 290 E", type: "van", capacity: 1200, depot: "east" },
{ id: "v7", plate: "FP 311 E", type: "e-van", capacity: 900, depot: "east" },
{ id: "v8", plate: "FP 520 E", type: "truck", capacity: 7500, depot: "east" },
];
const DRIVERS = [
{ id: "d1", name: "Martin Hale", depot: "north" },
{ id: "d2", name: "Nadia Petrova", depot: "north" },
{ id: "d3", name: "Owen Carter", depot: "north" },
{ id: "d4", name: "Lucia Romero", depot: "river" },
{ id: "d5", name: "Ben Adeyemi", depot: "river" },
{ id: "d6", name: "Hanna Berg", depot: "east" },
{ id: "d7", name: "Yusuf Demir", depot: "east" },
{ id: "d8", name: "Clara Wendt", depot: "east" },
] as const;
const CUSTOMERS = [
"Holloway Garden Supplies",
"Marlow & Finch Books",
"Oakridge Pharmacy",
"Brixley Cycles",
"Tamsin's Bakery",
"Northfold Office",
"Pellham Hardware",
"Greywick Studio",
"Juniper Lane Florist",
"Ashcombe Dental",
] as const;
interface Stop {
id: string;
shipment: string;
customer: string;
/** The window the customer was promised, `[from, to]`. */
window: readonly [number, number];
/** When the tour plans to be there. */
arrival: number;
}
interface Tour {
id: string;
vehicle: string;
driver: string;
depot: string;
/** Leaves the depot, loaded; back at the depot. */
from: number;
to: number;
/** Minutes of loading before `from`. */
loading: number;
km: number;
stops: readonly Stop[];
}
/** One tour per vehicle. Stops every half hour or so, each with a two-hour
window; every seventh arrival misses its window - the late ones a
dispatcher looks for. */
function tour(index: number): Tour {
const r = random(700 + index);
const vehicle = VEHICLES[index]!;
const truck = vehicle.type === "truck";
const from = at(truck ? 6 : 7, index % 2 === 0 ? 0 : 30);
const count = truck ? 5 : 8 + Math.floor(r() * 4);
const stops: Stop[] = [];
let arrival = from + (20 + Math.floor(r() * 15)) * MINUTE;
for (let i = 0; i < count; i++) {
const n = index * 20 + i;
const slot = Math.floor((arrival - at(0)) / (2 * 60 * MINUTE)) * 2;
/* A late stop's window closed half an hour or more before the arrival. */
const late = i >= 2 && n % 7 === 3;
const halfHour = 30 * MINUTE;
const windowFrom = late ? Math.floor(arrival / halfHour) * halfHour - 5 * halfHour : at(slot);
stops.push({
id: `s-${index + 1}-${i + 1}`,
shipment: `FP-${(1_004_210 + n * 13).toString()}`,
customer: CUSTOMERS[(n * 3) % CUSTOMERS.length]!,
window: [windowFrom, windowFrom + 2 * 60 * MINUTE],
arrival,
});
arrival += (truck ? 45 : 22) * MINUTE + Math.floor(r() * 18) * MINUTE;
}
return {
id: `T-${String(index + 1).padStart(2, "0")}`,
vehicle: vehicle.id,
driver: DRIVERS[index]!.id,
depot: vehicle.depot,
from,
to: arrival + 30 * MINUTE,
loading: truck ? 45 : 30,
km: Math.round((truck ? 140 : 70) + r() * 40),
stops,
};
}
const TOURS: readonly Tour[] = VEHICLES.map((_, index) => tour(index));
/** The closed set of vehicle states. The order is the code. */
const VEHICLE_STATES = [
{ label: "Driving", color: "#2f6fb4" },
{ label: "Loading", color: "#c08a2e" },
{ label: "Idle", color: "#8a8f98" },
{ label: "Break", color: "#6b8e5a" },
] as const;
interface StatePoint {
t: number;
/** A code in `VEHICLE_STATES`; `null` before the tour and after it. */
state: number | null;
}
/** What a vehicle did from 05:00 to 19:00, quarter hour by quarter hour:
loading before its tour, driving between stops, idle at them now and then,
and a break around noon. */
function vehicleDay(vehicleId: string): StatePoint[] {
const one = TOURS.find((t) => t.vehicle === vehicleId);
if (one === undefined) throw new Error(`No vehicle "${vehicleId}".`);
const r = random(900 + TOURS.indexOf(one));
const points: StatePoint[] = [];
for (let t = at(5); t <= at(19); t += 15 * MINUTE) {
let state: number | null = null;
if (t >= one.from - one.loading * MINUTE && t < one.from) state = 1;
else if (t >= one.from && t < one.to) state = t >= at(12) && t < at(12, 30) ? 3 : r() < 0.18 ? 2 : 0;
points.push({ t, state });
}
return points;
}
interface BatteryPoint {
t: number;
/** State of charge, in per cent; `null` before the tour and after it. */
charge: number | null;
}
/** An e-van's battery through the day, with `vehicleDay`'s quarter hours:
full when the tour leaves, draining while it drives, a little while it
idles, and topped up at the depot's charger over the noon break. */
function batteryDay(vehicleId: string): BatteryPoint[] {
let charge = 100;
return vehicleDay(vehicleId).map(({ t, state }) => {
if (state === null) return { t, charge: null };
const now = charge;
charge = Math.max(0, Math.min(100, charge + [-4.8, 0, -0.6, 3][state]!));
return { t, charge: Math.round(now * 10) / 10 };
});
}
const BATTERY = batteryDay("v2");
const STATES = vehicleDay("v2");
export default function UnderACourse() {
const { Chart, XAxis, YAxis, StateBand, Line } = useChart(BATTERY);
return (
{/* The lane: the bottom unit of five, the rest is room for the course. */}
"State"} />
);
}
```
##### Give each one a lane
Each band names its `lane`, and the y axis lists one per name, the first at the top: the legend explains every state once, however many lanes share them.
```tsx
import { Legend, Tooltip, useChart } from "@umriss-ui/charts";
/* Data from the logistics 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 = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
const MINUTE = 60_000;
interface Vehicle {
id: string;
plate: string;
type: "van" | "e-van" | "truck";
/** Payload, in kg. */
capacity: number;
depot: string;
}
const VEHICLES: readonly Vehicle[] = [
{ id: "v1", plate: "FP 214 K", type: "van", capacity: 1200, depot: "north" },
{ id: "v2", plate: "FP 377 K", type: "e-van", capacity: 900, depot: "north" },
{ id: "v3", plate: "FP 118 R", type: "truck", capacity: 7500, depot: "north" },
{ id: "v4", plate: "FP 402 R", type: "van", capacity: 1200, depot: "river" },
{ id: "v5", plate: "FP 455 R", type: "e-van", capacity: 900, depot: "river" },
{ id: "v6", plate: "FP 290 E", type: "van", capacity: 1200, depot: "east" },
{ id: "v7", plate: "FP 311 E", type: "e-van", capacity: 900, depot: "east" },
{ id: "v8", plate: "FP 520 E", type: "truck", capacity: 7500, depot: "east" },
];
const DRIVERS = [
{ id: "d1", name: "Martin Hale", depot: "north" },
{ id: "d2", name: "Nadia Petrova", depot: "north" },
{ id: "d3", name: "Owen Carter", depot: "north" },
{ id: "d4", name: "Lucia Romero", depot: "river" },
{ id: "d5", name: "Ben Adeyemi", depot: "river" },
{ id: "d6", name: "Hanna Berg", depot: "east" },
{ id: "d7", name: "Yusuf Demir", depot: "east" },
{ id: "d8", name: "Clara Wendt", depot: "east" },
] as const;
const CUSTOMERS = [
"Holloway Garden Supplies",
"Marlow & Finch Books",
"Oakridge Pharmacy",
"Brixley Cycles",
"Tamsin's Bakery",
"Northfold Office",
"Pellham Hardware",
"Greywick Studio",
"Juniper Lane Florist",
"Ashcombe Dental",
] as const;
interface Stop {
id: string;
shipment: string;
customer: string;
/** The window the customer was promised, `[from, to]`. */
window: readonly [number, number];
/** When the tour plans to be there. */
arrival: number;
}
interface Tour {
id: string;
vehicle: string;
driver: string;
depot: string;
/** Leaves the depot, loaded; back at the depot. */
from: number;
to: number;
/** Minutes of loading before `from`. */
loading: number;
km: number;
stops: readonly Stop[];
}
/** One tour per vehicle. Stops every half hour or so, each with a two-hour
window; every seventh arrival misses its window - the late ones a
dispatcher looks for. */
function tour(index: number): Tour {
const r = random(700 + index);
const vehicle = VEHICLES[index]!;
const truck = vehicle.type === "truck";
const from = at(truck ? 6 : 7, index % 2 === 0 ? 0 : 30);
const count = truck ? 5 : 8 + Math.floor(r() * 4);
const stops: Stop[] = [];
let arrival = from + (20 + Math.floor(r() * 15)) * MINUTE;
for (let i = 0; i < count; i++) {
const n = index * 20 + i;
const slot = Math.floor((arrival - at(0)) / (2 * 60 * MINUTE)) * 2;
/* A late stop's window closed half an hour or more before the arrival. */
const late = i >= 2 && n % 7 === 3;
const halfHour = 30 * MINUTE;
const windowFrom = late ? Math.floor(arrival / halfHour) * halfHour - 5 * halfHour : at(slot);
stops.push({
id: `s-${index + 1}-${i + 1}`,
shipment: `FP-${(1_004_210 + n * 13).toString()}`,
customer: CUSTOMERS[(n * 3) % CUSTOMERS.length]!,
window: [windowFrom, windowFrom + 2 * 60 * MINUTE],
arrival,
});
arrival += (truck ? 45 : 22) * MINUTE + Math.floor(r() * 18) * MINUTE;
}
return {
id: `T-${String(index + 1).padStart(2, "0")}`,
vehicle: vehicle.id,
driver: DRIVERS[index]!.id,
depot: vehicle.depot,
from,
to: arrival + 30 * MINUTE,
loading: truck ? 45 : 30,
km: Math.round((truck ? 140 : 70) + r() * 40),
stops,
};
}
const TOURS: readonly Tour[] = VEHICLES.map((_, index) => tour(index));
/** The closed set of vehicle states. The order is the code. */
const VEHICLE_STATES = [
{ label: "Driving", color: "#2f6fb4" },
{ label: "Loading", color: "#c08a2e" },
{ label: "Idle", color: "#8a8f98" },
{ label: "Break", color: "#6b8e5a" },
] as const;
interface StatePoint {
t: number;
/** A code in `VEHICLE_STATES`; `null` before the tour and after it. */
state: number | null;
}
/** What a vehicle did from 05:00 to 19:00, quarter hour by quarter hour:
loading before its tour, driving between stops, idle at them now and then,
and a break around noon. */
function vehicleDay(vehicleId: string): StatePoint[] {
const one = TOURS.find((t) => t.vehicle === vehicleId);
if (one === undefined) throw new Error(`No vehicle "${vehicleId}".`);
const r = random(900 + TOURS.indexOf(one));
const points: StatePoint[] = [];
for (let t = at(5); t <= at(19); t += 15 * MINUTE) {
let state: number | null = null;
if (t >= one.from - one.loading * MINUTE && t < one.from) state = 1;
else if (t >= one.from && t < one.to) state = t >= at(12) && t < at(12, 30) ? 3 : r() < 0.18 ? 2 : 0;
points.push({ t, state });
}
return points;
}
const FLEET = VEHICLES.map((vehicle) => ({ vehicle, day: vehicleDay(vehicle.id) }));
export default function OneLaneEach() {
const { Chart, XAxis, YAxis, StateBand } = useChart(FLEET[0]!.day);
return (
{FLEET.map(({ vehicle, day }) => (
))}
);
}
```
#### When to use something else
- Work with its own start and end, and gaps between → `Schedule` from @umriss-ui/schedule
#### Keyboard
The keys of [Chart](#/chart/keyboard-chart) apply here.
#### API
##### `StateBandProps`
| Prop | Type | Default | Description |
|---|---|---|---|
| `value` *required* | [`Value`](#type-Value)`` | — | Index of the state in `states` - a number field of the row or a function of it; null/undefined/NaN/±Infinity is a gap - and a gap stays a hole, it gets no colour for "unknown". A colour would be a claim about the interval. Shown in: [One vehicle through the day](#/stateband/one-vehicle), [Lay a state under a course](#/stateband/under-a-course), [Give each one a lane](#/stateband/one-lane-each) and 4 more |
| `states` *required* | `readonly `[`StateEntry`](#type-StateEntry)`[]` | — | The closed set of states, in the order of their codes. Shown in: [One vehicle through the day](#/stateband/one-vehicle), [Lay a state under a course](#/stateband/under-a-course), [Give each one a lane](#/stateband/one-lane-each) and 4 more |
| `xAxisId` | `string` | — | Binding to an x axis. |
| `yAxisId` | `string` | — | Binding to a y axis. Shown in: [Lay a state under a course](#/stateband/under-a-course), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells), [Read limits above the states (LimitLine)](#/limitline/limits-and-state) |
| `data` | `readonly T[]` | — | Series-own data; overrides the container data. Shown in: [Lay a state under a course](#/stateband/under-a-course), [Give each one a lane](#/stateband/one-lane-each), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells) and 2 more |
| `name` | `string` | "Series n", after its place in the chart | The name in legend and tooltip. Without one a warning stands in DEV - an unnamed series is a colour nobody can look up. Shown in: [One vehicle through the day](#/stateband/one-vehicle), [Lay a state under a course](#/stateband/under-a-course), [Give each one a lane](#/stateband/one-lane-each) and 4 more |
| `lane` | `string` | — | The lane by its name: a category of the y axis, whose place the band takes, as wide as a bar is. The y axis then lists a lane per name, the first at the top, in the bands' order or its own `categories` ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). Not together with `laneFrom` or `laneTo`. Shown in: [Give each one a lane](#/stateband/one-lane-each), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells), [Read limits above the states (LimitLine)](#/limitline/limits-and-state) and 1 more |
| `laneFrom` | `number` | the beginning of the axis' domain | Lower edge of the lane in domain units of a numeric y axis. Shown in: [Lay a state under a course](#/stateband/under-a-course) |
| `laneTo` | `number` | the end of the axis' domain | Upper edge of the lane in domain units of a numeric y axis. Shown in: [Lay a state under a course](#/stateband/under-a-course) |
##### Types on this page
###### `Value`
A value read from the row ([ADR-0048](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0048-charts-are-bound-to-their-rows-by-the-hook.md)): the name of a number field, compared
by its name, or a function, compared by its source text (`ValueFunction`).
```ts
type Value = NumberField | ValueFunction;
```
###### `StateEntry`
One state of the closed set a state series knows.
| Prop | Type | Default | Description |
|---|---|---|---|
| `label` *required* | `string` | — | |
| `color` *required* | `string` | — | |
###### `NumberField`
The fields of a row whose value is a number, or absent.
```ts
type NumberField = NoInfer<
{
[K in keyof T & string]-?: T[K] extends number | null | undefined ? K : never;
}[keyof T & string]
>;
```
###### `ValueFunction`
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.
```ts
type ValueFunction = (d: T, index: number) => number | null | undefined;
```
### Matrix
One value per cell across two axes (a heatmap), coloured by its limits or along a gradient. Reach for it to see whether a bad hour hits everyone or one row.
```ts
import { useChart, DEFAULT_GRADIENT } from "@umriss-ui/charts";
```
Demo page: https://romanhaendler.github.io/umriss-ui/charts/matrix/
`value` places a cell's row, as it places every series along y - a string names the row, and the y axis lists one per name, the first at the top ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)); `level` decides its colour. A cell without a level stays a hole, not a zero. The tooltip carries the level as text, since colour alone carries no number.
#### Examples
##### Colour cells by their limits
With `coloring` of kind `assessment` a cell takes the tone its limits give it: a bad hour everywhere reads differently from one bad service. `value` names the row - a string is a category, and the y axis lists one per service, the first at the top; `format` writes the level in the tooltip.
```tsx
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;
};
}
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 SuccessCell {
hour: number;
/** The row: the service's name. */
service: string;
/** Requests that succeeded, in per cent; `null` where there were none. */
success: number | null;
}
/** Yesterday, per service and hour: the share of requests that succeeded.
Webhooks has a bad late morning, 03:00 is bad everywhere (a DNS change),
and Reporting takes no requests before 06:00 - no requests, no share. */
const SUCCESS_BY_HOUR: readonly SuccessCell[] = (() => {
const r = random(23);
const cells: SuccessCell[] = [];
SERVICES.forEach((one) => {
for (let hour = 0; hour < 24; hour++) {
let success: number | null = 99.2 + r() * 0.8;
if (one.id === "webhooks" && hour >= 9 && hour < 14) success = 96 + r() * 1.5;
if (hour === 3) success = 97.4 + r() * 1;
if (one.id === "reports" && hour < 6) success = null;
cells.push({ hour, service: one.name, success: success === null ? null : Math.round(success * 100) / 100 });
}
});
return cells;
})();
export default function ByLimits() {
const { Chart, XAxis, YAxis, Matrix } = useChart(SUCCESS_BY_HOUR);
return (
`${v}:00`} label="Hour" />
`${v.toFixed(2)} %`}
/>
);
}
```
##### Colour cells along a gradient
Without `coloring` the cells run along `DEFAULT_GRADIENT` from the lowest value to the highest; a cell without a value stays a hole.
```tsx
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;
};
}
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 SuccessCell {
hour: number;
/** The row: the service's name. */
service: string;
/** Requests that succeeded, in per cent; `null` where there were none. */
success: number | null;
}
/** Yesterday, per service and hour: the share of requests that succeeded.
Webhooks has a bad late morning, 03:00 is bad everywhere (a DNS change),
and Reporting takes no requests before 06:00 - no requests, no share. */
const SUCCESS_BY_HOUR: readonly SuccessCell[] = (() => {
const r = random(23);
const cells: SuccessCell[] = [];
SERVICES.forEach((one) => {
for (let hour = 0; hour < 24; hour++) {
let success: number | null = 99.2 + r() * 0.8;
if (one.id === "webhooks" && hour >= 9 && hour < 14) success = 96 + r() * 1.5;
if (hour === 3) success = 97.4 + r() * 1;
if (one.id === "reports" && hour < 6) success = null;
cells.push({ hour, service: one.name, success: success === null ? null : Math.round(success * 100) / 100 });
}
});
return cells;
})();
export default function Gradient() {
const { Chart, XAxis, YAxis, Matrix } = useChart(SUCCESS_BY_HOUR);
return (
`${v}:00`} label="Hour" />
);
}
```
#### Keyboard
The keys of [Chart](#/chart/keyboard-chart) apply here.
#### API
##### `MatrixProps`
| Prop | Type | Default | Description |
|---|---|---|---|
| `value` *required* | [`AxisField`](#type-AxisField)` \| ((d: T, index: number) => number \| string \| null \| undefined)` | — | The row on the y axis - a field of the data's row or a function of it. A number is a position, as every series' y is; a string is a category: the y axis then has a named row per distinct one, the first at the top, in the order the data bring them or the y axis' `categories` ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). Never an instant: a `Date` is refused. Shown in: [Colour cells by their limits](#/matrix/by-limits), [Colour cells along a gradient](#/matrix/gradient), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells) and 1 more |
| `level` *required* | [`Value`](#type-Value)`` | — | The level that decides the colour - a number field of the row or a function of it. A missing level is a hole, not a zero. Shown in: [Colour cells by their limits](#/matrix/by-limits), [Colour cells along a gradient](#/matrix/gradient), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells) and 1 more |
| `coloring` | [`MatrixColoring`](#type-MatrixColoring) | `{ kind: "gradient", stops: DEFAULT_GRADIENT }` | How the level colours a cell: by assessment against limits, or across a gradient. Shown in: [Colour cells by their limits](#/matrix/by-limits), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells) |
| `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. |
| `name` | `string` | "Series n", after its place in the chart | The name in legend and tooltip. Without one a warning stands in DEV - an unnamed series is a colour nobody can look up. Shown in: [Colour cells by their limits](#/matrix/by-limits), [Colour cells along a gradient](#/matrix/gradient), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells) and 1 more |
| `format` | `(value: number) => string` | the built-in number format | The level as the tooltip writes it. The y axis' `tickFormat` writes the row, not the level. Shown in: [Colour cells by their limits](#/matrix/by-limits) |
##### Types on this page
###### `AxisField`
The fields of a row an axis can read - or, with `V`, those that hold only
some of what an axis reads: a matrix' row is a position or a category,
never an instant.
```ts
type AxisField = NoInfer<
{
[K in keyof T & string]-?: T[K] extends V ? K : never;
}[keyof T & string]
>;
```
###### `Value`
A value read from the row ([ADR-0048](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0048-charts-are-bound-to-their-rows-by-the-hook.md)): the name of a number field, compared
by its name, or a function, compared by its source text (`ValueFunction`).
```ts
type Value = NumberField | ValueFunction;
```
###### `MatrixColoring`
How a matrix colours its cells. Discretely by assessment - the same limits
that colour a tile - or across a gradient.
```ts
type MatrixColoring =
| { kind: "assessment"; limits: LimitSet }
| {
kind: "gradient";
/** Colour stops from small to large; at least two. The stops ARE the
steps - the library does not interpolate between them, because that
would mean a colour parser for arbitrary CSS colour values, which this
package does not have and is not to get (ADR-0011). Whoever wants a
finer gradation names more colours. */
stops: readonly string[];
/** Value range of the gradient.
@default the range of the data */
range?: readonly [number, number];
};
```
###### `AxisValue`
What an axis reads from a row ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)): a number - a position, or an
instant on a time axis -, a string, which is a category, or a `Date`,
which is an instant. The type of the value decides what kind the axis is,
never its content: `"2024"` is a category, `2024` a number. On an axis
that says `time` a string is an ISO 8601 instant. null/undefined is no
place.
```ts
type AxisValue = number | string | Date | null | undefined;
```
###### `NumberField`
The fields of a row whose value is a number, or absent.
```ts
type NumberField = NoInfer<
{
[K in keyof T & string]-?: T[K] extends number | null | undefined ? K : never;
}[keyof T & string]
>;
```
###### `ValueFunction`
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.
```ts
type ValueFunction = (d: T, index: number) => number | null | undefined;
```
###### `LimitSet`
The set a value is read against: its bounds and, where there is one, its
target.
The target is not a limit. It is missed, never violated, and it is never the
edge of a region. Conflating the two is how a tile turns red for being above
target.
| Prop | Type | Default | Description |
|---|---|---|---|
| `limits` | `readonly `[`Limit`](#type-Limit)`[]` | — | In any order; none, one or several. Shown in: [Colour cells by their limits](#/matrix/by-limits), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells) |
| `target` | `number` | — | |
###### `Limit`
A bound: a value, a side and a severity. No more - a tolerance band is
derived and never an input (two limits of equal severity on opposite sides
enclose one). That keeps the one-sided case, which is the more common one,
free of a `null` on the other edge.
| Prop | Type | Default | Description |
|---|---|---|---|
| `value` *required* | `number` | — | Shown in: [Colour cells by their limits](#/matrix/by-limits), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells) |
| `side` *required* | [`Side`](#type-Side) `"upper" \| "lower"` | — | Shown in: [Colour cells by their limits](#/matrix/by-limits), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells) |
| `severity` *required* | [`Severity`](#type-Severity) `"warning" \| "alarm"` | — | Shown in: [Colour cells by their limits](#/matrix/by-limits), [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells) |
###### `Side`
Whether a limit is an upper or a lower bound.
```ts
type Side = "upper" | "lower";
```
###### `Severity`
How seriously a violation weighs. Exactly two: one severity that advises and
one that calls for action. Five severities would be five colours, and five
colours on a dense screen are noise. Whoever needs a third
distinction sets a second limit.
```ts
type Severity = "warning" | "alarm";
```
## Limits and alarms
A value read against its limits, and a process against its own behaviour.
### LimitLine
The limits a course is read against (a threshold, a reference line): a line above the series, a band beneath them. Reach for it for objectives, budgets and alert levels.
```ts
import { LimitLine, LimitBand } from "@umriss-ui/charts";
```
Demo page: https://romanhaendler.github.io/umriss-ui/charts/limitline/
A limit's tone follows its `severity`, never a colour of its own. The model is the one `@umriss-ui/core` uses for its verdicts, so a chart and a tile agree ([ADR-0006](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0006-the-limit-model-lives-in-both-packages.md)).
#### Examples
##### Draw a limit
A `LimitLine` lies above the series with its label; `severity` picks its tone, and its value counts in the y extent.
```tsx
import { LimitLine, 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 CHECKOUT = metrics("checkout");
const OBJECTIVE = SERVICES.find((one) => one.id === "checkout")!.latencySlo;
export default function Objective() {
const { Chart, XAxis, YAxis, Line } = useChart(CHECKOUT);
return (
);
}
```
##### Lay a band under the series
A `LimitBand` fills from `from` to `to` below every series - ground to read against - while a line stays on top as the landmark.
```tsx
import { LimitBand, LimitLine, 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 CHECKOUT = metrics("checkout");
export default function Band() {
const { Chart, XAxis, YAxis, Line } = useChart(CHECKOUT);
return (
);
}
```
##### Read limits above the states
A course, the limits it is read against and the states beneath it, on one x axis: where the charge runs low, the lanes say why.
```tsx
import { Legend, LimitBand, LimitLine, Tooltip, useChart } from "@umriss-ui/charts";
/* Data from the logistics 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 = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
const MINUTE = 60_000;
interface Vehicle {
id: string;
plate: string;
type: "van" | "e-van" | "truck";
/** Payload, in kg. */
capacity: number;
depot: string;
}
const VEHICLES: readonly Vehicle[] = [
{ id: "v1", plate: "FP 214 K", type: "van", capacity: 1200, depot: "north" },
{ id: "v2", plate: "FP 377 K", type: "e-van", capacity: 900, depot: "north" },
{ id: "v3", plate: "FP 118 R", type: "truck", capacity: 7500, depot: "north" },
{ id: "v4", plate: "FP 402 R", type: "van", capacity: 1200, depot: "river" },
{ id: "v5", plate: "FP 455 R", type: "e-van", capacity: 900, depot: "river" },
{ id: "v6", plate: "FP 290 E", type: "van", capacity: 1200, depot: "east" },
{ id: "v7", plate: "FP 311 E", type: "e-van", capacity: 900, depot: "east" },
{ id: "v8", plate: "FP 520 E", type: "truck", capacity: 7500, depot: "east" },
];
const DRIVERS = [
{ id: "d1", name: "Martin Hale", depot: "north" },
{ id: "d2", name: "Nadia Petrova", depot: "north" },
{ id: "d3", name: "Owen Carter", depot: "north" },
{ id: "d4", name: "Lucia Romero", depot: "river" },
{ id: "d5", name: "Ben Adeyemi", depot: "river" },
{ id: "d6", name: "Hanna Berg", depot: "east" },
{ id: "d7", name: "Yusuf Demir", depot: "east" },
{ id: "d8", name: "Clara Wendt", depot: "east" },
] as const;
const CUSTOMERS = [
"Holloway Garden Supplies",
"Marlow & Finch Books",
"Oakridge Pharmacy",
"Brixley Cycles",
"Tamsin's Bakery",
"Northfold Office",
"Pellham Hardware",
"Greywick Studio",
"Juniper Lane Florist",
"Ashcombe Dental",
] as const;
interface Stop {
id: string;
shipment: string;
customer: string;
/** The window the customer was promised, `[from, to]`. */
window: readonly [number, number];
/** When the tour plans to be there. */
arrival: number;
}
interface Tour {
id: string;
vehicle: string;
driver: string;
depot: string;
/** Leaves the depot, loaded; back at the depot. */
from: number;
to: number;
/** Minutes of loading before `from`. */
loading: number;
km: number;
stops: readonly Stop[];
}
/** One tour per vehicle. Stops every half hour or so, each with a two-hour
window; every seventh arrival misses its window - the late ones a
dispatcher looks for. */
function tour(index: number): Tour {
const r = random(700 + index);
const vehicle = VEHICLES[index]!;
const truck = vehicle.type === "truck";
const from = at(truck ? 6 : 7, index % 2 === 0 ? 0 : 30);
const count = truck ? 5 : 8 + Math.floor(r() * 4);
const stops: Stop[] = [];
let arrival = from + (20 + Math.floor(r() * 15)) * MINUTE;
for (let i = 0; i < count; i++) {
const n = index * 20 + i;
const slot = Math.floor((arrival - at(0)) / (2 * 60 * MINUTE)) * 2;
/* A late stop's window closed half an hour or more before the arrival. */
const late = i >= 2 && n % 7 === 3;
const halfHour = 30 * MINUTE;
const windowFrom = late ? Math.floor(arrival / halfHour) * halfHour - 5 * halfHour : at(slot);
stops.push({
id: `s-${index + 1}-${i + 1}`,
shipment: `FP-${(1_004_210 + n * 13).toString()}`,
customer: CUSTOMERS[(n * 3) % CUSTOMERS.length]!,
window: [windowFrom, windowFrom + 2 * 60 * MINUTE],
arrival,
});
arrival += (truck ? 45 : 22) * MINUTE + Math.floor(r() * 18) * MINUTE;
}
return {
id: `T-${String(index + 1).padStart(2, "0")}`,
vehicle: vehicle.id,
driver: DRIVERS[index]!.id,
depot: vehicle.depot,
from,
to: arrival + 30 * MINUTE,
loading: truck ? 45 : 30,
km: Math.round((truck ? 140 : 70) + r() * 40),
stops,
};
}
const TOURS: readonly Tour[] = VEHICLES.map((_, index) => tour(index));
/** The closed set of vehicle states. The order is the code. */
const VEHICLE_STATES = [
{ label: "Driving", color: "#2f6fb4" },
{ label: "Loading", color: "#c08a2e" },
{ label: "Idle", color: "#8a8f98" },
{ label: "Break", color: "#6b8e5a" },
] as const;
interface StatePoint {
t: number;
/** A code in `VEHICLE_STATES`; `null` before the tour and after it. */
state: number | null;
}
/** What a vehicle did from 05:00 to 19:00, quarter hour by quarter hour:
loading before its tour, driving between stops, idle at them now and then,
and a break around noon. */
function vehicleDay(vehicleId: string): StatePoint[] {
const one = TOURS.find((t) => t.vehicle === vehicleId);
if (one === undefined) throw new Error(`No vehicle "${vehicleId}".`);
const r = random(900 + TOURS.indexOf(one));
const points: StatePoint[] = [];
for (let t = at(5); t <= at(19); t += 15 * MINUTE) {
let state: number | null = null;
if (t >= one.from - one.loading * MINUTE && t < one.from) state = 1;
else if (t >= one.from && t < one.to) state = t >= at(12) && t < at(12, 30) ? 3 : r() < 0.18 ? 2 : 0;
points.push({ t, state });
}
return points;
}
interface BatteryPoint {
t: number;
/** State of charge, in per cent; `null` before the tour and after it. */
charge: number | null;
}
/** An e-van's battery through the day, with `vehicleDay`'s quarter hours:
full when the tour leaves, draining while it drives, a little while it
idles, and topped up at the depot's charger over the noon break. */
function batteryDay(vehicleId: string): BatteryPoint[] {
let charge = 100;
return vehicleDay(vehicleId).map(({ t, state }) => {
if (state === null) return { t, charge: null };
const now = charge;
charge = Math.max(0, Math.min(100, charge + [-4.8, 0, -0.6, 3][state]!));
return { t, charge: Math.round(now * 10) / 10 };
});
}
const NORTH = VEHICLES.filter((vehicle) => vehicle.depot === "north");
const BATTERY = batteryDay("v2");
/* The lanes: one per vehicle, named by its plate. */
const LANES = NORTH.map((vehicle) => ({ vehicle, day: vehicleDay(vehicle.id) }));
export default function LimitsAndState() {
const { Chart, XAxis, YAxis, StateBand, Line } = useChart(BATTERY);
return (
{/* The lanes take the lower places; the domain leaves the rest as room for the course. */}
{LANES.map(({ vehicle, day }) => (
))}
);
}
```
##### Watch a kiln zone against its tolerance
In the plant: the tolerance as a band a tile must be fired in, the alarm limit above it - the burner's overshoot crosses both.
```tsx
import { Legend, LimitBand, LimitLine, Tooltip, useChart } from "@umriss-ui/charts";
/* Data from the plant world, written out here so the example runs on its own. */
/** A small LCG - reproducible across runs and platforms. */
function random(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
/** The early shift, 06:00 to 14:00. */
const SHIFT_MINUTES = 480;
/** The kiln's zone 3, in °C: the numbers every part reads it against, once.
Between the two warnings lies the tolerance - a tile fired outside it is
scrap; `returnTo` is the alarm's dead band. */
const KILN = { target: 1200, tolerance: [1185, 1215], alarm: 1230, returnTo: 1222 } as const;
/** The kiln fires one tile every quarter of a minute when nothing holds it up -
the ideal cycle time of the OEE's performance. */
const IDEAL_CYCLE_MINUTES = 0.25;
/** One minute of the line. */
interface Reading {
/** Minutes since the start of the shift. */
minute: number;
/** Zone 3 of the kiln, in °C. */
kiln: number;
/** The exit pyrometer, in °C - `null` while it sends nothing. */
exit: number | null;
/** Whether the belt runs. */
running: boolean;
/** Tiles out of the kiln in this minute. */
fired: number;
/** Of them, the ones fired inside the tolerance. */
good: number;
}
/** A tile taken off the belt and measured - one every ten minutes while it runs. */
interface Sample {
minute: number;
/** The tile's length after firing, in mm (nominal 600). */
length: number;
}
/** A batch of tiles through the three stations. */
interface Batch {
id: string;
name: string;
/** How many tiles the batch is planned for. */
tiles: number;
/** Minutes since the start of the shift, per station. */
press: readonly [number, number];
dryer: readonly [number, number];
kiln: readonly [number, number];
}
interface Plant {
readings: readonly Reading[];
samples: readonly Sample[];
batches: readonly Batch[];
}
/* When the exit pyrometer goes silent, and for how long. Fixed rather than
seeded, so that a running control room shows its reading turn stale, then
lost, and come back. */
const SILENT_FROM = 288;
const SILENT_FOR = 50;
/** The shift, from a seed. */
function plant(seed: number): Plant {
const r = random(seed);
/* The one excursion: a burner overshoots somewhere between 02:30 and 03:30
into the shift, for half an hour, peaking near 1242 °C. The operator stops
the feed twenty-two minutes in, for eighteen minutes. The noise around it
is too small to reach a warning on its own, so this is the only crossing -
which is what the tests hold. */
const surgeFrom = 150 + Math.floor(r() * 60);
const surgeFor = 30;
const stopFrom = surgeFrom + 22;
const stopFor = 18;
/* The plan: batches through the kiln one after another, each pressed an
hour and dried half an hour before - the first ones during the night
shift. Tiles come out of the kiln only while a batch is in it, so the
OEE's count and the batches' counts are the same tiles. The plan is a
plan: a stop of the belt does not move it. */
const batches: Batch[] = [];
for (let at = 0, i = 0; at < SHIFT_MINUTES; i++) {
const length = 70 + Math.floor(r() * 25);
batches.push({
id: `b-${4121 + i}`,
name: `B-${4121 + i}`,
tiles: Math.round(length / IDEAL_CYCLE_MINUTES / 10) * 10,
press: [at - 60, at - 60 + length],
dryer: [at - 30, at - 30 + length],
kiln: [at, at + length],
});
at += length + 5;
}
const readings: Reading[] = [];
const samples: Sample[] = [];
let drift = 0;
for (let minute = 0; minute < SHIFT_MINUTES; minute++) {
drift = 0.9 * drift + (r() - 0.5) * 3;
const into = minute - surgeFrom;
const surge = into >= 0 && into <= surgeFor ? 42 * Math.sin((Math.PI * into) / surgeFor) : 0;
const kiln = Math.round((KILN.target + drift + surge) * 10) / 10;
const running = !(minute >= stopFrom && minute < stopFrom + stopFor);
const loaded = batches.some((batch) => batch.kiln[0] <= minute && minute < batch.kiln[1]);
const jam = r() < 0.12;
const fired = running && loaded ? (jam ? 3 : 4) : 0;
const inTolerance = kiln >= KILN.tolerance[0] && kiln <= KILN.tolerance[1];
const silent = minute >= SILENT_FROM && minute < SILENT_FROM + SILENT_FOR;
const exit = silent ? null : Math.round((kiln - 380 + (r() - 0.5) * 4) * 10) / 10;
readings.push({ minute, kiln, exit, running, fired, good: inTolerance ? fired : 0 });
if (running && minute % 10 === 0) {
/* Hotter shrinks more: the length follows the kiln, so the control
chart sees the excursion the trend sees. */
const length = 600 + (KILN.target - kiln) * 0.05 + (r() - 0.5) * 0.6;
samples.push({ minute, length: Math.round(length * 100) / 100 });
}
}
return { readings, samples, batches };
}
const SHIFT = plant(7);
/* The line counts in minutes; the early shift began at 06:00. */
const SHIFT_START = new Date(2026, 2, 17, 6).getTime();
export default function KilnZone() {
const { Chart, XAxis, YAxis, Line } = useChart(SHIFT.readings);
return (
SHIFT_START + d.minute * 60_000} time />
);
}
```
##### Mark moments and windows along time
With `orientation="x"` a limit lies on the time axis: a line for a moment, a band for a window, each named as a `Date` or in milliseconds. Neither carries a severity, so each takes a `color` of its own.
```tsx
import { LimitBand, LimitLine, 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 CHECKOUT = metrics("checkout");
export default function AlongTime() {
const { Chart, XAxis, YAxis, Line } = useChart(CHECKOUT);
return (
);
}
```
##### Set specification and control limits side by side
In the plant: a specification is chosen, a control limit is computed from the process - `role` draws the two apart, so neither passes for the other ([ADR-0008](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0008-control-limits-are-not-specification-limits.md)).
```tsx
import { LimitBand, LimitLine, Tooltip, controlLimits, useChart } from "@umriss-ui/charts";
/* Data from the plant world, written out here so the example runs on its own. */
/** A small LCG - reproducible across runs and platforms. */
function random(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
/** The early shift, 06:00 to 14:00. */
const SHIFT_MINUTES = 480;
/** The kiln's zone 3, in °C: the numbers every part reads it against, once.
Between the two warnings lies the tolerance - a tile fired outside it is
scrap; `returnTo` is the alarm's dead band. */
const KILN = { target: 1200, tolerance: [1185, 1215], alarm: 1230, returnTo: 1222 } as const;
/** The kiln fires one tile every quarter of a minute when nothing holds it up -
the ideal cycle time of the OEE's performance. */
const IDEAL_CYCLE_MINUTES = 0.25;
/** One minute of the line. */
interface Reading {
/** Minutes since the start of the shift. */
minute: number;
/** Zone 3 of the kiln, in °C. */
kiln: number;
/** The exit pyrometer, in °C - `null` while it sends nothing. */
exit: number | null;
/** Whether the belt runs. */
running: boolean;
/** Tiles out of the kiln in this minute. */
fired: number;
/** Of them, the ones fired inside the tolerance. */
good: number;
}
/** A tile taken off the belt and measured - one every ten minutes while it runs. */
interface Sample {
minute: number;
/** The tile's length after firing, in mm (nominal 600). */
length: number;
}
/** A batch of tiles through the three stations. */
interface Batch {
id: string;
name: string;
/** How many tiles the batch is planned for. */
tiles: number;
/** Minutes since the start of the shift, per station. */
press: readonly [number, number];
dryer: readonly [number, number];
kiln: readonly [number, number];
}
interface Plant {
readings: readonly Reading[];
samples: readonly Sample[];
batches: readonly Batch[];
}
/* When the exit pyrometer goes silent, and for how long. Fixed rather than
seeded, so that a running control room shows its reading turn stale, then
lost, and come back. */
const SILENT_FROM = 288;
const SILENT_FOR = 50;
/** The shift, from a seed. */
function plant(seed: number): Plant {
const r = random(seed);
/* The one excursion: a burner overshoots somewhere between 02:30 and 03:30
into the shift, for half an hour, peaking near 1242 °C. The operator stops
the feed twenty-two minutes in, for eighteen minutes. The noise around it
is too small to reach a warning on its own, so this is the only crossing -
which is what the tests hold. */
const surgeFrom = 150 + Math.floor(r() * 60);
const surgeFor = 30;
const stopFrom = surgeFrom + 22;
const stopFor = 18;
/* The plan: batches through the kiln one after another, each pressed an
hour and dried half an hour before - the first ones during the night
shift. Tiles come out of the kiln only while a batch is in it, so the
OEE's count and the batches' counts are the same tiles. The plan is a
plan: a stop of the belt does not move it. */
const batches: Batch[] = [];
for (let at = 0, i = 0; at < SHIFT_MINUTES; i++) {
const length = 70 + Math.floor(r() * 25);
batches.push({
id: `b-${4121 + i}`,
name: `B-${4121 + i}`,
tiles: Math.round(length / IDEAL_CYCLE_MINUTES / 10) * 10,
press: [at - 60, at - 60 + length],
dryer: [at - 30, at - 30 + length],
kiln: [at, at + length],
});
at += length + 5;
}
const readings: Reading[] = [];
const samples: Sample[] = [];
let drift = 0;
for (let minute = 0; minute < SHIFT_MINUTES; minute++) {
drift = 0.9 * drift + (r() - 0.5) * 3;
const into = minute - surgeFrom;
const surge = into >= 0 && into <= surgeFor ? 42 * Math.sin((Math.PI * into) / surgeFor) : 0;
const kiln = Math.round((KILN.target + drift + surge) * 10) / 10;
const running = !(minute >= stopFrom && minute < stopFrom + stopFor);
const loaded = batches.some((batch) => batch.kiln[0] <= minute && minute < batch.kiln[1]);
const jam = r() < 0.12;
const fired = running && loaded ? (jam ? 3 : 4) : 0;
const inTolerance = kiln >= KILN.tolerance[0] && kiln <= KILN.tolerance[1];
const silent = minute >= SILENT_FROM && minute < SILENT_FROM + SILENT_FOR;
const exit = silent ? null : Math.round((kiln - 380 + (r() - 0.5) * 4) * 10) / 10;
readings.push({ minute, kiln, exit, running, fired, good: inTolerance ? fired : 0 });
if (running && minute % 10 === 0) {
/* Hotter shrinks more: the length follows the kiln, so the control
chart sees the excursion the trend sees. */
const length = 600 + (KILN.target - kiln) * 0.05 + (r() - 0.5) * 0.6;
samples.push({ minute, length: Math.round(length * 100) / 100 });
}
}
return { readings, samples, batches };
}
const SAMPLES = plant(7).samples;
/* Computed from the first fifteen samples, when the kiln ran in control. */
const LIMITS = controlLimits(
SAMPLES.map((s) => s.length),
{ kind: "referenceWindow", from: 0, to: 15 },
);
export default function SpecificationAndControl() {
const { Chart, XAxis, YAxis, Line } = useChart(SAMPLES);
return (
);
}
```
#### When to use something else
- Limits computed from the process itself → ControlChart
#### Keyboard
The keys of [Chart](#/chart/keyboard-chart) apply here.
#### API
##### `LimitLineProps`
| Prop | Type | Default | Description |
|---|---|---|---|
| `value` *required* | `number \| Date` | — | Where the line lies, in domain units of its axis; along a time axis an instant, in milliseconds or as a `Date`. Shown in: [Draw a limit](#/limitline/objective), [Lay a band under the series](#/limitline/band), [Read limits above the states](#/limitline/limits-and-state) and 11 more |
| `axisId` | `string` | the first axis of the orientation | Axis on which the value lies. Shown in: [Sort causes and collect the tail (Pareto)](#/pareto/pareto) |
| `orientation` | `"x" \| "y"` | `"y"` | Whether the value lies on the x or on the y axis - a limit is nearly always a value and not a moment. Shown in: [Mark moments and windows along time](#/limitline/along-time) |
| `severity` | [`Severity`](#type-Severity) `"warning" \| "alarm"` | `"alarm"` | Two severities, not five: five urgencies are five colours, and five colours on a dense screen are noise. Shown in: [Draw a limit](#/limitline/objective), [Lay a band under the series](#/limitline/band), [Read limits above the states](#/limitline/limits-and-state) and 10 more |
| `label` | `string` | — | What the value means. A line at 90 does not say what 90 is. Shown in: [Draw a limit](#/limitline/objective), [Lay a band under the series](#/limitline/band), [Read limits above the states](#/limitline/limits-and-state) and 11 more |
| `color` | `string` | — | Any CSS colour value, in place of the one the severity yields. A colour handed in instead of a severity puts the rule in the application and the presentation in the library, and the two drift apart as soon as a bound moves - so this is for the case that has no severity, not for taste. The line or band takes it; its label keeps the axis's text colour, which stays legible in both themes, and a short stroke before the text in the line's pattern takes this colour. Shown in: [Mark moments and windows along time](#/limitline/along-time) |
| `inExtent` | `boolean` | `true` | Whether the value widens the axis extent, so that the limit is certainly visible. Shown in: [Sort causes and collect the tail (Pareto)](#/pareto/pareto), [Watch latency against its objective (Scenarios)](#/scenarios/watch-latency-against-its-objective), [Hold the tile length in control (Scenarios)](#/scenarios/hold-the-tile-length-in-control) |
###### Accessibility
| Prop | Type | Default | Description |
|---|---|---|---|
| `role` | [`LimitRole`](#type-LimitRole) `"specification" \| "control" \| "zone"` | `"specification"` | A chosen specification limit or a calculated control limit. They look different because they mean different things ([ADR-0008](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0008-control-limits-are-not-specification-limits.md)). Shown in: [Set specification and control limits side by side](#/limitline/specification-and-control) |
##### `LimitBandProps`
| Prop | Type | Default | Description |
|---|---|---|---|
| `from` *required* | `number \| Date` | — | Lower edge of the band, in domain units of its axis; along a time axis an instant, in milliseconds or as a `Date`. Shown in: [Lay a band under the series](#/limitline/band), [Read limits above the states](#/limitline/limits-and-state), [Watch a kiln zone against its tolerance](#/limitline/kiln-zone) and 5 more |
| `to` *required* | `number \| Date` | — | Upper edge, in domain units of its axis. A band drawn backwards (`from` above `to`) is drawn between the two all the same - the pair is a range, not a direction. Shown in: [Lay a band under the series](#/limitline/band), [Read limits above the states](#/limitline/limits-and-state), [Watch a kiln zone against its tolerance](#/limitline/kiln-zone) and 5 more |
| `axisId` | `string` | the first axis of the orientation | Axis on which the value lies. Shown in: [Sort causes and collect the tail (Pareto)](#/pareto/pareto) |
| `orientation` | `"x" \| "y"` | `"y"` | Whether the value lies on the x or on the y axis - a limit is nearly always a value and not a moment. Shown in: [Mark moments and windows along time](#/limitline/along-time) |
| `severity` | [`Severity`](#type-Severity) `"warning" \| "alarm"` | `"warning"` | Two severities, not five: five urgencies are five colours, and five colours on a dense screen are noise. Shown in: [Draw a limit](#/limitline/objective), [Lay a band under the series](#/limitline/band), [Read limits above the states](#/limitline/limits-and-state) and 10 more |
| `label` | `string` | — | What the value means. A line at 90 does not say what 90 is. Shown in: [Draw a limit](#/limitline/objective), [Lay a band under the series](#/limitline/band), [Read limits above the states](#/limitline/limits-and-state) and 11 more |
| `color` | `string` | — | Any CSS colour value, in place of the one the severity yields. A colour handed in instead of a severity puts the rule in the application and the presentation in the library, and the two drift apart as soon as a bound moves - so this is for the case that has no severity, not for taste. The line or band takes it; its label keeps the axis's text colour, which stays legible in both themes, and a short stroke before the text in the line's pattern takes this colour. Shown in: [Mark moments and windows along time](#/limitline/along-time) |
| `inExtent` | `boolean` | `true` | Whether the value widens the axis extent, so that the limit is certainly visible. Shown in: [Sort causes and collect the tail (Pareto)](#/pareto/pareto), [Watch latency against its objective (Scenarios)](#/scenarios/watch-latency-against-its-objective), [Hold the tile length in control (Scenarios)](#/scenarios/hold-the-tile-length-in-control) |
###### Accessibility
| Prop | Type | Default | Description |
|---|---|---|---|
| `role` | [`LimitRole`](#type-LimitRole) `"specification" \| "control" \| "zone"` | `"specification"` | A chosen specification limit or a calculated control limit. They look different because they mean different things ([ADR-0008](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0008-control-limits-are-not-specification-limits.md)). Shown in: [Set specification and control limits side by side](#/limitline/specification-and-control) |
##### Types on this page
###### `Severity`
How seriously a violation weighs. Exactly two: one severity that advises and
one that calls for action. Five severities would be five colours, and five
colours on a dense screen are noise. Whoever needs a third
distinction sets a second limit.
```ts
type Severity = "warning" | "alarm";
```
###### `LimitRole`
What a limit stands for. The difference is not cosmetic: a specification
limit is chosen (what the customer assumes), a control limit is calculated
(what the process normally does). A process can be in control and outside the
specification and the other way round, and each of the four cases calls for
something different - which is why the two lines have to look
distinguishable ([ADR-0008](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0008-control-limits-are-not-specification-limits.md)).
```ts
type LimitRole = "specification" | "control" | "zone";
```
### ControlChart
Centre line, control limits and the rule violations of a process (an SPC or Shewhart chart). Reach for it to see a process drift before it leaves its specification.
```ts
import { ControlChart, controlLimits, violations } from "@umriss-ui/charts";
```
Demo page: https://romanhaendler.github.io/umriss-ui/charts/controlchart/
Control limits are computed from a named reference window, never from what happens to be visible; specification limits are chosen and drawn with `LimitLine` ([ADR-0008](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0008-control-limits-are-not-specification-limits.md)).
The rules - outlier, run, trend, two of three - are exported as plain functions, so the violations can stand beside the chart as text.
#### Examples
##### Chart a process against its control limits
In the plant: `origin` names the reference window the limits come from; the burner's overshoot breaks them while every tile stays in specification.
```tsx
import { ControlChart, LimitLine, Tooltip, useChart } from "@umriss-ui/charts";
/* Data from the plant world, written out here so the example runs on its own. */
/** A small LCG - reproducible across runs and platforms. */
function random(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
/** The early shift, 06:00 to 14:00. */
const SHIFT_MINUTES = 480;
/** The kiln's zone 3, in °C: the numbers every part reads it against, once.
Between the two warnings lies the tolerance - a tile fired outside it is
scrap; `returnTo` is the alarm's dead band. */
const KILN = { target: 1200, tolerance: [1185, 1215], alarm: 1230, returnTo: 1222 } as const;
/** The kiln fires one tile every quarter of a minute when nothing holds it up -
the ideal cycle time of the OEE's performance. */
const IDEAL_CYCLE_MINUTES = 0.25;
/** One minute of the line. */
interface Reading {
/** Minutes since the start of the shift. */
minute: number;
/** Zone 3 of the kiln, in °C. */
kiln: number;
/** The exit pyrometer, in °C - `null` while it sends nothing. */
exit: number | null;
/** Whether the belt runs. */
running: boolean;
/** Tiles out of the kiln in this minute. */
fired: number;
/** Of them, the ones fired inside the tolerance. */
good: number;
}
/** A tile taken off the belt and measured - one every ten minutes while it runs. */
interface Sample {
minute: number;
/** The tile's length after firing, in mm (nominal 600). */
length: number;
}
/** A batch of tiles through the three stations. */
interface Batch {
id: string;
name: string;
/** How many tiles the batch is planned for. */
tiles: number;
/** Minutes since the start of the shift, per station. */
press: readonly [number, number];
dryer: readonly [number, number];
kiln: readonly [number, number];
}
interface Plant {
readings: readonly Reading[];
samples: readonly Sample[];
batches: readonly Batch[];
}
/* When the exit pyrometer goes silent, and for how long. Fixed rather than
seeded, so that a running control room shows its reading turn stale, then
lost, and come back. */
const SILENT_FROM = 288;
const SILENT_FOR = 50;
/** The shift, from a seed. */
function plant(seed: number): Plant {
const r = random(seed);
/* The one excursion: a burner overshoots somewhere between 02:30 and 03:30
into the shift, for half an hour, peaking near 1242 °C. The operator stops
the feed twenty-two minutes in, for eighteen minutes. The noise around it
is too small to reach a warning on its own, so this is the only crossing -
which is what the tests hold. */
const surgeFrom = 150 + Math.floor(r() * 60);
const surgeFor = 30;
const stopFrom = surgeFrom + 22;
const stopFor = 18;
/* The plan: batches through the kiln one after another, each pressed an
hour and dried half an hour before - the first ones during the night
shift. Tiles come out of the kiln only while a batch is in it, so the
OEE's count and the batches' counts are the same tiles. The plan is a
plan: a stop of the belt does not move it. */
const batches: Batch[] = [];
for (let at = 0, i = 0; at < SHIFT_MINUTES; i++) {
const length = 70 + Math.floor(r() * 25);
batches.push({
id: `b-${4121 + i}`,
name: `B-${4121 + i}`,
tiles: Math.round(length / IDEAL_CYCLE_MINUTES / 10) * 10,
press: [at - 60, at - 60 + length],
dryer: [at - 30, at - 30 + length],
kiln: [at, at + length],
});
at += length + 5;
}
const readings: Reading[] = [];
const samples: Sample[] = [];
let drift = 0;
for (let minute = 0; minute < SHIFT_MINUTES; minute++) {
drift = 0.9 * drift + (r() - 0.5) * 3;
const into = minute - surgeFrom;
const surge = into >= 0 && into <= surgeFor ? 42 * Math.sin((Math.PI * into) / surgeFor) : 0;
const kiln = Math.round((KILN.target + drift + surge) * 10) / 10;
const running = !(minute >= stopFrom && minute < stopFrom + stopFor);
const loaded = batches.some((batch) => batch.kiln[0] <= minute && minute < batch.kiln[1]);
const jam = r() < 0.12;
const fired = running && loaded ? (jam ? 3 : 4) : 0;
const inTolerance = kiln >= KILN.tolerance[0] && kiln <= KILN.tolerance[1];
const silent = minute >= SILENT_FROM && minute < SILENT_FROM + SILENT_FOR;
const exit = silent ? null : Math.round((kiln - 380 + (r() - 0.5) * 4) * 10) / 10;
readings.push({ minute, kiln, exit, running, fired, good: inTolerance ? fired : 0 });
if (running && minute % 10 === 0) {
/* Hotter shrinks more: the length follows the kiln, so the control
chart sees the excursion the trend sees. */
const length = 600 + (KILN.target - kiln) * 0.05 + (r() - 0.5) * 0.6;
samples.push({ minute, length: Math.round(length * 100) / 100 });
}
}
return { readings, samples, batches };
}
/* A tile measured off the belt every ten minutes of the early shift. */
const SAMPLES = plant(7).samples;
/* The first fifteen samples, 06:00 to 08:20: the kiln demonstrably ran in
control. Never the samples that happen to be visible (ADR-0008). */
const REFERENCE_WINDOW = { kind: "referenceWindow", from: 0, to: 15 } as const;
export default function ControlChartExample() {
const { Chart, XAxis, YAxis } = useChart(SAMPLES);
return (
{/* Specification limits are chosen, control limits are computed. */}
);
}
```
##### List the rule violations
In the plant: `onViolations` hands over the verdict the chart marks, so the same finds can stand beside it as text.
```tsx
import { useState } from "react";
import { ControlChart, Tooltip, useChart } from "@umriss-ui/charts";
import type { RuleName, Violation } from "@umriss-ui/charts";
/* Data from the plant world, written out here so the example runs on its own. */
/** A small LCG - reproducible across runs and platforms. */
function random(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
/** The early shift, 06:00 to 14:00. */
const SHIFT_MINUTES = 480;
/** The kiln's zone 3, in °C: the numbers every part reads it against, once.
Between the two warnings lies the tolerance - a tile fired outside it is
scrap; `returnTo` is the alarm's dead band. */
const KILN = { target: 1200, tolerance: [1185, 1215], alarm: 1230, returnTo: 1222 } as const;
/** The kiln fires one tile every quarter of a minute when nothing holds it up -
the ideal cycle time of the OEE's performance. */
const IDEAL_CYCLE_MINUTES = 0.25;
/** One minute of the line. */
interface Reading {
/** Minutes since the start of the shift. */
minute: number;
/** Zone 3 of the kiln, in °C. */
kiln: number;
/** The exit pyrometer, in °C - `null` while it sends nothing. */
exit: number | null;
/** Whether the belt runs. */
running: boolean;
/** Tiles out of the kiln in this minute. */
fired: number;
/** Of them, the ones fired inside the tolerance. */
good: number;
}
/** A tile taken off the belt and measured - one every ten minutes while it runs. */
interface Sample {
minute: number;
/** The tile's length after firing, in mm (nominal 600). */
length: number;
}
/** A batch of tiles through the three stations. */
interface Batch {
id: string;
name: string;
/** How many tiles the batch is planned for. */
tiles: number;
/** Minutes since the start of the shift, per station. */
press: readonly [number, number];
dryer: readonly [number, number];
kiln: readonly [number, number];
}
interface Plant {
readings: readonly Reading[];
samples: readonly Sample[];
batches: readonly Batch[];
}
/* When the exit pyrometer goes silent, and for how long. Fixed rather than
seeded, so that a running control room shows its reading turn stale, then
lost, and come back. */
const SILENT_FROM = 288;
const SILENT_FOR = 50;
/** The shift, from a seed. */
function plant(seed: number): Plant {
const r = random(seed);
/* The one excursion: a burner overshoots somewhere between 02:30 and 03:30
into the shift, for half an hour, peaking near 1242 °C. The operator stops
the feed twenty-two minutes in, for eighteen minutes. The noise around it
is too small to reach a warning on its own, so this is the only crossing -
which is what the tests hold. */
const surgeFrom = 150 + Math.floor(r() * 60);
const surgeFor = 30;
const stopFrom = surgeFrom + 22;
const stopFor = 18;
/* The plan: batches through the kiln one after another, each pressed an
hour and dried half an hour before - the first ones during the night
shift. Tiles come out of the kiln only while a batch is in it, so the
OEE's count and the batches' counts are the same tiles. The plan is a
plan: a stop of the belt does not move it. */
const batches: Batch[] = [];
for (let at = 0, i = 0; at < SHIFT_MINUTES; i++) {
const length = 70 + Math.floor(r() * 25);
batches.push({
id: `b-${4121 + i}`,
name: `B-${4121 + i}`,
tiles: Math.round(length / IDEAL_CYCLE_MINUTES / 10) * 10,
press: [at - 60, at - 60 + length],
dryer: [at - 30, at - 30 + length],
kiln: [at, at + length],
});
at += length + 5;
}
const readings: Reading[] = [];
const samples: Sample[] = [];
let drift = 0;
for (let minute = 0; minute < SHIFT_MINUTES; minute++) {
drift = 0.9 * drift + (r() - 0.5) * 3;
const into = minute - surgeFrom;
const surge = into >= 0 && into <= surgeFor ? 42 * Math.sin((Math.PI * into) / surgeFor) : 0;
const kiln = Math.round((KILN.target + drift + surge) * 10) / 10;
const running = !(minute >= stopFrom && minute < stopFrom + stopFor);
const loaded = batches.some((batch) => batch.kiln[0] <= minute && minute < batch.kiln[1]);
const jam = r() < 0.12;
const fired = running && loaded ? (jam ? 3 : 4) : 0;
const inTolerance = kiln >= KILN.tolerance[0] && kiln <= KILN.tolerance[1];
const silent = minute >= SILENT_FROM && minute < SILENT_FROM + SILENT_FOR;
const exit = silent ? null : Math.round((kiln - 380 + (r() - 0.5) * 4) * 10) / 10;
readings.push({ minute, kiln, exit, running, fired, good: inTolerance ? fired : 0 });
if (running && minute % 10 === 0) {
/* Hotter shrinks more: the length follows the kiln, so the control
chart sees the excursion the trend sees. */
const length = 600 + (KILN.target - kiln) * 0.05 + (r() - 0.5) * 0.6;
samples.push({ minute, length: Math.round(length * 100) / 100 });
}
}
return { readings, samples, batches };
}
const SAMPLES = plant(7).samples;
const REFERENCE_WINDOW = { kind: "referenceWindow", from: 0, to: 15 } as const;
const RULES: Record = {
outlier: "Beyond a control limit",
run: "A run on one side of the centre",
trend: "A steady climb or fall",
twoOfThree: "Two of three near a limit",
};
const clock = (minute: number) => {
const total = 6 * 60 + minute;
return `${String(Math.floor(total / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`;
};
export default function ListTheViolations() {
const [found, setFound] = useState([]);
const { Chart, XAxis, YAxis } = useChart(SAMPLES);
return (
);
}
```
##### Choose the rules
In the plant: `rules` switches rules off and sets their run lengths - here rule 4 is off and a run counts from eight - and without `zoneLines` the sigma zones stay unmarked.
```tsx
import { useState } from "react";
import { ControlChart, Tooltip, useChart } from "@umriss-ui/charts";
import type { RuleName, Violation } from "@umriss-ui/charts";
/* Data from the plant world, written out here so the example runs on its own. */
/** A small LCG - reproducible across runs and platforms. */
function random(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
/** The early shift, 06:00 to 14:00. */
const SHIFT_MINUTES = 480;
/** The kiln's zone 3, in °C: the numbers every part reads it against, once.
Between the two warnings lies the tolerance - a tile fired outside it is
scrap; `returnTo` is the alarm's dead band. */
const KILN = { target: 1200, tolerance: [1185, 1215], alarm: 1230, returnTo: 1222 } as const;
/** The kiln fires one tile every quarter of a minute when nothing holds it up -
the ideal cycle time of the OEE's performance. */
const IDEAL_CYCLE_MINUTES = 0.25;
/** One minute of the line. */
interface Reading {
/** Minutes since the start of the shift. */
minute: number;
/** Zone 3 of the kiln, in °C. */
kiln: number;
/** The exit pyrometer, in °C - `null` while it sends nothing. */
exit: number | null;
/** Whether the belt runs. */
running: boolean;
/** Tiles out of the kiln in this minute. */
fired: number;
/** Of them, the ones fired inside the tolerance. */
good: number;
}
/** A tile taken off the belt and measured - one every ten minutes while it runs. */
interface Sample {
minute: number;
/** The tile's length after firing, in mm (nominal 600). */
length: number;
}
/** A batch of tiles through the three stations. */
interface Batch {
id: string;
name: string;
/** How many tiles the batch is planned for. */
tiles: number;
/** Minutes since the start of the shift, per station. */
press: readonly [number, number];
dryer: readonly [number, number];
kiln: readonly [number, number];
}
interface Plant {
readings: readonly Reading[];
samples: readonly Sample[];
batches: readonly Batch[];
}
/* When the exit pyrometer goes silent, and for how long. Fixed rather than
seeded, so that a running control room shows its reading turn stale, then
lost, and come back. */
const SILENT_FROM = 288;
const SILENT_FOR = 50;
/** The shift, from a seed. */
function plant(seed: number): Plant {
const r = random(seed);
/* The one excursion: a burner overshoots somewhere between 02:30 and 03:30
into the shift, for half an hour, peaking near 1242 °C. The operator stops
the feed twenty-two minutes in, for eighteen minutes. The noise around it
is too small to reach a warning on its own, so this is the only crossing -
which is what the tests hold. */
const surgeFrom = 150 + Math.floor(r() * 60);
const surgeFor = 30;
const stopFrom = surgeFrom + 22;
const stopFor = 18;
/* The plan: batches through the kiln one after another, each pressed an
hour and dried half an hour before - the first ones during the night
shift. Tiles come out of the kiln only while a batch is in it, so the
OEE's count and the batches' counts are the same tiles. The plan is a
plan: a stop of the belt does not move it. */
const batches: Batch[] = [];
for (let at = 0, i = 0; at < SHIFT_MINUTES; i++) {
const length = 70 + Math.floor(r() * 25);
batches.push({
id: `b-${4121 + i}`,
name: `B-${4121 + i}`,
tiles: Math.round(length / IDEAL_CYCLE_MINUTES / 10) * 10,
press: [at - 60, at - 60 + length],
dryer: [at - 30, at - 30 + length],
kiln: [at, at + length],
});
at += length + 5;
}
const readings: Reading[] = [];
const samples: Sample[] = [];
let drift = 0;
for (let minute = 0; minute < SHIFT_MINUTES; minute++) {
drift = 0.9 * drift + (r() - 0.5) * 3;
const into = minute - surgeFrom;
const surge = into >= 0 && into <= surgeFor ? 42 * Math.sin((Math.PI * into) / surgeFor) : 0;
const kiln = Math.round((KILN.target + drift + surge) * 10) / 10;
const running = !(minute >= stopFrom && minute < stopFrom + stopFor);
const loaded = batches.some((batch) => batch.kiln[0] <= minute && minute < batch.kiln[1]);
const jam = r() < 0.12;
const fired = running && loaded ? (jam ? 3 : 4) : 0;
const inTolerance = kiln >= KILN.tolerance[0] && kiln <= KILN.tolerance[1];
const silent = minute >= SILENT_FROM && minute < SILENT_FROM + SILENT_FOR;
const exit = silent ? null : Math.round((kiln - 380 + (r() - 0.5) * 4) * 10) / 10;
readings.push({ minute, kiln, exit, running, fired, good: inTolerance ? fired : 0 });
if (running && minute % 10 === 0) {
/* Hotter shrinks more: the length follows the kiln, so the control
chart sees the excursion the trend sees. */
const length = 600 + (KILN.target - kiln) * 0.05 + (r() - 0.5) * 0.6;
samples.push({ minute, length: Math.round(length * 100) / 100 });
}
}
return { readings, samples, batches };
}
const SAMPLES = plant(7).samples;
const REFERENCE_WINDOW = { kind: "referenceWindow", from: 0, to: 15 } as const;
/* Western Electric's run of eight in place of Nelson's nine; "two of three
near a limit" is not watched on this line, so its zones need no lines. */
const CHOSEN = { twoOfThree: false, runLength: 8 } as const;
const RULES: Record = {
outlier: "Beyond a control limit",
run: "Eight on one side of the centre",
trend: "A steady climb or fall",
twoOfThree: "Two of three near a limit",
};
const clock = (minute: number) => {
const total = 6 * 60 + minute;
return `${String(Math.floor(total / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`;
};
export default function ChooseTheRules() {
const [found, setFound] = useState([]);
const { Chart, XAxis, YAxis } = useChart(SAMPLES);
return (
);
}
```
#### When to use something else
- Limits you set yourself → LimitLine
#### Keyboard
The keys of [Chart](#/chart/keyboard-chart) apply here.
#### API
##### `ControlChartProps`
| Prop | Type | Default | Description |
|---|---|---|---|
| `value` *required* | [`Value`](#type-Value)`` | — | The values of the chart - a number field of its `data`'s rows or a function of them. Shown in: [Chart a process against its control limits](#/controlchart/control-chart), [List the rule violations](#/controlchart/list-the-violations), [Choose the rules](#/controlchart/choose-the-rules) and 1 more |
| `data` *required* | `readonly T[]` | — | Required: the limits arise out of these values, not out of what happens to be visible. Shown in: [Chart a process against its control limits](#/controlchart/control-chart), [List the rule violations](#/controlchart/list-the-violations), [Choose the rules](#/controlchart/choose-the-rules) and 1 more |
| `origin` *required* | [`ControlLimitOrigin`](#type-ControlLimitOrigin) | — | Given, or from a named reference window - never tacitly from everything on the screen ([ADR-0008](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0008-control-limits-are-not-specification-limits.md)). Shown in: [Chart a process against its control limits](#/controlchart/control-chart), [List the rule violations](#/controlchart/list-the-violations), [Choose the rules](#/controlchart/choose-the-rules) and 1 more |
| `xAxisId` | `string` | `"x"` | Binding to an x axis. |
| `yAxisId` | `string` | `"y"` | Binding to a y axis. |
| `name` | `string` | "Series n", after its place in the chart | The name in legend and tooltip. Without one a warning stands in DEV - an unnamed series is a colour nobody can look up. Shown in: [Chart a process against its control limits](#/controlchart/control-chart), [List the rule violations](#/controlchart/list-the-violations), [Choose the rules](#/controlchart/choose-the-rules) and 1 more |
| `format` | `(value: number) => string` | the y axis' `tickFormat`, else the built-in number format | The value as the tooltip writes it - on the line and on the violations. |
| `color` | `string` | the palette's `--uc-series-N` | Any CSS colour value. |
| `tone` | `"ok" \| "warning" \| "alarm"` | — | A role instead of a colour value for the line; the theme resolves it. The violations are always "alarm". |
| `rules` | `Partial<`[`RuleOptions`](#type-RuleOptions)`>` | — | Switchable one by one; run lengths are parameters. Shown in: [Choose the rules](#/controlchart/choose-the-rules) |
| `zoneLines` | `boolean` | `true` | The faint zone lines at one and two sigma. Rule 4 is about the two-sigma zone; without them a reader cannot check it. Shown in: [Choose the rules](#/controlchart/choose-the-rules) |
| `labelUpper` | `string` | no label | Label of the upper control limit in the axis band. The package brings no text along, not even "UCL" and "LCL". Shown in: [Chart a process against its control limits](#/controlchart/control-chart), [Hold the tile length in control (Scenarios)](#/scenarios/hold-the-tile-length-in-control) |
| `labelLower` | `string` | no label | The same for the lower control limit. Named separately because a chart may well want one of the two labelled and not the other. Shown in: [Chart a process against its control limits](#/controlchart/control-chart), [Hold the tile length in control (Scenarios)](#/scenarios/hold-the-tile-length-in-control) |
| `violationName` | `string` | "Series n", after its place in the chart | Name of the scatter of violations in the legend and the tooltip. Without one a legend warns once in DEV. Before this, a German suffix stood on the name of the chart. Shown in: [Chart a process against its control limits](#/controlchart/control-chart), [Hold the tile length in control (Scenarios)](#/scenarios/hold-the-tile-length-in-control) |
###### Events
| Prop | Type | Default | Description |
|---|---|---|---|
| `onViolations` | `(found: readonly `[`Violation`](#type-Violation)`[]) => void` | — | The violations as data - they can be listed beside the chart as well as marked on it. Shown in: [List the rule violations](#/controlchart/list-the-violations), [Choose the rules](#/controlchart/choose-the-rules) |
##### Types on this page
###### `Value`
A value read from the row ([ADR-0048](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0048-charts-are-bound-to-their-rows-by-the-hook.md)): the name of a number field, compared
by its name, or a function, compared by its source text (`ValueFunction`).
```ts
type Value = NumberField | ValueFunction;
```
###### `ControlLimitOrigin`
Where the limits come from. There are exactly these two possibilities.
```ts
type ControlLimitOrigin =
/** From a baseline the caller has fixed. Taken as it is - even where the
series would yield something else out of itself. */
| { readonly kind: "given"; readonly center: number; readonly sigma: number }
/** From exactly this index range of the series, half-open [from, to). */
| { readonly kind: "referenceWindow"; readonly from: number; readonly to: number };
```
###### `RuleOptions`
Which rules run, and with which run lengths.
| Prop | Type | Default | Description |
|---|---|---|---|
| `outlier` *required* | `boolean` | — | |
| `run` *required* | `boolean` | — | |
| `trend` *required* | `boolean` | — | |
| `twoOfThree` *required* | `boolean` | — | |
| `runLength` *required* | `number` | — | Points on one side of the centre line from which rule 2 fires. |
| `trendLength` *required* | `number` | — | Points in succession from which rule 3 fires. |
###### `Violation`
A rule and the indices it violates.
| Prop | Type | Default | Description |
|---|---|---|---|
| `rule` *required* | [`RuleName`](#type-RuleName) `"outlier" \| "run" \| "trend" \| "twoOfThree"` | — | Shown in: [List the rule violations](#/controlchart/list-the-violations), [Choose the rules](#/controlchart/choose-the-rules), [Hold the tile length in control (Scenarios)](#/scenarios/hold-the-tile-length-in-control) |
| `indices` *required* | `readonly number[]` | — | Shown in: [List the rule violations](#/controlchart/list-the-violations), [Choose the rules](#/controlchart/choose-the-rules), [Hold the tile length in control (Scenarios)](#/scenarios/hold-the-tile-length-in-control) |
###### `NumberField`
The fields of a row whose value is a number, or absent.
```ts
type NumberField = NoInfer<
{
[K in keyof T & string]-?: T[K] extends number | null | undefined ? K : never;
}[keyof T & string]
>;
```
###### `ValueFunction`
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.
```ts
type ValueFunction = (d: T, index: number) => number | null | undefined;
```
###### `RuleName`
Name of a rule, the way it is switched on and off in `violations`.
```ts
type RuleName = "outlier" | "run" | "trend" | "twoOfThree";
```
### Pareto
Causes sorted by weight with a cumulative line (a Pareto chart). Reach for it after a bad week, to see which few causes make most of the trouble.
```ts
import { pareto } from "@umriss-ui/charts";
```
Demo page: https://romanhaendler.github.io/umriss-ui/charts/pareto/
`pareto()` sorts, adds up and folds the tail into one entry that stands last whatever its size; the bars, the line and the second axis are yours to compose.
#### Examples
##### Sort causes and collect the tail
`pareto()` sorts descending, adds up and folds everything past `collectRank` into one entry that stands last; the bars and the line are yours, and the x axis reads each entry's `name`.
```tsx
import { useMemo } from "react";
import { Legend, LimitLine, Tooltip, pareto, useChart } from "@umriss-ui/charts";
/* Why deliveries failed last month, in no particular order. */
const FAILED_DELIVERIES = [
{ name: "Damaged in transit", value: 21 },
{ name: "Nobody home", value: 312 },
{ name: "Address incomplete", value: 96 },
{ name: "Refused", value: 34 },
{ name: "Business closed", value: 58 },
{ name: "No access code", value: 147 },
{ name: "Wrong address", value: 12 },
{ name: "Parcel not found", value: 8 },
{ name: "Weather", value: 6 },
{ name: "Vehicle breakdown", value: 4 },
];
export default function ParetoExample() {
const { entries } = useMemo(() => pareto(FAILED_DELIVERIES, { collectRank: 6, remainderName: "Other" }), []);
const { Chart, XAxis, YAxis, Bar, Line } = useChart(entries);
return (
`${v.toFixed(0)} %`}
label="cumulative"
/>
d.cumulative * 100} yAxisId="share" name="cumulative" markers="always" strokeWidth={1.75} />
);
}
```
#### Keyboard
The keys of [Chart](#/chart/keyboard-chart) apply here.
#### Props on this page
From `ChartProps` — the full table stands on [Chart](#/chart/type-ChartProps).
| Prop | Type | Default | Description |
|---|---|---|---|
| [`height`](#/chart/ChartProps-height) | `number` | fills the frame, at least 300 px | Height in CSS pixels, fixed. Left out, the chart fills its frame - a stretched grid cell, a card the grid stretches, a container of definite height - and never goes below 300 px, which is also its height in a frame without one. A different floor goes through `style`'s `minHeight` ([ADR-0050](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0050-a-chart-fills-its-frame-never-below-its-floor.md)). |
| [`children`](#/chart/ChartProps-children) | `ReactNode` | — | The axes, series and companions of this chart. They draw nothing themselves: each registers its configuration and the scene draws it, and the order in the JSX is the drawing order. |
###### Accessibility
| Prop | Type | Default | Description |
|---|---|---|---|
| [`ariaLabel`](#/chart/ChartProps-ariaLabel) | `string` | — | Required in DEV through a warning. |
From `XAxisProps` — the full table stands on [Axis](#/axis/type-XAxisProps).
| Prop | Type | Default | Description |
|---|---|---|---|
| [`value`](#/axis/XAxisProps-value) *required* | [`AxisField`](#/axis/type-AxisField)` \| ((d: T, index: number) => `[`AxisValue`](#/axis/type-AxisValue)`)` | — | Where a row lies on this axis: a field of the row, compared by its name, or a function of it, compared by its source text as a series' is (`ValueFunction`) - with the same closure limit. A number is a position, a `Date` an instant, a string a category: every distinct one gets a place of its own, in the order the rows bring them, a tick and its name as the label ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). The type decides, never the content - `"2024"` is a category, `2024` a number - and a row without a value has no place. The axis reads the rows of every series bound to it; where those bring their own `data`, name their row: ` value="hour" />`. |
| [`id`](#/axis/XAxisProps-id) | `string` | `"x"` | Axis id, through which series bind themselves. |
| [`label`](#/axis/XAxisProps-label) | `string` | — | Axis title. |
| [`tickFormat`](#/axis/XAxisProps-tickFormat) | `(v: number) => string` | — | Label of a tick. Compared by its source text: one that reads a changed closure variable under the same text does not relabel the axis - give it a new text or remount. A bound `Intl.NumberFormat#format` is compared by identity. |
From `YAxisProps` — the full table stands on [Axis](#/axis/type-YAxisProps).
| Prop | Type | Default | Description |
|---|---|---|---|
| [`domain`](#/axis/YAxisProps-domain) | `"nice" \| "data" \| "visible" \| readonly [number, number]` | `"nice"` | As on the x axis, and `"visible"`: what the series show inside their x axis' domain - a zoomed hour gets the hour's range, not the week's -, widened to ticks as `"nice"` is. Where the x domain is not fixed that is every point. |
| [`position`](#/axis/YAxisProps-position) | `"left" \| "right"` | `"left"` | Which edge the axis stands at. Several y axes per side are stacked outwards, so that extents of clearly different magnitude stay readable. |
| [`id`](#/axis/YAxisProps-id) | `string` | `"y"` | Axis id, through which series bind themselves. |
| [`label`](#/axis/YAxisProps-label) | `string` | — | Axis title. |
| [`tickFormat`](#/axis/YAxisProps-tickFormat) | `(v: number) => string` | — | Label of a tick. Compared by its source text: one that reads a changed closure variable under the same text does not relabel the axis - give it a new text or remount. A bound `Intl.NumberFormat#format` is compared by identity. |
From `LineProps` — the full table stands on [Line](#/line/type-LineProps).
| Prop | Type | Default | Description |
|---|---|---|---|
| [`value`](#/line/LineProps-value) *required* | [`Value`](#/line/type-Value)`` | — | Y value - a number field of the row or a function of it; null/undefined/NaN/±Infinity means a gap. |
| [`yAxisId`](#/line/LineProps-yAxisId) | `string` | `"y"` | Binding to a y axis. |
| [`name`](#/line/LineProps-name) | `string` | "Series n", after its place in the chart | The name in legend and tooltip. Without one a warning stands in DEV - an unnamed series is a colour nobody can look up. |
| [`strokeWidth`](#/line/LineProps-strokeWidth) | `number` | `1.5` | Line width in CSS pixels. |
| [`markers`](#/line/LineProps-markers) | `"auto" \| "always" \| "never"` | `"auto"` | When the individual points are drawn as marks: `auto` every point up to 60 points and above that only a point between two gaps - it has no line to be seen by - `always`, or `never`, not even that one. |
From `BarProps` — the full table stands on [Bar](#/bar/type-BarProps).
| Prop | Type | Default | Description |
|---|---|---|---|
| [`value`](#/bar/BarProps-value) *required* | [`Value`](#/bar/type-Value)`` | — | Height - a number field of the row or a function of it; null/undefined/NaN/±Infinity means a gap. |
| [`name`](#/bar/BarProps-name) | `string` | "Series n", after its place in the chart | The name in legend and tooltip. Without one a warning stands in DEV - an unnamed series is a colour nobody can look up. |
| [`barWidth`](#/bar/BarProps-barWidth) | `number` | `0.8` | Width as a fraction of the step; just under 1, so that neighbours do not touch. Several bar series share this fraction. |
From `LimitLineProps` — the full table stands on [LimitLine](#/limitline/type-LimitLineProps).
| Prop | Type | Default | Description |
|---|---|---|---|
| [`value`](#/limitline/LimitLineProps-value) *required* | `number \| Date` | — | Where the line lies, in domain units of its axis; along a time axis an instant, in milliseconds or as a `Date`. |
| [`axisId`](#/limitline/LimitLineProps-axisId) | `string` | the first axis of the orientation | Axis on which the value lies. |
| [`severity`](#/limitline/LimitLineProps-severity) | [`Severity`](#/limitline/type-Severity) `"warning" \| "alarm"` | `"alarm"` | Two severities, not five: five urgencies are five colours, and five colours on a dense screen are noise. |
| [`label`](#/limitline/LimitLineProps-label) | `string` | — | What the value means. A line at 90 does not say what 90 is. |
| [`inExtent`](#/limitline/LimitLineProps-inExtent) | `boolean` | `true` | Whether the value widens the axis extent, so that the limit is certainly visible. |
From `LimitBandProps` — the full table stands on [LimitLine](#/limitline/type-LimitBandProps).
| Prop | Type | Default | Description |
|---|---|---|---|
| [`axisId`](#/limitline/LimitBandProps-axisId) | `string` | the first axis of the orientation | Axis on which the value lies. |
| [`severity`](#/limitline/LimitBandProps-severity) | [`Severity`](#/limitline/type-Severity) `"warning" \| "alarm"` | `"warning"` | Two severities, not five: five urgencies are five colours, and five colours on a dense screen are noise. |
| [`label`](#/limitline/LimitBandProps-label) | `string` | — | What the value means. A line at 90 does not say what 90 is. |
| [`inExtent`](#/limitline/LimitBandProps-inExtent) | `boolean` | `true` | Whether the value widens the axis extent, so that the limit is certainly visible. |
From `TooltipProps` — the full table stands on [Tooltip](#/tooltip/type-TooltipProps).
| Prop | Type | Default | Description |
|---|---|---|---|
| [`mode`](#/tooltip/TooltipProps-mode) | `"x" \| "nearest"` | `"x"` | "x": every series at the x position; "nearest": only the nearest point. |
From `LegendProps` — the full table stands on [Legend](#/legend/type-LegendProps).
| Prop | Type | Default | Description |
|---|---|---|---|
| [`placement`](#/legend/LegendProps-placement) | `"top" \| "bottom"` | `"top"` | Which side of the plot area the legend stands on. |
## Around the chart
What stands beside the series: what a hover says, what the colours mean, and what the whole thing costs.
### Tooltip
What a hover reports (a hover card), and the values as a table for a reader who wants them all at once.
```ts
import { Tooltip, DataTable } from "@umriss-ui/charts";
```
Demo page: https://romanhaendler.github.io/umriss-ui/charts/tooltip/
The crosshair snaps to a reading, never between two. The data table's key stands at the end of the legend.
`useChart(rows, { onSelect })` hears a click or tap on a hit and Enter on the Active point, with the hit a custom `render` receives - once, never at the end of a drag, and for a double click only on its first click. The chart keeps and draws no selection; it needs a `Tooltip` for its hits and does not send the hit over `syncId`.
#### Examples
##### Report every series or the nearest
`mode="x"` lists every series at the pointer's x; `mode="nearest"` names only the closest point - the question when lines cross.
```tsx
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 SEARCH = metrics("search");
const SIGN_IN = metrics("sign-in");
function Latencies({ mode }: { mode: "x" | "nearest" }) {
const { Chart, XAxis, YAxis, Line } = useChart(SEARCH);
return (
);
}
export default function XOrNearest() {
return (
mode="x" - every series at the x position
mode="nearest" - only the nearest point
);
}
```
##### Write the tooltip yourself
`render` receives the hit - the x value and each series' point with its row - and returns the box's content, in your screen's language.
```tsx
import { Legend, Tooltip, useChart } from "@umriss-ui/charts";
import type { TooltipHit } 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 CHECKOUT = metrics("checkout");
const BILLING = metrics("billing");
const OBJECTIVE: Record = Object.fromEntries(SERVICES.map((one) => [one.name, one.latencySlo]));
const timeOfDay = (v: number) =>
new Date(v).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
function AgainstObjective({ hit }: { hit: TooltipHit }) {
return (
);
}
export default function OwnContent() {
const { Chart, XAxis, YAxis, Line } = useChart(CHECKOUT);
return (
mode="x" render={(hit) => } />
);
}
```
##### Write each value in its unit
`format` on a series writes its value in the tooltip; without it the y axis' `tickFormat` does, and without that a bare number.
```tsx
import { Legend, 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 CHECKOUT = metrics("checkout");
const milliseconds = (v: number) => `${v.toFixed(0)} ms`;
const percent = (v: number) => `${v.toFixed(2)} %`;
export default function ValueFormat() {
const { Chart, XAxis, YAxis, Line } = useChart(CHECKOUT);
return (
);
}
```
##### Show the values as a table
`DataTable` adds a key to the legend that lays a table of what the chart shows over the plot, and takes it back.
```tsx
import { DataTable, Legend, Tooltip, useChart } from "@umriss-ui/charts";
/* Data from the planning world, written out here so the example runs on its own. */
const at = (day: number, hours = 9, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
/** A work item, shaped as `@umriss-ui/schedule`'s `Subtask`. */
interface WorkItem {
id: string;
/** The project. */
task: string;
/** The person. */
lane: string;
from: number;
to: number;
name: string;
sprint: string;
/** Hours, as estimated at planning. */
estimate: number;
status: "to do" | "in progress" | "in review" | "done";
}
const item = (id: string, task: string, lane: string, from: number, to: number, name: string, estimate: number, status: WorkItem["status"]): WorkItem =>
({ id, task, lane, from, to, name, sprint: "sprint-14", estimate, status });
const WORK: readonly WorkItem[] = [
item("w-101", "portal", "arjun", at(9), at(11, 17), "Sign-in with e-mail code", 18, "done"),
item("w-102", "portal", "arjun", at(12), at(17, 17), "Profile page", 26, "in progress"),
item("w-103", "portal", "chloe", at(9), at(10, 17), "Session handling", 12, "done"),
item("w-104", "shop", "chloe", at(11), at(13, 17), "Basket keeps items across devices", 20, "in review"),
item("w-105", "portal", "chloe", at(16), at(19, 17), "Change of address form", 24, "in progress"),
item("w-106", "portal", "noah", at(9), at(12, 13), "Profile page design", 14, "done"),
item("w-107", "shop", "noah", at(16), at(18, 17), "Search results layout", 12, "to do"),
item("w-108", "portal", "eva", at(12), at(13, 17), "Test plan for sign-in", 10, "done"),
item("w-109", "portal", "eva", at(17), at(20, 17), "Regression run", 20, "to do"),
item("w-110", "booking", "hana", at(9), at(13, 17), "Reminder scheduling service", 32, "done"),
item("w-111", "booking", "hana", at(16), at(20, 17), "Push notifications", 30, "in progress"),
item("w-112", "booking", "kofi", at(10), at(12, 17), "Calendar sync", 18, "done"),
item("w-113", "intranet", "kofi", at(16), at(18, 17), "News feed", 16, "in progress"),
item("w-114", "booking", "freya", at(9), at(11, 17), "Reminder settings screen", 16, "done"),
item("w-115", "booking", "freya", at(18), at(20, 17), "Store screenshots", 12, "to do"),
item("w-116", "booking", "david", at(16), at(19, 13), "Device test matrix", 14, "to do"),
];
interface BurndownPoint {
/** The working day, at 17:00. */
t: number;
/** Where the line would be, burning evenly. */
ideal: number;
/** Hours left, `null` for the days still to come. */
remaining: number | null;
}
/** Sprint 14's ten working days. The team fell behind in the first week, when
two were away, and is catching up. */
const BURNDOWN: readonly BurndownPoint[] = (() => {
const total = WORK.reduce((sum, one) => sum + one.estimate, 0);
const days = [9, 10, 11, 12, 13, 16, 17, 18, 19, 20];
const burnt = [0, 22, 38, 60, 71, 98];
return days.map((day, i) => ({
t: at(day, 17),
ideal: Math.round(total * (1 - i / (days.length - 1))),
remaining: burnt[i] === undefined ? null : total - burnt[i]!,
}));
})();
const day = (v: number) => new Date(v).toLocaleDateString("en-GB", { weekday: "short", day: "numeric" });
const hours = (v: number) => `${v.toFixed(0)} h`;
export default function ValuesAsTable() {
const { Chart, XAxis, YAxis, Line } = useChart(BURNDOWN);
return (
d.t)} tickFormat={day} />
{/* The table lists what the chart shows: hide a series and it follows. */}
);
}
```
##### Table a long series
Above 500 rows the table lists each stretch's first, lowest, highest and last value and says so; zoom in and every reading returns.
```tsx
import { DataTable, 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;
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;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
/** Monday, 9 March 2026, 00:00 - the start of last week. */
const LAST_WEEK = at(9, 0);
/** Search returned nothing for some regions (INC-1043): Saturday, 18:30 to 19:55. */
const SEARCH_OUTAGE = [at(14, 18, 30) - LAST_WEEK, at(14, 19, 55) - LAST_WEEK] as const;
/** A service's last week, Monday to Monday, one point every `step`
milliseconds: busy by day, quieter at the weekend. During INC-1043 Search's
95th percentile more than doubles and its errors climb. The hour is counted,
not asked of a Date - a week of seconds is 604,800 points. */
function week(serviceId: string, step: number): 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(2000 + index * 37);
const n = Math.floor((7 * DAY) / step);
const points = new Array(n);
/* The drift wanders at the same pace whatever the step. */
const keep = Math.pow(0.8, step / (5 * MINUTE));
const kick = 0.08 * Math.sqrt(1 - keep * keep) / 0.6;
let drift = 0;
for (let i = 0; i < n; i++) {
const offset = i * step;
const day = Math.floor(offset / DAY);
const hour = (offset % DAY) / HOUR;
const load = (day >= 5 ? 0.85 : 1) * (0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16)));
drift = keep * drift + (r() - 0.5) * kick;
const outage = serviceId === "search" && offset >= SEARCH_OUTAGE[0] && offset < SEARCH_OUTAGE[1];
const p95 = Math.round(service.latencySlo * (0.45 + 0.2 * load + drift) * (outage ? 2.3 : 1) * (1 + r() * 0.08));
points[i] = {
t: LAST_WEEK + offset,
p50: Math.round(p95 * (0.42 + r() * 0.06)),
p95,
errorRate: Math.round((0.05 + r() * 0.12 + (outage ? 3 : 0)) * 100) / 100,
requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
};
}
return points;
}
const SEARCH_WEEK = week("search", 60_000);
const milliseconds = (v: number) => `${v.toFixed(0)} ms`;
export default function WeekAsTable() {
const { Chart, XAxis, YAxis, Line } = useChart(SEARCH_WEEK);
return (
{/* Without a legend the key stands on a line of its own. */}
);
}
```
##### Read the limits at the hit
`limits` adds a row for every labelled limit on the hit's y axis, its label and value in the axis' format; a key reads the same out, and the chart's summary names them without it.
```tsx
import { LimitBand, LimitLine, Tooltip, useChart } from "@umriss-ui/charts";
/* Checkout's 95th percentile latency every ten minutes of a morning, in ms, as the monitor exports it. */
const LATENCY = [
{ time: "09:00", p95: 212 }, { time: "09:10", p95: 224 }, { time: "09:20", p95: 231 },
{ time: "09:30", p95: 246 }, { time: "09:40", p95: 318 }, { time: "09:50", p95: 402 },
{ time: "10:00", p95: 387 }, { time: "10:10", p95: 291 }, { time: "10:20", p95: 238 },
{ time: "10:30", p95: 229 },
].map((row, x) => ({ ...row, x }));
export default function Limits() {
const { Chart, XAxis, YAxis, Line } = useChart(LATENCY);
return (
LATENCY[Math.round(v)]?.time ?? ""} />
`${v} ms`} />
);
}
```
##### Choose a hit
`onSelect` hears a click on a hit, a tap, or Enter on the Active point, with the same hit a custom tooltip receives. The chart keeps no choice and draws none: it is your state, here shown below the chart.
```tsx
import { useState } from "react";
import { Tooltip, useChart, type TooltipHit } from "@umriss-ui/charts";
/* Orders taken per hour of a shop's day, written out here so the example runs on its own. */
const ORDERS = [
{ hour: 8, web: 14, phone: 6 }, { hour: 9, web: 22, phone: 9 }, { hour: 10, web: 31, phone: 11 },
{ hour: 11, web: 38, phone: 8 }, { hour: 12, web: 52, phone: 14 }, { hour: 13, web: 47, phone: 10 },
{ hour: 14, web: 35, phone: 7 }, { hour: 15, web: 29, phone: 9 },
];
type Order = (typeof ORDERS)[number];
export default function ChooseAHit() {
const [chosen, setChosen] = useState | null>(null);
const { Chart, XAxis, YAxis, Line } = useChart(ORDERS, { onSelect: setChosen });
return (
<>
`${v}:00`} />
Chosen
{chosen === null ? "nothing yet - click a point, or press Enter on one" : `${chosen.xValue}:00`}
{chosen?.points.map((p) => (
{p.seriesName}
{p.yValue} orders
))}
>
);
}
```
##### Read a stack's total in the table
The table of a stacked chart ends the stack's members with a Total column - the sum the tooltip shows at the same place, so nobody adds the row up by hand.
```tsx
import { DataTable, Legend, Tooltip, useChart } from "@umriss-ui/charts";
/* Support tickets opened per working day, by channel - one week, 2 to 6 March. */
const TICKETS = [
{ day: "Mon 2", mail: 34, chat: 21, phone: 9 },
{ day: "Tue 3", mail: 29, chat: 25, phone: 12 },
{ day: "Wed 4", mail: 31, chat: 18, phone: 7 },
{ day: "Thu 5", mail: 27, chat: 30, phone: 11 },
{ day: "Fri 6", mail: 22, chat: 16, phone: 6 },
];
export default function TotalOfAStack() {
const { Chart, XAxis, YAxis, Bar } = useChart(TICKETS);
return (
);
}
```
#### Keyboard
The keys of [Chart](#/chart/keyboard-chart) apply here.
#### API
##### `TooltipProps`
| Prop | Type | Default | Description |
|---|---|---|---|
| `mode` | `"x" \| "nearest"` | `"x"` | "x": every series at the x position; "nearest": only the nearest point. Shown in: [Report every series or the nearest](#/tooltip/x-or-nearest), [Write the tooltip yourself](#/tooltip/own-content), [Write each value in its unit](#/tooltip/value-format) and 61 more |
| `limits` | `boolean` | `false` | Adds a row for every labelled `LimitLine` and `LimitBand` on the hit's y axis - its label and value in that axis' format - to the built-in tooltip, and the same to what a key reads out. Limits are not series and stay out of the tooltip unless asked for. Shown in: [Read the limits at the hit](#/tooltip/limits) |
| `render` | `(hit: `[`TooltipHit`](#type-TooltipHit)`) => ReactNode` | the built-in tooltip | Render prop for content of one's own. Compared by its source text, like `tickFormat` - with the same closure limit. Shown in: [Write the tooltip yourself](#/tooltip/own-content), [Fix the domain and name the ticks (Axis)](#/axis/configuration) |
##### `ChartOptions`
| Prop | Type | Default | Description |
|---|---|---|---|
| `initialView` | [`ChartView`](#/view/type-ChartView) | — | The view to start from, read once when the hook first runs; a different value on a later render is ignored, as React ignores a changed `defaultValue`. To go to a view later, call `setView`. The chart remembers none: where a view is kept is the application's decision. Shown in: [Fit the y axis to what is visible (Zoom and pan)](#/zoom-and-pan/visible-domain), [Hide a series from the legend (Legend)](#/legend/hide-a-series) |
###### Events
| Prop | Type | Default | Description |
|---|---|---|---|
| `onViewChange` | `(view: `[`ChartView`](#/view/type-ChartView)`) => void` | — | Every change the reader makes, once, always the whole view - the one to keep, or to hand another chart's `setView`. A pan or zoom is reported at most once per frame. Not called for the view the chart starts with, nor for one `setView` applies. Shown in: [Keep charts in step (Zoom and pan)](#/zoom-and-pan/cursor-sync), [Read it by keyboard and screen reader (Chart)](#/chart/keyboard-and-screen-reader) |
| `onSelect` | `(hit: `[`TooltipHit`](#type-TooltipHit)`) => void` | — | The reader chose a point: a click on a hit (a tap too), or Enter on the Active point - the same hit the tooltip's `render` receives. Once per click; the end of a drag does not report, and the second click of a double click does not either. The chart holds no selection and draws none: what is chosen is the application's state. Needs a ``, which makes the hits; the hit does not travel over `syncId`. Shown in: [Choose a hit](#/tooltip/choose-a-hit) |
##### Types on this page
###### `TooltipHit`
What the pointer hit: the x value under it and one point per series there.
A custom tooltip's `render` receives it.
| Prop | Type | Default | Description |
|---|---|---|---|
| `xValue` *required* | `number` | — | Shown in: [Write the tooltip yourself](#/tooltip/own-content), [Choose a hit](#/tooltip/choose-a-hit), [Fix the domain and name the ticks (Axis)](#/axis/configuration) |
| `points` *required* | `readonly `[`TooltipPoint`](#type-TooltipPoint)`[]` | — | Shown in: [Write the tooltip yourself](#/tooltip/own-content), [Choose a hit](#/tooltip/choose-a-hit), [Fix the domain and name the ticks (Axis)](#/axis/configuration) |
| `xPx` *required* | `number` | — | Pixel position of the crosshair (snapped to the data point). |
| `yPx` *required* | `number` | — | Pixel position of the primary hit. |
###### `TooltipPoint`
One hit per series in the tooltip.
| Prop | Type | Default | Description |
|---|---|---|---|
| `seriesName` *required* | `string` | — | Shown in: [Write the tooltip yourself](#/tooltip/own-content), [Choose a hit](#/tooltip/choose-a-hit), [Fix the domain and name the ticks (Axis)](#/axis/configuration) |
| `color` *required* | `string` | — | |
| `xValue` *required* | `number` | — | The point's x value on its own x axis - with several x axes it can differ from the hit's. |
| `yValue` *required* | `number` | — | The point's y value; in a stack its own value, not its top - in a normalised stack its share in percent. Shown in: [Choose a hit](#/tooltip/choose-a-hit), [Fix the domain and name the ticks (Axis)](#/axis/configuration) |
| `datum` *required* | `T` | — | Shown in: [Write the tooltip yourself](#/tooltip/own-content) |
| `index` *required* | `number` | — | |
| `level` | `number` | — | Only for the matrix: its level, out of the level channel. It stands here and not in yValue, because yValue is the position on the y axis - for a matrix therefore the row. Putting both into one field would be exactly the overloading [ADR-0011](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0011-named-channels-never-overloaded.md) is written against. |
| `segment` | `{ from: number; to: number; label: string }` | — | Only for state series: the section being pointed at. A duration does not stand here - it is the difference of two numbers the entry already carries, and formatting it would presuppose knowing what the x axis means. The caller knows that, not this library. |
| `box` | [`BoxNumbers`](#type-BoxNumbers)` & Partial<`[`BoxExtras`](#type-BoxExtras)`` > & { /** As the caller gave them; only where the series has `outliers`. */ outliers?: readonly number[]; } `` | — | Only for a box: its further numbers; yValue is its median. Fields of their own for the reason `level` has one ([ADR-0011](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0011-named-channels-never-overloaded.md)). |
###### `BoxNumbers`
A box's further numbers beside its median, by name.
| Prop | Type | Default | Description |
|---|---|---|---|
| `lowerQuartile` *required* | `V` | — | |
| `upperQuartile` *required* | `V` | — | |
| `lowerWhisker` *required* | `V` | — | |
| `upperWhisker` *required* | `V` | — | |
###### `BoxExtras`
The optional numbers of a box by name.
| Prop | Type | Default | Description |
|---|---|---|---|
| `mean` *required* | `V` | — | |
| `notchLower` *required* | `V` | — | |
| `notchUpper` *required* | `V` | — | |
| `count` *required* | `V` | — | |
### Legend
What the colours mean (a key), and a switch for every series: a click hides it and shows it again, a double click shows only it (a solo).
```ts
import { Legend, useChart } from "@umriss-ui/charts";
```
Demo page: https://romanhaendler.github.io/umriss-ui/charts/legend/
The legend toggles by default: every entry of a named series is a button that says whether its series is shown. The hidden series are part of the chart's view, by `name` - no state of yours, and kept like the rest of the view, on [View](#/view). A series without a name cannot be hidden; its entry only explains.
A double click, Alt/⌥+click or Shift+Enter on an entry shows only its series; on the only one visible, all again. A click acts at once rather than wait for a second, so a double click first toggles the series and back - its result is the same either way.
A hidden series leaves the drawing, the y extent and the keys' walk; its entry stays, struck through, and keeps its colour, so showing it again moves nothing else. Nothing hides every series: what would shows all instead. A state band's entries are its states, and any of them hides or shows only the whole band.
On the hook, `hidden`, `toggleSeries(name)`, `showOnly(name)` and `showAllSeries()` do the same from your own controls.
#### Examples
##### Hide a series from the legend
A click on an entry hides its series and shows it again, through the chart's view - no state of yours. A double click, Alt/⌥+click or Shift+Enter shows only it, and on the only one visible all again; `showOnly` and `showAllSeries` on the hook do the same from your own buttons.
```tsx
import { Legend, 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 SERIES = [
{ name: "Checkout", data: metrics("checkout") },
{ name: "Billing", data: metrics("billing") },
{ name: "Reporting", data: metrics("reports") },
];
export default function HideASeries() {
/* Reporting's slow jobs would flatten the other two. */
const { Chart, XAxis, YAxis, Line, showOnly, showAllSeries } = useChart(SERIES[0]!.data, {
initialView: { hidden: ["Reporting"] },
});
return (
<>
{SERIES.map((one) => (
))}
>
);
}
```
##### Legend above or below
A `Legend` stands above the plot by default, `placement="bottom"` puts it below; hovering an entry lifts its series.
```tsx
import { Legend, useChart } from "@umriss-ui/charts";
/* Data from the controlling 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;
};
}
interface CostCentre {
id: string;
name: string;
owner: string;
/** The budget of one month. */
monthlyBudget: number;
}
const COST_CENTRES: readonly CostCentre[] = [
{ id: "CC-1100", name: "Sales", owner: "Helen Marsh", monthlyBudget: 142_000 },
{ id: "CC-1200", name: "Marketing", owner: "Rafael Ortiz", monthlyBudget: 68_000 },
{ id: "CC-2100", name: "Engineering", owner: "Anika Sørensen", monthlyBudget: 188_000 },
{ id: "CC-2200", name: "Design", owner: "Paul Whitaker", monthlyBudget: 54_000 },
{ id: "CC-3100", name: "Customer service", owner: "Grace Obi", monthlyBudget: 61_000 },
{ id: "CC-4100", name: "Finance", owner: "Martina Vogel", monthlyBudget: 47_000 },
{ id: "CC-4200", name: "People", owner: "Daniel Frost", monthlyBudget: 39_000 },
{ id: "CC-4300", name: "IT", owner: "Kenji Arai", monthlyBudget: 83_000 },
{ id: "CC-4400", name: "Facilities", owner: "Olga Ivanova", monthlyBudget: 72_000 },
];
/** The year's months, as `"2026-01"` … `"2026-12"`. */
const MONTHS: readonly string[] = Array.from({ length: 12 }, (_, i) => `2026-${String(i + 1).padStart(2, "0")}`);
/** The months whose books are closed: they have an actual. */
const CLOSED_MONTHS = 2;
interface LedgerRow {
costCentre: string;
month: string;
budget: number;
/** `null` while the month is open. */
actual: number | null;
forecast: number;
}
/* Where a cost centre lands against its budget, as a factor: Marketing's
spring campaign overspends, IT's delayed licences underspend. */
const TENDENCY: Readonly> = { "CC-1200": 1.14, "CC-4300": 0.88 };
/** Budget, actual and forecast of every cost centre in every month. */
const LEDGER: readonly LedgerRow[] = COST_CENTRES.flatMap((centre, c) => {
const r = random(310 + c);
const tendency = TENDENCY[centre.id] ?? 1;
return MONTHS.map((month, m) => {
/* December pays the bonuses, August is the quiet month. */
const season = m === 11 ? 1.12 : m === 7 ? 0.9 : 1;
const budget = Math.round(centre.monthlyBudget * season);
const forecast = Math.round((budget * (tendency + (r() - 0.5) * 0.06)) / 100) * 100;
const actual = m < CLOSED_MONTHS ? Math.round(forecast * (1 + (r() - 0.5) * 0.08)) : null;
return { costCentre: centre.id, month, budget, actual, forecast };
});
});
const MARKETING = LEDGER.filter((row) => row.costCentre === "CC-1200");
const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/** `"2026-03"` as the axis writes it. */
const monthName = (month: string) => MONTH_NAMES[Number(month.slice(5)) - 1] ?? month;
function Marketing({ placement }: { placement?: "bottom" }) {
const { Chart, XAxis, YAxis, Line } = useChart(MARKETING);
return (
);
}
export default function LegendPlacement() {
return (
no placement - above, the default
placement="bottom"
);
}
```
#### Keyboard
| Key | Action |
|---|---|
| `Enter Space` | Hide the entry's series, or show it again. |
| `Shift+Enter` | Show only the entry's series; on the only one visible, all again. |
The keys of [Chart](#/chart/keyboard-chart) apply here.
#### Accessibility
Every entry of a named series is a button whose `aria-pressed` says whether its series is shown, and the plot's summary names Shift+Enter. Where a click, a gesture or a setter would leave nothing visible and shows all instead, the chart's polite live region says so.
#### API
##### `LegendProps`
| Prop | Type | Default | Description |
|---|---|---|---|
| `placement` | `"top" \| "bottom"` | `"top"` | Which side of the plot area the legend stands on. Shown in: [Legend above or below](#/legend/above-or-below), [Mix series kinds (Chart)](#/chart/mixed), [Mark every kind (Chart)](#/chart/marks-on-every-kind) and 28 more |
### Benchmark
Millions of points, measured in this browser rather than claimed: the time to prepare and draw them, and the frame rate while the pointer moves.
```ts
import { useChart } from "@umriss-ui/charts";
```
Demo page: https://romanhaendler.github.io/umriss-ui/charts/benchmark/
A hover redraws only the overlay layer, so the frame rate stays up over a million points. The figures in `packages/charts/docs/capabilities.md` come from here.
#### Examples
##### Measure a million points
Load up to a million points per series and hover: the times and the frame rate say what drawing and a hover cost here.
```tsx
/* Lines and the area downsample on their own above two points per pixel
column; a hover draws the overlay layer only, which the FPS counter shows.
Live appends 10 points a second in a moving window, and the axis width stays
calm through its hysteresis. Not photographed: it measures the
moment it runs. */
import { useCallback, useEffect, useRef, useState } from "react";
import { Legend, Tooltip, useChart } from "@umriss-ui/charts";
import type { ChartPerf } from "@umriss-ui/charts";
/* A small LCG - the same numbers on every run. */
function random(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
interface LoadPoint {
t: number;
s1: number;
s2: number;
s3: number;
/** A state code 0..3 that holds for a run of about ten points. */
st: number;
/** Column, row and level of the same points laid out as a grid 200 rows high. */
c: number;
r: number;
lvl: number;
}
const STATES = [
{ label: "A", color: "#2f6fb4" },
{ label: "B", color: "#c08a2e" },
{ label: "C", color: "#8a8f98" },
{ label: "D", color: "#6b8e5a" },
] as const;
const ROWS = 200;
/** Three random walks of any length, with a state, and a grid laid over them. */
function load(seed: number, n: number): LoadPoint[] {
const r = random(seed);
const points = new Array(n);
let s1 = 100;
let s2 = 60;
let s3 = 140;
let st = 0;
const columns = Math.max(1, Math.ceil(n / ROWS));
for (let i = 0; i < n; i++) {
s1 += r() - 0.5;
s2 += (r() - 0.5) * 0.8;
s3 += (r() - 0.5) * 1.2;
if (r() < 0.1) st = Math.floor(r() * 4);
const c = i % columns;
const row = Math.floor(i / columns);
points[i] = { t: i, s1, s2, s3, st, c, r: row, lvl: 0.5 + 0.5 * Math.sin(c / 40) * Math.cos(row / 15) + (r() - 0.5) * 0.2 };
}
return points;
}
const SIZES = [1_000, 100_000, 1_000_000] as const;
const WINDOW = 400;
/** Series kinds of the measuring run. A filled mark and a rectangle per point are
different work from a stroked path - the measurements in the capability record
must not hold for lines only. */
type Mode = "lines" | "mixed" | "bars" | "scatter" | "state" | "matrix";
const MODES: readonly Mode[] = ["lines", "mixed", "bars", "scatter", "state", "matrix"];
/** How many points along x (columns for the matrix) a zoom shows. */
const ZOOMED = 400;
export default function Benchmark() {
const [data, setData] = useState(() => load(5, 1_000));
const { Chart, XAxis, YAxis, Line, Bar, Area, Scatter, StateBand, Matrix, setDomain } = useChart(data);
const [perf, setPerf] = useState({
materializeMs: 0,
seriesDrawMs: 0,
points: 0,
});
const [fps, setFps] = useState(null);
const [live, setLive] = useState(false);
const [mode, setMode] = useState("lines");
/* The time the chart's handlers take for one pointer move, the mean of the
last twenty: the hit test of the matrix is in it. */
const [hoverMs, setHoverMs] = useState(null);
const moves = useRef([]);
const moveStart = useRef(0);
const matrix = mode === "matrix";
const columns = matrix ? Math.max(1, Math.ceil(data.length / ROWS)) : data.length;
const course = mode === "lines" || mode === "mixed";
const onPerf = useCallback((p: ChartPerf) => setPerf(p), []);
/* FPS measurement: runs only while the pointer is over the plot area. What is
measured is the actual frame rate of the page - if it drops, the series layer
is drawing along (which it must not do on a hover). */
const measuring = useRef(null);
const startMeasuring = useCallback(() => {
if (measuring.current !== null) return;
let frames = 0;
let start = performance.now();
const step = () => {
frames++;
const now = performance.now();
if (now - start >= 400) {
setFps(Math.round((frames * 1000) / (now - start)));
frames = 0;
start = now;
}
measuring.current = requestAnimationFrame(step);
};
measuring.current = requestAnimationFrame(step);
}, []);
const stopMeasuring = useCallback(() => {
if (measuring.current !== null) cancelAnimationFrame(measuring.current);
measuring.current = null;
setFps(null);
}, []);
useEffect(() => stopMeasuring, [stopMeasuring]);
/* Streaming test: 10 points/s, a travelling window. */
useEffect(() => {
if (!live) return;
const r = random(4711);
const timer = window.setInterval(() => {
setData((previous) => {
const last = previous[previous.length - 1];
if (last === undefined) return previous;
const next: LoadPoint = {
t: last.t + 1,
s1: last.s1 + (r() - 0.5) * 3,
s2: last.s2 + (r() - 0.5) * 2.4,
s3: last.s3 + (r() - 0.5) * 3.6,
st: r() < 0.1 ? Math.floor(r() * 4) : last.st,
c: last.c + 1,
r: last.r,
lvl: last.lvl,
};
const grown = [...previous, next];
return grown.length > WINDOW ? grown.slice(grown.length - WINDOW) : grown;
});
}, 100);
return () => window.clearInterval(timer);
}, [live]);
const loadPoints = (n: number) => {
setLive(false);
setData(load(5, n));
setDomain("x", null);
moves.current = [];
};
const pick = (m: Mode) => {
setMode(m);
setDomain("x", null);
moves.current = [];
};
/* A span in the middle of the data: a small part of the volume is in view. */
const zoom = () => {
const from = Math.floor(columns / 2);
setDomain("x", [from, from + ZOOMED]);
};
return (
<>
>
);
}
```
##### Draw a week of seconds
Two series of 604,800 readings each: every pixel column draws first, lowest, highest and last, so a spike stays; zoom in for every reading.
```tsx
/* Not photographed, like the benchmark above it: it measures the series draw. */
import { useCallback, useState } from "react";
import { Legend, Tooltip, useChart } from "@umriss-ui/charts";
import type { ChartPerf } 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;
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;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
/** Monday, 9 March 2026, 00:00 - the start of last week. */
const LAST_WEEK = at(9, 0);
/** Search returned nothing for some regions (INC-1043): Saturday, 18:30 to 19:55. */
const SEARCH_OUTAGE = [at(14, 18, 30) - LAST_WEEK, at(14, 19, 55) - LAST_WEEK] as const;
/** A service's last week, Monday to Monday, one point every `step`
milliseconds: busy by day, quieter at the weekend. During INC-1043 Search's
95th percentile more than doubles and its errors climb. The hour is counted,
not asked of a Date - a week of seconds is 604,800 points. */
function week(serviceId: string, step: number): 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(2000 + index * 37);
const n = Math.floor((7 * DAY) / step);
const points = new Array(n);
/* The drift wanders at the same pace whatever the step. */
const keep = Math.pow(0.8, step / (5 * MINUTE));
const kick = 0.08 * Math.sqrt(1 - keep * keep) / 0.6;
let drift = 0;
for (let i = 0; i < n; i++) {
const offset = i * step;
const day = Math.floor(offset / DAY);
const hour = (offset % DAY) / HOUR;
const load = (day >= 5 ? 0.85 : 1) * (0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16)));
drift = keep * drift + (r() - 0.5) * kick;
const outage = serviceId === "search" && offset >= SEARCH_OUTAGE[0] && offset < SEARCH_OUTAGE[1];
const p95 = Math.round(service.latencySlo * (0.45 + 0.2 * load + drift) * (outage ? 2.3 : 1) * (1 + r() * 0.08));
points[i] = {
t: LAST_WEEK + offset,
p50: Math.round(p95 * (0.42 + r() * 0.06)),
p95,
errorRate: Math.round((0.05 + r() * 0.12 + (outage ? 3 : 0)) * 100) / 100,
requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
};
}
return points;
}
export default function WeekOfSeconds() {
// Made on first render, not on import: 604,800 readings are no page's
// business until this one is open.
const [data] = useState(() => week("search", 1000));
const { Chart, XAxis, YAxis, Line } = useChart(data);
const [drawMs, setDrawMs] = useState(0);
const onPerf = useCallback((p: ChartPerf) => setDrawMs(p.seriesDrawMs), []);
return (
<>
Points
{(2 * data.length).toLocaleString("en-US")}
Series draw
{drawMs.toFixed(1)} ms
>
);
}
```
#### Keyboard
The keys of [Chart](#/chart/keyboard-chart) apply here.
#### Props on this page
From `ChartProps` — the full table stands on [Chart](#/chart/type-ChartProps).
| Prop | Type | Default | Description |
|---|---|---|---|
| [`height`](#/chart/ChartProps-height) | `number` | fills the frame, at least 300 px | Height in CSS pixels, fixed. Left out, the chart fills its frame - a stretched grid cell, a card the grid stretches, a container of definite height - and never goes below 300 px, which is also its height in a frame without one. A different floor goes through `style`'s `minHeight` ([ADR-0050](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0050-a-chart-fills-its-frame-never-below-its-floor.md)). |
| [`children`](#/chart/ChartProps-children) | `ReactNode` | — | The axes, series and companions of this chart. They draw nothing themselves: each registers its configuration and the scene draws it, and the order in the JSX is the drawing order. |
###### Events
| Prop | Type | Default | Description |
|---|---|---|---|
| [`onPerf`](#/chart/ChartProps-onPerf) | `(perf: `[`ChartPerf`](#/chart/type-ChartPerf)`) => void` | — | Instrumentation for the benchmark page; not needed otherwise. |
###### Accessibility
| Prop | Type | Default | Description |
|---|---|---|---|
| [`ariaLabel`](#/chart/ChartProps-ariaLabel) | `string` | — | Required in DEV through a warning. |
From `XAxisProps` — the full table stands on [Axis](#/axis/type-XAxisProps).
| Prop | Type | Default | Description |
|---|---|---|---|
| [`value`](#/axis/XAxisProps-value) *required* | [`AxisField`](#/axis/type-AxisField)` \| ((d: T, index: number) => `[`AxisValue`](#/axis/type-AxisValue)`)` | — | Where a row lies on this axis: a field of the row, compared by its name, or a function of it, compared by its source text as a series' is (`ValueFunction`) - with the same closure limit. A number is a position, a `Date` an instant, a string a category: every distinct one gets a place of its own, in the order the rows bring them, a tick and its name as the label ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). The type decides, never the content - `"2024"` is a category, `2024` a number - and a row without a value has no place. The axis reads the rows of every series bound to it; where those bring their own `data`, name their row: ` value="hour" />`. |
| [`domain`](#/axis/XAxisProps-domain) | `"nice" \| "data" \| readonly [number, number] \| readonly [Date, Date]` | `"nice"` | `"nice"` widens the data's extent to ticks - to named `ticks` where there are any, on a time axis to whole units of a step of hours or longer -, `"data"` keeps it, a pair is fixed: numbers, on a time axis milliseconds or two `Date`s, on a category axis places. On a `zoomable` axis it is the start: what the axis shows while the chart's view names no span for it. |
| [`time`](#/axis/XAxisProps-time) | `boolean` | — | The values are instants: milliseconds since the epoch, or with `time` said ISO 8601 strings as an API sends them, read by the platform's rule - a date with a time and no offset is local time, a date alone is UTC; a string that is none is not drawn. Rows that hold a `Date` put the axis in time without `time`. Ticks on local boundaries from the minute to the month, labels by level in en-GB with a 24-hour clock - `15:00`, `17 Mar`, `Mar 2026` - and the date on the first tick and on the first of a new day (`17 Mar 00:00`); a day tick there carries the year. The tooltip's x value carries the seconds where the readings lie less than a minute apart. Another language is a `tickFormat`, which is handed the instant in milliseconds. `calendar` implies it. |
| [`zoomable`](#/axis/XAxisProps-zoomable) | `boolean` | `false` | Zoom and pan: Ctrl or ⌘ with the wheel - and a pinch - zoom around the pointer, a drag, a horizontal wheel or Shift with the wheel pan, a double click goes back to `domain`; with the focus on the chart, + and − zoom, Shift with ← or → pans and 0 goes back. The span shown is part of the chart's view, by this axis' `id` (`useChart`'s `initialView`, `onViewChange`, `setView`, `setDomain`). The plain wheel always scrolls the page. |
| [`id`](#/axis/XAxisProps-id) | `string` | `"x"` | Axis id, through which series bind themselves. |
| [`label`](#/axis/XAxisProps-label) | `string` | — | Axis title. |
From `YAxisProps` — the full table stands on [Axis](#/axis/type-YAxisProps).
| Prop | Type | Default | Description |
|---|---|---|---|
| [`domain`](#/axis/YAxisProps-domain) | `"nice" \| "data" \| "visible" \| readonly [number, number]` | `"nice"` | As on the x axis, and `"visible"`: what the series show inside their x axis' domain - a zoomed hour gets the hour's range, not the week's -, widened to ticks as `"nice"` is. Where the x domain is not fixed that is every point. |
| [`position`](#/axis/YAxisProps-position) | `"left" \| "right"` | `"left"` | Which edge the axis stands at. Several y axes per side are stacked outwards, so that extents of clearly different magnitude stay readable. |
| [`id`](#/axis/YAxisProps-id) | `string` | `"y"` | Axis id, through which series bind themselves. |
| [`label`](#/axis/YAxisProps-label) | `string` | — | Axis title. |
From `LineProps` — the full table stands on [Line](#/line/type-LineProps).
| Prop | Type | Default | Description |
|---|---|---|---|
| [`value`](#/line/LineProps-value) *required* | [`Value`](#/line/type-Value)`` | — | Y value - a number field of the row or a function of it; null/undefined/NaN/±Infinity means a gap. |
| [`yAxisId`](#/line/LineProps-yAxisId) | `string` | `"y"` | Binding to a y axis. |
| [`name`](#/line/LineProps-name) | `string` | "Series n", after its place in the chart | The name in legend and tooltip. Without one a warning stands in DEV - an unnamed series is a colour nobody can look up. |
From `AreaProps` — the full table stands on [Area](#/area/type-AreaProps).
| Prop | Type | Default | Description |
|---|---|---|---|
| [`value`](#/area/AreaProps-value) *required* | [`Value`](#/area/type-Value)`` | — | Upper edge - a number field of the row or a function of it; null/undefined/NaN/±Infinity means a gap. |
| [`name`](#/area/AreaProps-name) | `string` | "Series n", after its place in the chart | The name in legend and tooltip. Without one a warning stands in DEV - an unnamed series is a colour nobody can look up. |
From `BarProps` — the full table stands on [Bar](#/bar/type-BarProps).
| Prop | Type | Default | Description |
|---|---|---|---|
| [`value`](#/bar/BarProps-value) *required* | [`Value`](#/bar/type-Value)`` | — | Height - a number field of the row or a function of it; null/undefined/NaN/±Infinity means a gap. |
| [`name`](#/bar/BarProps-name) | `string` | "Series n", after its place in the chart | The name in legend and tooltip. Without one a warning stands in DEV - an unnamed series is a colour nobody can look up. |
From `ScatterProps` — the full table stands on [Scatter](#/scatter/type-ScatterProps).
| Prop | Type | Default | Description |
|---|---|---|---|
| [`value`](#/scatter/ScatterProps-value) *required* | [`Value`](#/scatter/type-Value)`` | — | Y value - a number field of the row or a function of it; null/undefined/NaN/±Infinity means a gap. |
| [`name`](#/scatter/ScatterProps-name) | `string` | "Series n", after its place in the chart | The name in legend and tooltip. Without one a warning stands in DEV - an unnamed series is a colour nobody can look up. |
From `StateBandProps` — the full table stands on [StateBand](#/stateband/type-StateBandProps).
| Prop | Type | Default | Description |
|---|---|---|---|
| [`value`](#/stateband/StateBandProps-value) *required* | [`Value`](#/stateband/type-Value)`` | — | Index of the state in `states` - a number field of the row or a function of it; null/undefined/NaN/±Infinity is a gap - and a gap stays a hole, it gets no colour for "unknown". A colour would be a claim about the interval. |
| [`states`](#/stateband/StateBandProps-states) *required* | `readonly `[`StateEntry`](#/stateband/type-StateEntry)`[]` | — | The closed set of states, in the order of their codes. |
| [`name`](#/stateband/StateBandProps-name) | `string` | "Series n", after its place in the chart | The name in legend and tooltip. Without one a warning stands in DEV - an unnamed series is a colour nobody can look up. |
From `MatrixProps` — the full table stands on [Matrix](#/matrix/type-MatrixProps).
| Prop | Type | Default | Description |
|---|---|---|---|
| [`value`](#/matrix/MatrixProps-value) *required* | [`AxisField`](#/matrix/type-AxisField)` \| ((d: T, index: number) => number \| string \| null \| undefined)` | — | The row on the y axis - a field of the data's row or a function of it. A number is a position, as every series' y is; a string is a category: the y axis then has a named row per distinct one, the first at the top, in the order the data bring them or the y axis' `categories` ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). Never an instant: a `Date` is refused. |
| [`level`](#/matrix/MatrixProps-level) *required* | [`Value`](#/matrix/type-Value)`` | — | The level that decides the colour - a number field of the row or a function of it. A missing level is a hole, not a zero. |
| [`name`](#/matrix/MatrixProps-name) | `string` | "Series n", after its place in the chart | The name in legend and tooltip. Without one a warning stands in DEV - an unnamed series is a colour nobody can look up. |
From `TooltipProps` — the full table stands on [Tooltip](#/tooltip/type-TooltipProps).
| Prop | Type | Default | Description |
|---|---|---|---|
| [`mode`](#/tooltip/TooltipProps-mode) | `"x" \| "nearest"` | `"x"` | "x": every series at the x position; "nearest": only the nearest point. |
From `LegendProps` — the full table stands on [Legend](#/legend/type-LegendProps).
| Prop | Type | Default | Description |
|---|---|---|---|
| [`placement`](#/legend/LegendProps-placement) | `"top" \| "bottom"` | `"top"` | Which side of the plot area the legend stands on. |
## API index
Every name the package exports, in one place.
### API index
Everything `@umriss-ui/charts` exports, with its signature. A component's props stand on its own page.
Demo page: https://romanhaendler.github.io/umriss-ui/charts/api/
#### Components
##### `ControlChart`
[ControlChart](#/controlchart) – Centre line, control limits and the rule violations of a process (an SPC or Shewhart chart). Reach for it to see a process drift before it leaves its specification.
Used on [Scenarios](#/) and [ControlChart](#/controlchart).
##### `DataTable`
The chart's values as a table, on demand: a key - in the legend where
there is one - opens a table of what the chart shows, one column per
visible series, in the tooltip's format. For a reader who wants every value
at once. Place it inside a `Chart`.
```ts
function DataTable(): ReactNode;
```
Used on [Chart](#/chart), [BoxPlot](#/boxplot) and [Tooltip](#/tooltip).
##### `Legend`
[Legend](#/legend) – What the colours mean (a key), and a switch for every series: a click hides it and shows it again, a double click shows only it (a solo).
Used on [Scenarios](#/), [Chart](#/chart), [Axis](#/axis), [View](#/view), [Line](#/line), [Area](#/area), [Bar](#/bar), [Scatter](#/scatter), [BoxPlot](#/boxplot), [StateBand](#/stateband), [LimitLine](#/limitline), [Pareto](#/pareto), [Tooltip](#/tooltip), [Legend](#/legend) and [Benchmark](#/benchmark).
##### `LimitBand`
[LimitLine](#/limitline) – A limit drawn as a band between two values of an axis - a tolerance or a
zone. Otherwise a `LimitLine`.
Used on [Scenarios](#/), [Chart](#/chart), [LimitLine](#/limitline) and [Tooltip](#/tooltip).
##### `LimitLine`
[LimitLine](#/limitline) – The limits a course is read against (a threshold, a reference line): a line above the series, a band beneath them. Reach for it for objectives, budgets and alert levels.
Used on [Scenarios](#/), [Chart](#/chart), [Scatter](#/scatter), [BoxPlot](#/boxplot), [LimitLine](#/limitline), [ControlChart](#/controlchart), [Pareto](#/pareto) and [Tooltip](#/tooltip).
##### `Tooltip`
[Tooltip](#/tooltip) – What a hover reports (a hover card), and the values as a table for a reader who wants them all at once.
Used on [Scenarios](#/), [Installation](#/installation), [Chart](#/chart), [Axis](#/axis), [Zoom and pan](#/zoom-and-pan), [View](#/view), [Line](#/line), [Area](#/area), [Bar](#/bar), [Scatter](#/scatter), [BoxPlot](#/boxplot), [StateBand](#/stateband), [Matrix](#/matrix), [LimitLine](#/limitline), [ControlChart](#/controlchart), [Pareto](#/pareto), [Tooltip](#/tooltip), [Legend](#/legend) and [Benchmark](#/benchmark).
#### Hooks
##### `useChart`
A chart over your rows: the parts it returns are typed at your row, so
that a field name in `value` is checked by the compiler.
- `rows` – The rows every series reads that brings no `data` of its own.
- `options` – The view to start from and the handler of its changes.
- Returns `Chart`, `XAxis`, `YAxis` and every series kind - each keeps its
identity over the renders -, the `view`, its `domains` and `hidden`, and
the setters that change them.
```ts
function useChart(rows: readonly Z[], options?: ChartOptions): ChartParts;
```
Used on [Scenarios](#/), [Installation](#/installation), [Chart](#/chart), [Axis](#/axis), [Zoom and pan](#/zoom-and-pan), [View](#/view), [Line](#/line), [Area](#/area), [Bar](#/bar), [Scatter](#/scatter), [BoxPlot](#/boxplot), [StateBand](#/stateband), [Matrix](#/matrix), [LimitLine](#/limitline), [ControlChart](#/controlchart), [Pareto](#/pareto), [Tooltip](#/tooltip), [Legend](#/legend) and [Benchmark](#/benchmark).
#### Functions
##### `assess`
What the value is, given the limit set. A pure function of the two.
A missing or non-finite value yields `unknown` - and nothing else: the
deviation of a value nobody has is unknown to everybody too.
A bound is violated when the value lies beyond it, not when it lies on it. A
value exactly on the upper bound is in order. That is decided once here
([ADR-0006](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0006-the-limit-model-lives-in-both-packages.md)), so that two building blocks on the same screen do not disagree
about the edge.
Where several bounds are violated the most severe wins; among equally severe
ones the one the value lies furthest beyond. Under a tie in both, the one
named first.
No hysteresis: that would need the previous state, and a function of one
value has none. Exactly that is the property which makes this module safe to
duplicate.
```ts
function assess(value: number | null | undefined, set?: LimitSet): Assessment;
```
##### `bin`
Counts values into bins of equal width.
A value on an edge belongs to the bin that edge starts - `[from, to)` - but
the last bin is closed, so the greatest value of a domain is counted and not
lost. Values that are not finite (`NaN`, ±Infinity, `null`, `undefined`)
are skipped, as a gap is on a chart. Without values there are no bins; with
values there is at least one, even when they are all equal.
```ts
function bin(values: readonly (number | null | undefined)[], options?: BinOptions): Bin[];
function bin(rows: readonly T[], value: (row: T) => number | null | undefined, options?: BinOptions): Bin[];
```
Used on [Bar](#/bar).
##### `breaks`
The breaks in working time that lie within the extent [from, to] - what the
axis must mark, in domain units.
```ts
function breaks(input: CalendarInput, from: number, to: number): number[];
```
##### `calendarFrom`
Calendar from an input - a built calendar is passed straight through, a raw
list is built once and remembered.
```ts
function calendarFrom(input: CalendarInput): WorkingCalendar;
```
##### `controlLimits`
Centre line and control limits. Either given or calculated from exactly the
named reference window - never from the whole series and never from the
visible excerpt. Gaps (NaN) count neither in the centre line nor in the
moving range. A window without values yields NaN limits, not an error: the
chart then draws no line instead of bursting.
```ts
function controlLimits(values: readonly number[], origin: ControlLimitOrigin): ControlLimits;
```
Used on [Scenarios](#/), [LimitLine](#/limitline) and [ControlChart](#/controlchart).
##### `invalidateTheme`
Explicit invalidation: devalue the cache, every living chart reads anew and
redraws.
```ts
function invalidateTheme(): void;
```
##### `localOffset`
The local offset at an instant, as the shift of a grid that is to lie on
local time: `wallClock = offset + k · step`. Taken at one instant, it holds
for a grid up to the next clock change.
```ts
function localOffset(instant: number): number;
```
##### `pareto`
Sorts descending, accumulates, collects the tail and finds the cutoff
crossing.
Ties keep the input order - explicitly through the input index, not in
reliance on a stable sort - so that two reasons with the same count do not
swap places between two frames.
The remainder stands last, independently of its value. Two hundred small
reasons regularly yield a remainder larger than the third real bar; sorted
into place, the chart would claim there is a fault reason called "other"
which is the third biggest problem.
```ts
function pareto(items: readonly ParetoItem[], options?: ParetoSettings): ParetoResult;
```
Used on [Pareto](#/pareto).
##### `removedIntervals`
The removed spans between the working intervals, for the mark in the axis
band. A chart that takes a weekend out and does not say so claims a
continuity it does not have. Before the first and after the last interval
there is nothing to mark: that lies outside the axis' extent.
```ts
function removedIntervals(input: CalendarInput): RemovedSpan[];
```
##### `resolveColours`
Any number of CSS colours - a token, `light-dark(…)`, `var(--…)`, a
literal - resolved to values a canvas can draw, in the scheme that applies
at `root`. The same probe `resolveTheme` uses, for a canvas of a
neighbouring package ([ADR-0022](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0022-the-schedule-is-a-package-that-depends-on-core-and-charts.md)): the schedule draws its subtasks with the
colours of
```ts
function resolveColours(root: Element, colours: Readonly>): Record;
```
##### `subscribeTheme`
Subscribe to theme invalidations - a switch of `color-scheme` on the
document, the system's preference, or `invalidateTheme()`. The scenes of
the charts use it, and so does any canvas that resolved its colours with
[`resolveColours`](#/api/resolveColours). Returns the unsubscribe.
```ts
function subscribeTheme(notify: () => void): () => void;
```
##### `timeStep`
Step for tick candidates in wall clock time, at an intended count. Rounded up
to the next readable step, so that no more ticks arise than wanted. Above the
day steps it continues in whole days.
```ts
function timeStep(span: number, count: number): number;
```
##### `toWallClock`
Working time → wall clock time; outside [0, total] there is none.
At a seam - the end of an interval which is at the same time the beginning of
the next - the beginning of the next comes out: from there time counts again,
and a pointer aiming there aims at the coming interval. Only the end of the last
interval has no next and comes back as itself.
```ts
function toWallClock(workingTime: number, input: CalendarInput): number;
```
##### `toWorkingTimeClamped`
Wall clock time → working time, but without NaN: whatever lies in removed
time falls onto the seam that this span collapses onto anyway; whatever lies
before the first or after the last interval onto 0 or onto the whole
working time respectively.
The materialised x values must stay ascending: binary search assumes it, and
a search over NaN silently lands on the wrong point. The clamped value is a POSITION, not a statement: the point
itself is carried as a gap and is never drawn and never hit.
```ts
function toWorkingTimeClamped(wallClock: number, input: CalendarInput): number;
```
##### `verdictWeight`
How heavily a verdict weighs, as a number - for sorting and comparing, as
when a matrix is to bring its worst cell to the top. It stands here so that
the order is not reinvented at every call site.
```ts
function verdictWeight(verdict: Verdict): number;
```
##### `violatedIndices`
All violated indices, ascending and free of duplicates - the scatter drawn
beside the line.
```ts
function violatedIndices(found: readonly Violation[]): number[];
```
##### `violations`
The rules switched on, each with its indices. Rules without hits do not
appear in the list. The indices are data, not merely marks: they can be
listed beside the chart as well as marked on it.
```ts
function violations(values: readonly number[], limits: ControlLimits, options?: Partial): Violation[];
```
Used on [Scenarios](#/) and [ControlChart](#/controlchart).
##### `workingCalendar`
Builds a calendar: sorts, merges overlapping and abutting intervals and
throws away empty, reversed and non-finite ones. Merging is not a courtesy
but a precondition: only over separated intervals is the reverse mapping
unambiguous.
```ts
function workingCalendar(intervals: readonly WorkingInterval[]): WorkingCalendar;
```
Used on [Axis](#/axis).
##### `workingTimeTicks`
Ticks for a working time axis, from an extent in WALL CLOCK TIME.
The candidates arise in wall clock time on multiples of the step - hours,
interval changes, days - whatever lies in removed time falls away, the rest is
mapped. Never the other way round: ticks generated in working time land in
the middle of an interval and yield an axis labelled 14.5 and 29.0 which nobody
can read.
The offset shifts the grid; with it a local time hits its own midnight without
this module having to know about time zones.
If two candidates fall on the same working time - the end of one interval and
the beginning of the next - the earlier one stays: two labels on the same
pixel would be one too many.
Both times come back, because the axis needs both: the working time to
place the tick, the wall clock time to label it.
```ts
function workingTimeTicks(input: CalendarInput, from: number, to: number, step: number, offset?: number): WorkingTimeTick[];
```
##### `zones`
The faintly drawn zone lines. Rule 4 is about the two-sigma zone, and a
reader cannot check it against a chart that does not show it.
```ts
function zones(limits: ControlLimits, multiples?: readonly number[]): Zone[];
```
Used on [ControlChart](#/controlchart).
#### Constants
##### `DAY`
One day of 24 hours in milliseconds; across a clock change a calendar day
is an hour longer or shorter.
```ts
const DAY: number;
```
Used on [Scenarios](#/), [Chart](#/chart), [Axis](#/axis), [Zoom and pan](#/zoom-and-pan), [StateBand](#/stateband), [Tooltip](#/tooltip) and [Benchmark](#/benchmark).
##### `DEFAULT_CHARTS_WORDING`
The instance shipped by default. English; German is
`GERMAN_CHARTS_WORDING` in `@umriss-ui/charts/wording/de`. A chart takes
its own through `wording`.
Every entry, with its value, stands in the table [Charts wording](https://romanhaendler.github.io/umriss-ui/core/language/#charts-wording) on core's Language page.
```ts
const DEFAULT_CHARTS_WORDING: ChartsWording;
```
##### `DEFAULT_GRADIENT`
Default gradient: a sequential set, light to dark, so that it stays readable
as an order of lightness even without colour. It is a default, not a
restriction - whoever has a scale of their own names it.
```ts
const DEFAULT_GRADIENT: readonly string[];
```
Used on [Matrix](#/matrix).
##### `HOUR`
One hour in milliseconds.
```ts
const HOUR: number;
```
Used on [Chart](#/chart), [Axis](#/axis), [Zoom and pan](#/zoom-and-pan), [Area](#/area), [BoxPlot](#/boxplot), [Tooltip](#/tooltip) and [Benchmark](#/benchmark).
##### `LinearScale`
The affine mapping from a domain onto a pixel range, and back; the scale
every axis of the library uses.
```ts
class LinearScale implements Scale {
readonly domain: readonly [number, number];
readonly range: readonly [number, number];
/** Pre-computed line equation px = v·m + b - the draw loop uses it directly. */
readonly m: number;
readonly b: number;
constructor(domain: readonly [number, number], range: readonly [number, number]);
toPx(v: number): number;
fromPx(px: number): number;
ticks(n: number): number[];
}
```
##### `MINUTE`
One minute in milliseconds.
```ts
const MINUTE: number;
```
Used on [Scenarios](#/), [Installation](#/installation), [Chart](#/chart), [Axis](#/axis), [Zoom and pan](#/zoom-and-pan), [View](#/view), [Line](#/line), [Area](#/area), [Scatter](#/scatter), [BoxPlot](#/boxplot), [StateBand](#/stateband), [LimitLine](#/limitline), [Tooltip](#/tooltip), [Legend](#/legend) and [Benchmark](#/benchmark).
##### `SECOND`
One second in milliseconds, the unit of a time axis.
```ts
const SECOND = 1000;
```
#### Types
##### `AreaProps`
Its table stands on [Area](#/area/type-AreaProps).
##### `AreaSeriesConfig`
An `Area` as the chart holds it once registered.
| Prop | Type | Default | Description |
|---|---|---|---|
| `kind` *required* | `"area"` | — | |
| `baseline` | [`ValueFunction`](#type-ValueFunction)`` | — | Lower edge; without a value the fixed baseline 0. |
| `fillOpacity` *required* | `number` | — | Opacity of the fill; the outline stays fully opaque. |
| `strokeWidth` *required* | `number` | — | |
| `dash` | `readonly number[]` | — | Dash pattern of the outline. |
| `accessor` *required* | [`ValueFunction`](#type-ValueFunction)`` | — | Y value; null/undefined/NaN/±Infinity means a gap. From `SeriesBase`. |
| `data` | `readonly T[]` | — | Series-own data; overrides the container data. From `SeriesBase`. |
| `xAxisId` *required* | `string` | — | From `SeriesBase`. |
| `yAxisId` *required* | `string` | — | From `SeriesBase`. |
| `name` | `string` | — | From `SeriesBase`. |
| `format` | `(value: number) => string` | — | The value in the tooltip; before the y axis' `tickFormat`. Compared by its source text, as an accessor is. From `SeriesBase`. |
| `color` | `string` | — | From `SeriesBase`. |
| `tone` | `"ok" \| "warning" \| "alarm"` | — | A role instead of a colour value: the theme resolves it, in both themes. For series that mean something rather than just being the next one - the violating points of a control chart, say. `color` beats it. From `SeriesBase`. |
| `stack` | `string` | — | The stack this series stands in: series with the same id, on the same x and y axis, stack in registration order. From `Stackable`. |
| `normalize` | `boolean` | — | On any member, the whole stack sums to 100 at every x. From `Stackable`. |
##### `Assessment`
The verdict and what led to it.
| Prop | Type | Default | Description |
|---|---|---|---|
| `verdict` *required* | [`Verdict`](#type-Verdict) `"ok" \| "unknown" \| "warning" \| "alarm"` | — | |
| `limit` | [`Limit`](#type-Limit) | — | The violated limit - the same object the caller passed in, so that it can be recognised against the caller's own data. Absent where none is violated. |
| `excess` | `number` | — | The amount beyond the bound, always positive. Absent where none is violated. |
| `deviation` | `number` | — | The signed distance from the target. Absent where none is set - a missing target yields no deviation of zero. Two fields and not one: excess and deviation are two numbers, and a single field meaning the one thing when a bound is violated and the other otherwise reads well and is wrong at three in the morning. |
##### `AxisConfig`
An `XAxis` or `YAxis` as the chart holds it once registered.
| Prop | Type | Default | Description |
|---|---|---|---|
| `id` *required* | `string` | — | |
| `orientation` *required* | [`AxisOrientation`](#type-AxisOrientation) `"x" \| "y"` | — | |
| `position` *required* | [`AxisPosition`](#type-AxisPosition) `"bottom" \| "top" \| "left" \| "right"` | — | |
| `accessor` | `(d: T, index: number) => `[`AxisValue`](#type-AxisValue) | — | Where a row lies along the axis; an x axis' only - along y the series place their rows. A number is a position or an instant, a string a category, a `Date` an instant ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). |
| `label` | `string` | — | |
| `tickCount` | `number` | — | |
| `tickFormat` | `(v: number) => string` | — | |
| `domain` *required* | `"nice" \| "data" \| "visible" \| readonly [number, number]` | — | "visible" is a y axis' only: the extent of what its series show inside their x axis' domain. |
| `grid` | `boolean` | only the first registered axis per orientation | Whether the axis draws its grid lines. |
| `ticks` | `readonly number[]` | — | Fixed tick values instead of the 1-2-5 algorithm, on a numeric axis: an hour axis' named hours, a lane grid. |
| `categories` | `readonly string[]` | — | The categories of the axis, in their order: the list is the order and the extent, and makes the axis a category axis ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). |
| `categoryLabel` | `(category: string) => string` | — | What is written for a category, wherever it is written. |
| `time` | `boolean` | — | The values are instants - milliseconds, and where this is said ISO 8601 strings as well: ticks on local boundaries, labels by level. A calendar implies it, and so do rows that bring a `Date` ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). |
| `calendar` | `readonly `[`WorkingInterval`](#type-WorkingInterval)`[]` | — | Working calendar: the intervals in which time counts. With it, materialisation maps wall clock time onto working time before the scale calculates - the scale stays affine ([ADR-0001](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0001-affine-scale-contract.md)). |
| `alignTicks` | `boolean` | — | y axes only: this axis' ticks on the first y axis' grid. |
| `zoomable` | `boolean` | — | x axes only: wheel, drag, pinch, double click and the zoom keys move the span the chart's view holds for this axis ([ADR-0047](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0047-a-component-holds-its-own-view.md)). |
| `zoomLimits` | [`ZoomLimits`](#type-ZoomLimits) | at most the data's extent, at least three data steps | x axes only: the narrowest and the widest span zoom may reach. |
##### `AxisField`
The fields of a row an axis can read - or, with `V`, those that hold only
some of what an axis reads: a matrix' row is a position or a category,
never an instant.
```ts
type AxisField = NoInfer<
{
[K in keyof T & string]-?: T[K] extends V ? K : never;
}[keyof T & string]
>;
```
##### `AxisOrientation`
Whether an axis or a limit runs along x or along y.
```ts
type AxisOrientation = "x" | "y";
```
##### `AxisPosition`
The edge of the plot an axis stands at: `bottom` or `top` for x, `left` or
`right` for y.
```ts
type AxisPosition = "bottom" | "top" | "left" | "right";
```
##### `AxisSpans`
The span each zoomable x axis shows, by axis `id`, in the axis' units.
```ts
type AxisSpans = Readonly>;
```
##### `AxisValue`
What an axis reads from a row ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)): a number - a position, or an
instant on a time axis -, a string, which is a category, or a `Date`,
which is an instant. The type of the value decides what kind the axis is,
never its content: `"2024"` is a category, `2024` a number. On an axis
that says `time` a string is an ISO 8601 instant. null/undefined is no
place.
```ts
type AxisValue = number | string | Date | null | undefined;
```
##### `BarProps`
Its table stands on [Bar](#/bar/type-BarProps).
##### `BarSeriesConfig`
A `Bar` as the chart holds it once registered.
| Prop | Type | Default | Description |
|---|---|---|---|
| `kind` *required* | `"bar"` | — | |
| `barWidth` *required* | `number` | — | Width as a fraction of the step; the group shares this fraction. |
| `accessor` *required* | [`ValueFunction`](#type-ValueFunction)`` | — | Y value; null/undefined/NaN/±Infinity means a gap. From `SeriesBase`. |
| `data` | `readonly T[]` | — | Series-own data; overrides the container data. From `SeriesBase`. |
| `xAxisId` *required* | `string` | — | From `SeriesBase`. |
| `yAxisId` *required* | `string` | — | From `SeriesBase`. |
| `name` | `string` | — | From `SeriesBase`. |
| `format` | `(value: number) => string` | — | The value in the tooltip; before the y axis' `tickFormat`. Compared by its source text, as an accessor is. From `SeriesBase`. |
| `color` | `string` | — | From `SeriesBase`. |
| `tone` | `"ok" \| "warning" \| "alarm"` | — | A role instead of a colour value: the theme resolves it, in both themes. For series that mean something rather than just being the next one - the violating points of a control chart, say. `color` beats it. From `SeriesBase`. |
| `stack` | `string` | — | The stack this series stands in: series with the same id, on the same x and y axis, stack in registration order. From `Stackable`. |
| `normalize` | `boolean` | — | On any member, the whole stack sums to 100 at every x. From `Stackable`. |
##### `Bin`
A bin, the way it is drawn.
| Prop | Type | Default | Description |
|---|---|---|---|
| `from` *required* | `number` | — | Lower edge; the bin holds it. |
| `to` *required* | `number` | — | Upper edge; the bin holds it only if it is the last one. |
| `x` *required* | `number` | — | The middle, `(from + to) / 2` - the bar's x. |
| `count` *required* | `number` | — | How many values fall in it. |
##### `BinOptions`
The settings of `bin`.
| Prop | Type | Default | Description |
|---|---|---|---|
| `width` | `number` | — | The width of a bin. Wins over `count`. Without a `domain` the edges are multiples of it. Shown in: [Count readings into a histogram (Bar)](#/bar/histogram) |
| `count` | `number` | `10` | About how many bins: the width becomes the next 1-2-5 step, so the number comes out near it and not exactly - with a `domain`, exactly. |
| `domain` | `readonly [number, number]` | — | The span the bins cover, the first edge on its start and the last on its end. Values outside it are left out, not clamped into the end bins. |
##### `BoxChannels`
The named channels of a box: one number per point, NaN where the median
is a gap; and its outliers ([ADR-0040](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0040-a-box-carries-its-outliers.md)).
| Prop | Type | Default | Description |
|---|---|---|---|
| `outliers` *required* | `Float64Array \| null` | — | Every box's outliers, flat, box after box; null without `outliers`. |
| `outlierOffsets` *required* | `Uint32Array \| null` | — | Where each box's outliers begin, and one past the last: box i's stand from outlierOffsets[i] to outlierOffsets[i + 1]. null without `outliers`. |
| `mean` *required* | `Float64Array \| null` | — | The optional numbers, each null where not given. |
| `notchLower` *required* | `Float64Array \| null` | — | |
| `notchUpper` *required* | `Float64Array \| null` | — | |
| `count` *required* | `Float64Array \| null` | — | |
| `lowerQuartile` *required* | `Float64Array` | — | From `BoxNumbers`. |
| `upperQuartile` *required* | `Float64Array` | — | From `BoxNumbers`. |
| `lowerWhisker` *required* | `Float64Array` | — | From `BoxNumbers`. |
| `upperWhisker` *required* | `Float64Array` | — | From `BoxNumbers`. |
##### `BoxExtras`
The optional numbers of a box by name.
| Prop | Type | Default | Description |
|---|---|---|---|
| `mean` *required* | `V` | — | |
| `notchLower` *required* | `V` | — | |
| `notchUpper` *required* | `V` | — | |
| `count` *required* | `V` | — | |
##### `BoxNumbers`
A box's further numbers beside its median, by name.
| Prop | Type | Default | Description |
|---|---|---|---|
| `lowerQuartile` *required* | `V` | — | |
| `upperQuartile` *required* | `V` | — | |
| `lowerWhisker` *required* | `V` | — | |
| `upperWhisker` *required* | `V` | — | |
##### `BoxPlotProps`
Its table stands on [BoxPlot](#/boxplot/type-BoxPlotProps).
##### `BoxSeriesConfig`
A box plot (CONTEXT.md: Box). The base accessor is the median; the four
further numbers are the caller's, computed by whatever method the caller
chose - the library draws them and computes none.
| Prop | Type | Default | Description |
|---|---|---|---|
| `kind` *required* | `"box"` | — | |
| `lowerQuartile` *required* | [`ValueFunction`](#type-ValueFunction)`` | — | |
| `upperQuartile` *required* | [`ValueFunction`](#type-ValueFunction)`` | — | |
| `lowerWhisker` *required* | [`ValueFunction`](#type-ValueFunction)`` | — | |
| `upperWhisker` *required* | [`ValueFunction`](#type-ValueFunction)`` | — | |
| `outliers` | [`ListFunction`](#type-ListFunction)`` | — | The values beyond the whiskers, per box ([ADR-0040](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0040-a-box-carries-its-outliers.md)). |
| `mean` | [`ValueFunction`](#type-ValueFunction)`` | — | |
| `notchLower` | [`ValueFunction`](#type-ValueFunction)`` | — | Both bounds or neither (CONTEXT.md: Notch). |
| `notchUpper` | [`ValueFunction`](#type-ValueFunction)`` | — | |
| `count` | [`ValueFunction`](#type-ValueFunction)`` | — | How many values stand behind the box; no y value. |
| `boxWidth` *required* | `number` | — | Width as a fraction of the step; boxes and bars on one x axis share it. |
| `accessor` *required* | [`ValueFunction`](#type-ValueFunction)`` | — | Y value; null/undefined/NaN/±Infinity means a gap. From `SeriesBase`. |
| `data` | `readonly T[]` | — | Series-own data; overrides the container data. From `SeriesBase`. |
| `xAxisId` *required* | `string` | — | From `SeriesBase`. |
| `yAxisId` *required* | `string` | — | From `SeriesBase`. |
| `name` | `string` | — | From `SeriesBase`. |
| `format` | `(value: number) => string` | — | The value in the tooltip; before the y axis' `tickFormat`. Compared by its source text, as an accessor is. From `SeriesBase`. |
| `color` | `string` | — | From `SeriesBase`. |
| `tone` | `"ok" \| "warning" \| "alarm"` | — | A role instead of a colour value: the theme resolves it, in both themes. For series that mean something rather than just being the next one - the violating points of a control chart, say. `color` beats it. From `SeriesBase`. |
##### `CalendarInput`
Every function of this module takes either: the raw list as it stands on the
axis, or the calendar built from it.
```ts
type CalendarInput = WorkingCalendar | readonly WorkingInterval[];
```
##### `ChartOptions`
Its table stands on [Zoom and pan](#/zoom-and-pan/type-ChartOptions).
##### `ChartParts`
What `useChart` hands back: the chart and its parts, typed at the row `Z`,
its view and the setters that change it. A series given `data` of its own
is typed by that `data` instead.
| Prop | Type | Default | Description |
|---|---|---|---|
| `Chart` *required* | `(props: `[`ChartProps`](#/chart/type-ChartProps)`) => ReactNode` | — | The chart over the hook's rows. One per `useChart`: it holds the view, and a second chart is a second call. Shown in: [Draw a first chart (Installation)](#/installation/first-chart), [Size a chart by its container (Installation)](#/installation/in-its-container), [Mix series kinds (Chart)](#/chart/mixed) and 74 more |
| `XAxis` *required* | `(props: `[`XAxisProps`](#/axis/type-XAxisProps)`) => null` | — | An x axis reads the rows of every series bound to it: the hook's, or with `>` those the series bring as their own `data`. Shown in: [Draw a first chart (Installation)](#/installation/first-chart), [Size a chart by its container (Installation)](#/installation/in-its-container), [Mix series kinds (Chart)](#/chart/mixed) and 74 more |
| `YAxis` *required* | `(props: `[`YAxisProps`](#/axis/type-YAxisProps)`) => null` | — | Shown in: [Draw a first chart (Installation)](#/installation/first-chart), [Size a chart by its container (Installation)](#/installation/in-its-container), [Mix series kinds (Chart)](#/chart/mixed) and 74 more |
| `Line` *required* | `(props: `[`LineProps`](#/line/type-LineProps)`) => null` | — | Shown in: [Draw a first chart (Installation)](#/installation/first-chart), [Size a chart by its container (Installation)](#/installation/in-its-container), [Mix series kinds (Chart)](#/chart/mixed) and 47 more |
| `Area` *required* | `(props: `[`AreaProps`](#/area/type-AreaProps)`) => null` | — | Shown in: [Mix series kinds (Chart)](#/chart/mixed), [Mark every kind (Chart)](#/chart/marks-on-every-kind), [Bind every kind of series to a second axis (Axis)](#/axis/every-kind-on-a-second-axis) and 5 more |
| `Bar` *required* | `(props: `[`BarProps`](#/bar/type-BarProps)`) => null` | — | Shown in: [Mix series kinds (Chart)](#/chart/mixed), [Mark every kind (Chart)](#/chart/marks-on-every-kind), [Bind every kind of series to a second axis (Axis)](#/axis/every-kind-on-a-second-axis) and 13 more |
| `Scatter` *required* | `(props: `[`ScatterProps`](#/scatter/type-ScatterProps)`) => null` | — | Shown in: [Mix series kinds (Chart)](#/chart/mixed), [Mark every kind (Chart)](#/chart/marks-on-every-kind), [Bind every kind of series to a second axis (Axis)](#/axis/every-kind-on-a-second-axis) and 3 more |
| `StateBand` *required* | `(props: `[`StateBandProps`](#/stateband/type-StateBandProps)` & `[`OneLane`](#type-OneLane)`) => null` | — | Shown in: [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells), [One vehicle through the day (StateBand)](#/stateband/one-vehicle), [Lay a state under a course (StateBand)](#/stateband/under-a-course) and 4 more |
| `Matrix` *required* | `(props: `[`MatrixProps`](#/matrix/type-MatrixProps)`) => null` | — | Shown in: [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells), [Colour cells by their limits (Matrix)](#/matrix/by-limits), [Colour cells along a gradient (Matrix)](#/matrix/gradient) and 1 more |
| `BoxPlot` *required* | `(props: `[`BoxPlotProps`](#/boxplot/type-BoxPlotProps)`) => null` | — | Shown in: [One box per service (BoxPlot)](#/boxplot/minimal), [A box per hour on a time axis (BoxPlot)](#/boxplot/over-time), [With outliers (BoxPlot)](#/boxplot/outliers) and 4 more |
| `view` *required* | [`ChartView`](#/view/type-ChartView) | — | How the reader is looking at the chart. Shown in: [Keep and restore a view (View)](#/view/keep-and-restore) |
| `domains` *required* | [`AxisSpans`](#type-AxisSpans) | — | The span each zoomable x axis shows, by axis `id`; an axis absent shows its own `domain`. |
| `setView` *required* | `(view: `[`ChartView`](#/view/type-ChartView)`) => void` | — | Goes to a whole view; what it leaves out is shown whole again. Not reported through `onViewChange`: two charts kept in step by `onViewChange: (v) => other.setView(v)` in both directions do not answer each other. A view with the content already shown changes nothing. Shown in: [Read it by keyboard and screen reader (Chart)](#/chart/keyboard-and-screen-reader), [Keep charts in step (Zoom and pan)](#/zoom-and-pan/cursor-sync), [Keep and restore a view (View)](#/view/keep-and-restore) |
| `setDomain` *required* | `(axisId: string, span: readonly [number, number] \| null) => void` | — | Puts a span in view on a zoomable x axis - a lone one is `"x"`; `null` shows the axis' own `domain` again. Shown in: [Measure a million points (Benchmark)](#/benchmark/benchmark) |
| `hidden` *required* | `readonly string[]` | — | The hidden series, by `name`. |
| `toggleSeries` *required* | `(name: string) => void` | — | Hides the series `name`, or shows it where it is hidden - as a click on its legend entry does. Hiding the last series visible shows all. |
| `showOnly` *required* | `(name: string) => void` | — | Hides every series but `name`; all are shown where none carries it. Shown in: [Hide a series from the legend (Legend)](#/legend/hide-a-series) |
| `showAllSeries` *required* | `() => void` | — | Shows every series again. Shown in: [Hide a series from the legend (Legend)](#/legend/hide-a-series) |
Used on [Chart](#/chart).
##### `ChartPerf`
Measurements of the instrumentation for the benchmark page.
| Prop | Type | Default | Description |
|---|---|---|---|
| `materializeMs` *required* | `number` | — | Shown in: [Measure a million points (Benchmark)](#/benchmark/benchmark) |
| `seriesDrawMs` *required* | `number` | — | Shown in: [Measure a million points (Benchmark)](#/benchmark/benchmark), [Draw a week of seconds (Benchmark)](#/benchmark/a-week-of-seconds) |
| `points` *required* | `number` | — | Shown in: [Measure a million points (Benchmark)](#/benchmark/benchmark) |
Used on [Benchmark](#/benchmark).
##### `ChartProps`
Its table stands on [Chart](#/chart/type-ChartProps).
##### `ChartsWording`
Everything the charts put out as text.
| Prop | Type | Default | Description |
|---|---|---|---|
| `locale` *required* | `string` | — | The BCP 47 tag every number and every date or time label of the chart is formatted in (`en-GB`, `de-DE`). The one input for the locale: the browser's language is not read. The clock stays 24-hour in both. |
| `roleDescription` *required* | `string` | — | What a screen reader calls the plot area that takes the keys (`aria-roledescription`). |
| `empty` *required* | `string` | — | Shown in the plot area when no visible series has a point to show. |
| `seriesList` *required* | `(names: readonly string[]) => string` | — | The summary's visible series, by name. |
| `visibleRange` *required* | `(from: string, to: string) => string` | — | The summary's visible stretch of the x axis, both ends formatted. |
| `seriesExtent` *required* | `(name: string, min: string, max: string) => string` | — | The summary's lowest and highest value of one series in that stretch. |
| `limitList` *required* | `(limits: readonly { name: string; value: string }[]) => string` | — | The summary's labelled limits, each with its value in its axis' format (a band's two edges as one value). |
| `readout` *required* | `(x: string, rows: readonly { name: string; value: string; x: string }[]) => string` | — | What is read after a key: the position, then each series with its value (and its own x where it stands on another x axis), the emphasised one first. |
| `stackTotal` *required* | `string` | — | The last row of a stack in the tooltip and the readout: the sum of its members at the point. |
| `percent` *required* | `(value: string) => string` | — | A share in a normalised stack, its number already formatted. |
| `walkHelp` *required* | `string` | — | The keys that walk the chart. |
| `selectHelp` *required* | `string` | — | The key that reports the Active point - only where `onSelect` is given. |
| `zoomHelp` *required* | `string` | — | The keys that zoom and pan it - only where the chart can be zoomed. |
| `legendHelp` *required* | `string` | — | The keys of the legend's entries - only where it has one to press. |
| `allShown` *required* | `string` | — | Read out where hiding would leave nothing to see, and every series is shown instead. |
| `showAll` *required* | `string` | — | The control over a zoomed plot that brings every zoomed axis back to its own domain (component-view 04). |
| `showData` *required* | `string` | — | The data table's disclosure key, closed and open. |
| `hideData` *required* | `string` | — | |
| `positionColumn` *required* | `string` | — | The heading of the table's x column where its axis has no `label`, and of a matrix' row column where its y axis has none. |
| `rowColumn` *required* | `string` | — | |
| `tableCaption` *required* | `(from: string, to: string) => string` | — | The table's caption: the visible stretch it lists, both ends formatted. |
| `upperWhisker` *required* | `string` | — | A box's numbers, top to bottom as drawn: the tooltip's rows, the readout's parts and the data table's columns (box-plot B9). |
| `upperQuartile` *required* | `string` | — | |
| `median` *required* | `string` | — | |
| `lowerQuartile` *required* | `string` | — | |
| `lowerWhisker` *required* | `string` | — | |
| `outliers` *required* | `string` | — | A box's outliers: the row's name, and the row's value - their count and the first few values, the rest counted (box-plot B9). |
| `mean` *required* | `string` | — | A box's optional readings: its mean, its notch's two bounds as one row, and how many values stand behind it. |
| `notch` *required* | `string` | — | |
| `count` *required* | `string` | — | |
| `outlierList` *required* | `(values: readonly string[], more: number) => string` | — | |
| `downsampled` *required* | `(readings: number) => string` | — | Added to the caption where the table shows the downsampled course: how many readings its rows stand for. |
##### `ChartView`
Its table stands on [View](#/view/type-ChartView).
Used on [Zoom and pan](#/zoom-and-pan) and [View](#/view).
##### `ControlChartProps`
Its table stands on [ControlChart](#/controlchart/type-ControlChartProps).
##### `ControlLimitOrigin`
Where the limits come from. There are exactly these two possibilities.
```ts
type ControlLimitOrigin =
/** From a baseline the caller has fixed. Taken as it is - even where the
series would yield something else out of itself. */
| { readonly kind: "given"; readonly center: number; readonly sigma: number }
/** From exactly this index range of the series, half-open [from, to). */
| { readonly kind: "referenceWindow"; readonly from: number; readonly to: number };
```
##### `ControlLimits`
Centre line, estimated spread and the control limits at three sigma.
| Prop | Type | Default | Description |
|---|---|---|---|
| `center` *required* | `number` | — | Shown in: [Set specification and control limits side by side (LimitLine)](#/limitline/specification-and-control) |
| `sigma` *required* | `number` | — | Estimated spread of a single value, not the distance to the limit. |
| `upper` *required* | `number` | — | Shown in: [Set specification and control limits side by side (LimitLine)](#/limitline/specification-and-control) |
| `lower` *required* | `number` | — | Shown in: [Set specification and control limits side by side (LimitLine)](#/limitline/specification-and-control) |
##### `DataTableGroup`
One table of the data table: the series of one x axis, or one matrix. Every
cell is text already, in the format the tooltip writes.
| Prop | Type | Default | Description |
|---|---|---|---|
| `key` *required* | `string` | — | |
| `caption` *required* | `string` | — | |
| `columns` *required* | `readonly string[]` | — | |
| `rows` *required* | `readonly (readonly string[])[]` | — | The first cell of each row is its x - the row's heading. |
##### `LegendConfig`
A `Legend` as the chart holds it once registered.
| Prop | Type | Default | Description |
|---|---|---|---|
| `placement` *required* | `"top" \| "bottom"` | — | |
##### `LegendProps`
Its table stands on [Legend](#/legend/type-LegendProps).
##### `Limit`
A bound: a value, a side and a severity. No more - a tolerance band is
derived and never an input (two limits of equal severity on opposite sides
enclose one). That keeps the one-sided case, which is the more common one,
free of a `null` on the other edge.
| Prop | Type | Default | Description |
|---|---|---|---|
| `value` *required* | `number` | — | Shown in: [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells), [Colour cells by their limits (Matrix)](#/matrix/by-limits) |
| `side` *required* | [`Side`](#type-Side) `"upper" \| "lower"` | — | Shown in: [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells), [Colour cells by their limits (Matrix)](#/matrix/by-limits) |
| `severity` *required* | [`Severity`](#type-Severity) `"warning" \| "alarm"` | — | Shown in: [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells), [Colour cells by their limits (Matrix)](#/matrix/by-limits) |
##### `LimitBandConfig`
A `LimitBand` as the chart holds it once registered: the range from `from`
to `to`.
| Prop | Type | Default | Description |
|---|---|---|---|
| `kind` *required* | `"band"` | — | |
| `from` *required* | `number` | — | |
| `to` *required* | `number` | — | |
| `axisId` | `string` | — | Axis on which the value lies; without one, the first axis of its orientation. From `LimitBase`. |
| `orientation` *required* | [`AxisOrientation`](#type-AxisOrientation) `"x" \| "y"` | — | From `LimitBase`. |
| `severity` *required* | [`Severity`](#type-Severity) `"warning" \| "alarm"` | — | From `LimitBase`. |
| `label` | `string` | — | From `LimitBase`. |
| `color` | `string` | — | A colour of its own; without one, that of the role or of the severity out of the theme. From `LimitBase`. |
| `inExtent` *required* | `boolean` | — | Pulls the extent of the axis along. Default true: an excluded limit yields a chart that looks right and does not show its most important line - and that nobody notices. A squashed plot is noticed at once. From `LimitBase`. |
###### Accessibility
| Prop | Type | Default | Description |
|---|---|---|---|
| `role` *required* | [`LimitRole`](#type-LimitRole) `"specification" \| "control" \| "zone"` | — | From `LimitBase`. |
##### `LimitBandProps`
Its table stands on [LimitLine](#/limitline/type-LimitBandProps).
##### `LimitConfig`
Any registered limit, a line or a band, told apart by `kind`.
```ts
type LimitConfig = LimitLineConfig | LimitBandConfig;
```
##### `LimitLineConfig`
A `LimitLine` as the chart holds it once registered.
| Prop | Type | Default | Description |
|---|---|---|---|
| `kind` *required* | `"line"` | — | |
| `value` *required* | `number` | — | |
| `axisId` | `string` | — | Axis on which the value lies; without one, the first axis of its orientation. From `LimitBase`. |
| `orientation` *required* | [`AxisOrientation`](#type-AxisOrientation) `"x" \| "y"` | — | From `LimitBase`. |
| `severity` *required* | [`Severity`](#type-Severity) `"warning" \| "alarm"` | — | From `LimitBase`. |
| `label` | `string` | — | From `LimitBase`. |
| `color` | `string` | — | A colour of its own; without one, that of the role or of the severity out of the theme. From `LimitBase`. |
| `inExtent` *required* | `boolean` | — | Pulls the extent of the axis along. Default true: an excluded limit yields a chart that looks right and does not show its most important line - and that nobody notices. A squashed plot is noticed at once. From `LimitBase`. |
###### Accessibility
| Prop | Type | Default | Description |
|---|---|---|---|
| `role` *required* | [`LimitRole`](#type-LimitRole) `"specification" \| "control" \| "zone"` | — | From `LimitBase`. |
##### `LimitLineProps`
Its table stands on [LimitLine](#/limitline/type-LimitLineProps).
##### `LimitRole`
What a limit stands for. The difference is not cosmetic: a specification
limit is chosen (what the customer assumes), a control limit is calculated
(what the process normally does). A process can be in control and outside the
specification and the other way round, and each of the four cases calls for
something different - which is why the two lines have to look
distinguishable ([ADR-0008](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0008-control-limits-are-not-specification-limits.md)).
```ts
type LimitRole = "specification" | "control" | "zone";
```
##### `LimitSet`
The set a value is read against: its bounds and, where there is one, its
target.
The target is not a limit. It is missed, never violated, and it is never the
edge of a region. Conflating the two is how a tile turns red for being above
target.
| Prop | Type | Default | Description |
|---|---|---|---|
| `limits` | `readonly `[`Limit`](#type-Limit)`[]` | — | In any order; none, one or several. Shown in: [Mark bands, limits and cells (Chart)](#/chart/bands-limits-and-cells), [Colour cells by their limits (Matrix)](#/matrix/by-limits) |
| `target` | `number` | — | |
Used on [Scenarios](#/).
##### `LineProps`
Its table stands on [Line](#/line/type-LineProps).
##### `LineSeriesConfig`
A `Line` as the chart holds it once registered.
| Prop | Type | Default | Description |
|---|---|---|---|
| `kind` *required* | `"line"` | — | |
| `strokeWidth` *required* | `number` | — | |
| `dash` | `readonly number[]` | — | |
| `markers` *required* | `"auto" \| "always" \| "never"` | — | |
| `step` | `boolean` | — | Sample-and-hold: each value holds until the next sample. |
| `accessor` *required* | [`ValueFunction`](#type-ValueFunction)`` | — | Y value; null/undefined/NaN/±Infinity means a gap. From `SeriesBase`. |
| `data` | `readonly T[]` | — | Series-own data; overrides the container data. From `SeriesBase`. |
| `xAxisId` *required* | `string` | — | From `SeriesBase`. |
| `yAxisId` *required* | `string` | — | From `SeriesBase`. |
| `name` | `string` | — | From `SeriesBase`. |
| `format` | `(value: number) => string` | — | The value in the tooltip; before the y axis' `tickFormat`. Compared by its source text, as an accessor is. From `SeriesBase`. |
| `color` | `string` | — | From `SeriesBase`. |
| `tone` | `"ok" \| "warning" \| "alarm"` | — | A role instead of a colour value: the theme resolves it, in both themes. For series that mean something rather than just being the next one - the violating points of a control chart, say. `color` beats it. From `SeriesBase`. |
##### `ListField`
The fields of a row whose value is a list of numbers, or absent.
```ts
type ListField = NoInfer<
{
[K in keyof T & string]-?: T[K] extends readonly number[] | null | undefined ? K : never;
}[keyof T & string]
>;
```
##### `ListFunction`
A list computed from the row - a box's outliers ([ADR-0040](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0040-a-box-carries-its-outliers.md)), the
function form of `ListValue`. null/undefined or an
empty array is none; a value that is not finite is left out.
```ts
type ListFunction = (d: T, index: number) => readonly number[] | null | undefined;
```
##### `ListValue`
A list read from the row: the name of a list field or a function.
```ts
type ListValue = ListField | ListFunction;
```
##### `MaterializedSeries`
Materialised series: the draw loop works only on this.
Gaps (null/undefined/NaN/±Infinity out of the accessor) are encoded as NaN.
| Prop | Type | Default | Description |
|---|---|---|---|
| `x` *required* | `Float64Array` | — | |
| `y` *required* | `Float64Array` | — | |
| `y0` *required* | `Float64Array \| null` | — | Lower edge, where the baseline is an accessor of its own; otherwise null. A fixed baseline needs no channel - it is a number. |
| `w` *required* | `Float64Array \| null` | — | Level channel: the third value per point, which today only the matrix needs. Named rather than overloading y0 - a property that only some kinds carry does not belong in the shared type with a comment ([ADR-0011](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0011-named-channels-never-overloaded.md)). null for every kind that does not use it. |
| `box` *required* | [`BoxChannels`](#type-BoxChannels)` \| null` | — | A box's further numbers beside its median in y; null for every other kind ([ADR-0011](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0011-named-channels-never-overloaded.md)). |
| `length` *required* | `number` | — | |
##### `MatrixColoring`
How a matrix colours its cells. Discretely by assessment - the same limits
that colour a tile - or across a gradient.
```ts
type MatrixColoring =
| { kind: "assessment"; limits: LimitSet }
| {
kind: "gradient";
/** Colour stops from small to large; at least two. The stops ARE the
steps - the library does not interpolate between them, because that
would mean a colour parser for arbitrary CSS colour values, which this
package does not have and is not to get (ADR-0011). Whoever wants a
finer gradation names more colours. */
stops: readonly string[];
/** Value range of the gradient.
@default the range of the data */
range?: readonly [number, number];
};
```
##### `MatrixProps`
Its table stands on [Matrix](#/matrix/type-MatrixProps).
##### `MatrixSeriesConfig`
A `Matrix` as the chart holds it once registered: its base accessor is the
row, `level` the colour. The row may be named: where the accessor yields a
string, materialisation gives it its category's place ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)) - the one
series kind whose base accessor reads more than a number.
| Prop | Type | Default | Description |
|---|---|---|---|
| `kind` *required* | `"matrix"` | — | |
| `level` *required* | [`ValueFunction`](#type-ValueFunction)`` | — | The level that decides the colour - the third channel ([ADR-0011](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0011-named-channels-never-overloaded.md)). The base accessor yields the row position, not this level. |
| `coloring` *required* | [`MatrixColoring`](#type-MatrixColoring) | — | |
| `accessor` *required* | [`ValueFunction`](#type-ValueFunction)`` | — | Y value; null/undefined/NaN/±Infinity means a gap. From `SeriesBase`. |
| `data` | `readonly T[]` | — | Series-own data; overrides the container data. From `SeriesBase`. |
| `xAxisId` *required* | `string` | — | From `SeriesBase`. |
| `yAxisId` *required* | `string` | — | From `SeriesBase`. |
| `name` | `string` | — | From `SeriesBase`. |
| `format` | `(value: number) => string` | — | The value in the tooltip; before the y axis' `tickFormat`. Compared by its source text, as an accessor is. From `SeriesBase`. |
| `color` | `string` | — | From `SeriesBase`. |
| `tone` | `"ok" \| "warning" \| "alarm"` | — | A role instead of a colour value: the theme resolves it, in both themes. For series that mean something rather than just being the next one - the violating points of a control chart, say. `color` beats it. From `SeriesBase`. |
##### `NumberField`
The fields of a row whose value is a number, or absent.
```ts
type NumberField = NoInfer<
{
[K in keyof T & string]-?: T[K] extends number | null | undefined ? K : never;
}[keyof T & string]
>;
```
##### `OneLane`
A lane is named or given by its edges: the compiler refuses both at once.
Beside the props and not in them, so that `StateBandProps` stays an
interface another can extend.
```ts
type OneLane = { lane: string; laneFrom?: undefined; laneTo?: undefined } | { lane?: undefined };
```
##### `Padding`
Space in CSS pixels on each side of a rectangle.
| Prop | Type | Default | Description |
|---|---|---|---|
| `top` *required* | `number` | — | Shown in: [Fix the domain and name the ticks (Axis)](#/axis/configuration) |
| `right` *required* | `number` | — | Shown in: [Fix the domain and name the ticks (Axis)](#/axis/configuration) |
| `bottom` *required* | `number` | — | Shown in: [Fix the domain and name the ticks (Axis)](#/axis/configuration) |
| `left` *required* | `number` | — | Shown in: [Fix the domain and name the ticks (Axis)](#/axis/configuration) |
##### `ParetoEntry`
An item, the way it is drawn.
| Prop | Type | Default | Description |
|---|---|---|---|
| `name` *required* | `string` | — | |
| `value` *required* | `number` | — | |
| `index` *required* | `number` | — | The entry's rank from 0, in the order drawn - its place on an x axis that reads `name`, and an x value for one that reads numbers. |
| `share` *required* | `number` | — | Share of the total, 0…1. |
| `cumulative` *required* | `number` | — | Cumulative share up to and including this entry, 0…1. Exactly 1 at the last entry. Shown in: [Sort causes and collect the tail (Pareto)](#/pareto/pareto) |
| `remainder` *required* | `boolean` | — | The collected tail. Exactly one entry can be this, and it always stands last. |
##### `ParetoItem`
An item, the way the caller counts it.
| Prop | Type | Default | Description |
|---|---|---|---|
| `name` *required* | `string` | — | |
| `value` *required* | `number` | — | |
##### `ParetoOptions`
The settings of `pareto`, complete; a call gives `ParetoSettings`.
| Prop | Type | Default | Description |
|---|---|---|---|
| `cutoff` *required* | `number` | `0.8` | The share whose crossing is sought. Whoever lands exactly on it counts as the crossing: the question is "which reasons make up eighty per cent", and whoever reaches eighty exactly belongs to them. |
| `collectRank` *required* | `number` | `Infinity` | How many items stay on their own. Everything behind that becomes a single entry. Beyond the number of items no remainder arises at all - not an empty one. |
| `remainderName` | `string` | none - the caller names it | Name of the collected remainder. The helper has no default wording for it. |
##### `ParetoResult`
What `pareto` returns: the entries as they are drawn, their total and
where the cutoff is crossed.
| Prop | Type | Default | Description |
|---|---|---|---|
| `entries` *required* | `readonly `[`ParetoEntry`](#type-ParetoEntry)`[]` | — | Shown in: [Sort causes and collect the tail (Pareto)](#/pareto/pareto) |
| `total` *required* | `number` | — | Total of the drawn entries - after collecting, the same as before. |
| `cutoffIndex` *required* | `number` | — | Index of the first entry whose cumulative share reaches the cutoff; -1 when it is never reached. |
##### `ParetoSettings`
What a call may specify: a collect rank only together with the name of the
remainder it creates.
```ts
type ParetoSettings = { readonly cutoff?: number } & (
| { readonly collectRank?: undefined; readonly remainderName?: undefined }
| { readonly collectRank: number; readonly remainderName: string }
);
```
##### `Rect`
A rectangle in CSS pixels, from its top left corner.
| Prop | Type | Default | Description |
|---|---|---|---|
| `x` *required* | `number` | — | |
| `y` *required* | `number` | — | |
| `width` *required* | `number` | — | |
| `height` *required* | `number` | — | |
##### `RemovedSpan`
A removed span and the place it collapses onto.
| Prop | Type | Default | Description |
|---|---|---|---|
| `from` *required* | `number` | — | |
| `to` *required* | `number` | — | |
| `workingTime` *required* | `number` | — | Working time on which the whole span lies - a point, not a stretch. |
##### `ResolvedTheme`
A chart's colours and fonts, resolved from the `--uc-*` tokens where the
chart stands.
| Prop | Type | Default | Description |
|---|---|---|---|
| `colorAxis` *required* | `string` | — | |
| `colorGrid` *required* | `string` | — | |
| `colorText` *required* | `string` | — | |
| `colorBg` *required* | `string` | — | |
| `colorWarning` *required* | `string` | — | Colours of the two limit severities. A limit carries the colour of its severity, not that of its series - it belongs to none. |
| `colorAlarm` *required* | `string` | — | |
| `colorOk` *required* | `string` | — | The third outcome of an assessment. "unknown" deliberately has no colour: a missing value becomes a hole like every gap in this library, and a hole carries no colour, because a colour would be a claim. |
| `font` *required* | `string` | — | |
| `fontMono` *required* | `string` | — | |
| `series` *required* | `readonly string[]` | — | |
| `forced` *required* | `boolean` | — | The page is in forced colours: every colour above is a system colour, and the chart encodes by marks whatever its `encoding`. |
##### `RuleName`
Name of a rule, the way it is switched on and off in `violations`.
```ts
type RuleName = "outlier" | "run" | "trend" | "twoOfThree";
```
Used on [Scenarios](#/) and [ControlChart](#/controlchart).
##### `RuleOptions`
Which rules run, and with which run lengths.
| Prop | Type | Default | Description |
|---|---|---|---|
| `outlier` *required* | `boolean` | — | |
| `run` *required* | `boolean` | — | |
| `trend` *required* | `boolean` | — | |
| `twoOfThree` *required* | `boolean` | — | |
| `runLength` *required* | `number` | — | Points on one side of the centre line from which rule 2 fires. |
| `trendLength` *required* | `number` | — | Points in succession from which rule 3 fires. |
##### `Scale`
The mapping of an axis' values onto pixels and back, with its ticks. Every
scale of the library is affine; `LinearScale` implements it.
```ts
interface Scale {
domain: readonly [number, number];
range: readonly [number, number];
/** Affine coefficients of the mapping px = v·m + b.
They stand in the interface because the drawing code reads them and
computes inline in the point loop instead of calling toPx() per point -
that is what carries 60 fps at millions of points. Every scale of this
library is affine; see docs/adr/0001-affine-scale-contract.md, which also
says what a later log scale would cost. */
readonly m: number;
readonly b: number;
toPx(v: number): number;
fromPx(px: number): number;
ticks(n: number): number[];
}
```
##### `ScatterProps`
Its table stands on [Scatter](#/scatter/type-ScatterProps).
##### `ScatterSeriesConfig`
A `Scatter` as the chart holds it once registered.
| Prop | Type | Default | Description |
|---|---|---|---|
| `kind` *required* | `"scatter"` | — | |
| `radius` *required* | `number` | — | Point radius in CSS pixels. |
| `accessor` *required* | [`ValueFunction`](#type-ValueFunction)`` | — | Y value; null/undefined/NaN/±Infinity means a gap. From `SeriesBase`. |
| `data` | `readonly T[]` | — | Series-own data; overrides the container data. From `SeriesBase`. |
| `xAxisId` *required* | `string` | — | From `SeriesBase`. |
| `yAxisId` *required* | `string` | — | From `SeriesBase`. |
| `name` | `string` | — | From `SeriesBase`. |
| `format` | `(value: number) => string` | — | The value in the tooltip; before the y axis' `tickFormat`. Compared by its source text, as an accessor is. From `SeriesBase`. |
| `color` | `string` | — | From `SeriesBase`. |
| `tone` | `"ok" \| "warning" \| "alarm"` | — | A role instead of a colour value: the theme resolves it, in both themes. For series that mean something rather than just being the next one - the violating points of a control chart, say. `color` beats it. From `SeriesBase`. |
##### `SeriesBase