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.
import { useChart } from "@umriss-ui/charts";
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.
A Line joins the readings of its value in the order of x; the first y axis draws the grid.
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 (
<Chart height={280} ariaLabel="Checkout's 95th percentile latency today">
<XAxis value="t" time />
<YAxis label="ms" />
<Line value="p95" name="Checkout p95" />
<Tooltip />
</Chart>
);
}
Each Line may bring its own data; a null reading breaks the line instead of being bridged, and a lone reading keeps its marker.
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 (
<Chart height={300} ariaLabel="95th percentile latency of four services today">
<XAxis value="t" time />
<YAxis label="ms" />
<Line value="p95" name="Checkout" />
<Line data={BILLING} value="p95" name="Billing" />
<Line data={SIGN_IN} value="p95" name="Sign-in" dash={[4, 4]} />
<Line data={IMAGES} value="p95" name="Image service" />
<Legend placement="top" />
<Tooltip mode="x" />
</Chart>
);
}
A value logged only when it changes holds until the next entry; step draws it so, and a gap ends the hold.
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 (
<Chart height={260} ariaLabel="Checkout's requests and the instances serving them today">
<XAxis value="t" time />
<YAxis label="Requests/min" />
<YAxis id="instances" position="right" domain={[0, 15]} label="Instances" />
<Line value="requests" name="Requests" />
<Line data={REPLICAS} value="replicas" yAxisId="instances" name="Instances" step strokeWidth={2} />
<Legend />
<Tooltip mode="x" />
</Chart>
);
}
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.
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 (
<Chart height={260} ariaLabel="Sprint 14: hours left against the ideal">
<XAxis value="t" ticks={BURNDOWN.map((d) => d.t)} tickFormat={day} />
<YAxis label="Hours left" />
<Line value="ideal" name="Ideal" color="var(--uc-color-text)" dash={[4, 4]} />
<Line value="remaining" name="Remaining" markers="always" strokeWidth={2} />
<Legend />
<Tooltip mode="x" />
</Chart>
);
}
The keys of Chart apply here.
LineProps<T>| Name | Type | Default | Description |
|---|---|---|---|
valuerequired | Value<T> | — | Y value - a number field of the row or a function of it;
null/undefined/NaN/±Infinity means a gap. Shown in: One series, Several series and a gap, Hold a value until it changes and 47 more |
xAxisId | string | "x" | Binding to an x axis. Shown in: Add axes for other magnitudes (Axis), Bind every kind of series to a second axis (Axis) |
yAxisId | string | "y" | Binding to a y axis. Shown in: Hold a value until it changes, Add axes for other magnitudes (Axis), Align a second axis to the grid (Axis) and 4 more |
data | readonly T[] | — | Series-own data; overrides the container data. Shown in: Several series and a gap, Hold a value until it changes, Tell series apart without colour (Chart) 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, Several series and a gap, Hold a value until it changes 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), Show the values as a table (Tooltip), Table a long series (Tooltip) and 1 more |
color | string | the palette's --uc-series-N | Any CSS colour value. Shown in: Compare progress with a plan, Fix the domain and name the ticks (Axis), Watch latency against its objective (Scenarios) and 1 more |
tone | | "ok" | — | A role instead of a colour value; the theme resolves it. |
strokeWidth | number | 1.5 | Line width in CSS pixels. Shown in: Hold a value until it changes, Compare progress with a plan, Mix series kinds (Chart) 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, Compare progress with a plan, Add axes for other magnitudes (Axis) and 1 more |
markers | | "auto" | "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, Draw a series per group of long rows (Chart), Fix the domain and name the ticks (Axis) 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 |
Value<T>A value read from the row (ADR-0048): the name of a number field, compared
by its name, or a function, compared by its source text (ValueFunction).
type Value<T> = NumberField<T> | ValueFunction<T>;NumberField<T>The fields of a row whose value is a number, or absent.
type NumberField<T> = NoInfer<
{
[K in keyof T & string]-?: T[K] extends number | null | undefined ? K : never;
}[keyof T & string]
>;ValueFunction<T>A value computed from the row - the function form of Value.
Compared by its source text, not its identity - an inline function is new
on every render. Known limit: one that reads a
changed closure variable under the same text is not run again; pass a new
data reference then. A native or bound function is compared by identity.
type ValueFunction<T> = (d: T, index: number) => number | null | undefined;What umriss deliberately does not build, and why: ADR-0032.