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.
import { LimitLine, LimitBand } from "@umriss-ui/charts";
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).
A LimitLine lies above the series with its label; severity picks its tone, and its value counts in the y extent.
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 (
<Chart height={260} ariaLabel="Checkout's latency against its objective">
<XAxis value="t" time />
<YAxis label="ms" />
<LimitLine value={OBJECTIVE} severity="alarm" label="Objective" />
<Line value="p95" name="p95" />
<Tooltip mode="x" />
</Chart>
);
}
A LimitBand fills from from to to below every series - ground to read against - while a line stays on top as the landmark.
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 (
<Chart height={260} ariaLabel="Checkout's error rate against its warning band and alarm limit">
<XAxis value="t" time />
<YAxis label="Errors %" />
<LimitBand from={1} to={2} severity="warning" label="Watch" />
<LimitLine value={2} severity="alarm" label="Alert" />
<Line value="errorRate" name="Error rate" />
<Tooltip mode="x" />
</Chart>
);
}
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.
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 (
<Chart height={340} ariaLabel="An e-van's charge against its limits, above the states of the North depot's vehicles">
<XAxis value="t" time label="Time" />
<YAxis domain={[-100, 100]} ticks={[0, 20, 40, 60, 80, 100]} label="Charge %" />
{/* The lanes take the lower places; the domain leaves the rest as room for the course. */}
<YAxis id="lanes" position="right" domain={[-0.5, 7.5]} />
<LimitBand from={10} to={20} severity="warning" label="Reserve" />
<LimitLine value={20} severity="warning" label="Charge soon" />
<LimitLine value={10} severity="alarm" label="Return now" />
{LANES.map(({ vehicle, day }) => (
<StateBand key={vehicle.id} data={day} value="state" states={VEHICLE_STATES} yAxisId="lanes" lane={vehicle.plate} name={vehicle.plate} />
))}
<Line value="charge" name="Charge" strokeWidth={1.75} />
<Legend placement="top" />
<Tooltip mode="x" />
</Chart>
);
}
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.
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 (
<Chart height={280} ariaLabel="Kiln K1 zone 3 against its tolerance and alarm limit over the early shift">
<XAxis value={(d) => SHIFT_START + d.minute * 60_000} time />
<YAxis label="°C" />
<LimitBand from={KILN.tolerance[0]} to={KILN.tolerance[1]} severity="warning" label="Tolerance" />
<LimitLine value={KILN.alarm} severity="alarm" label="Alarm" />
<Line value="kiln" name="Zone 3" />
<Legend placement="top" />
<Tooltip mode="x" />
</Chart>
);
}
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.
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 (
<Chart height={260} ariaLabel="Checkout's latency across a maintenance window and the start of a release freeze">
<XAxis value="t" time />
<YAxis label="ms" />
<LimitBand orientation="x" from={new Date(2026, 2, 17, 2)} to={new Date(2026, 2, 17, 3)} color="#5b7c99" label="Maintenance" />
<LimitLine orientation="x" value={new Date(2026, 2, 17, 8, 30)} color="#7b61c9" label="Release freeze" />
<Line value="p95" name="p95" />
<Tooltip mode="x" />
</Chart>
);
}
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).
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 (
<Chart height={300} ariaLabel="The tile length against its specification and its control limits">
<XAxis value="minute" label="Minute of the shift" />
<YAxis label="mm" />
<LimitBand from={597.5} to={602.5} severity="warning" label="Specification" />
<LimitBand role="control" from={LIMITS.lower} to={LIMITS.upper} label="±3σ" />
<LimitLine role="control" value={LIMITS.center} label="Centre" />
<Line value="length" name="Tile length" />
<Tooltip mode="x" />
</Chart>
);
}
The keys of Chart apply here.
LimitLineProps| Name | Type | Default | Description |
|---|---|---|---|
valuerequired | 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, Lay a band under the series, Read limits above the states and 11 more |
axisId | string | the first axis of the orientation | Axis on which the value lies. |
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 |
severity | 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, Lay a band under the series, Read limits above the states and 10 more |
label | string | — | What the value means. A line at 90 does not say what 90 is. Shown in: Draw a limit, Lay a band under the series, Read limits above the states 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 |
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), Watch latency against its objective (Scenarios), Hold the tile length in control (Scenarios) |
LimitBandProps| Name | Type | Default | Description |
|---|---|---|---|
fromrequired | 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, Read limits above the states, Watch a kiln zone against its tolerance and 5 more |
torequired | 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, Read limits above the states, Watch a kiln zone against its tolerance and 5 more |
axisId | string | the first axis of the orientation | Axis on which the value lies. |
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 |
severity | 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, Lay a band under the series, Read limits above the states and 10 more |
label | string | — | What the value means. A line at 90 does not say what 90 is. Shown in: Draw a limit, Lay a band under the series, Read limits above the states 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 |
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), Watch latency against its objective (Scenarios), Hold the tile length in control (Scenarios) |
SeverityHow 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.
type Severity = "warning" | "alarm";LimitRoleWhat 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).
type LimitRole = "specification" | "control" | "zone";