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.
import { useChart } from "@umriss-ui/charts";
value gives a state's place in states, which fixes the order, the name and the colour in one place (ADR-0007). 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). On a numeric y axis laneFrom and laneTo give a lane its edges instead.
value gives a state's place in states; each segment runs to the next reading, and a null leaves a hole.
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 (
<Chart height={150} ariaLabel="States of van FP 214 K through the day">
<XAxis value="t" time label="Time" />
{/* Without a lane the band fills its y axis; one tick names it. */}
<YAxis domain={[0, 1]} ticks={[0.5]} tickFormat={() => "FP 214 K"} grid={false} />
<StateBand value="state" states={VEHICLE_STATES} name="FP 214 K" />
<Legend placement="top" />
<Tooltip mode="x" />
</Chart>
);
}
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.
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 (
<Chart height={320} ariaLabel="An e-van's charge above what it was doing">
<XAxis value="t" time label="Time" />
<YAxis domain={[-40, 100]} ticks={[0, 25, 50, 75, 100]} label="Charge %" />
{/* The lane: the bottom unit of five, the rest is room for the course. */}
<YAxis id="lane" position="right" domain={[0, 5]} ticks={[0.4]} tickFormat={() => "State"} />
<StateBand data={STATES} value="state" states={VEHICLE_STATES} yAxisId="lane" laneFrom={0} laneTo={0.8} name="FP 377 K" />
<Line value="charge" name="Charge" strokeWidth={1.75} />
<Legend placement="top" />
<Tooltip mode="x" />
</Chart>
);
}
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.
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 (
<Chart height={300} ariaLabel="The states of all eight vehicles through the day">
<XAxis value="t" time label="Time" />
<YAxis grid={false} />
{FLEET.map(({ vehicle, day }) => (
<StateBand key={vehicle.id} data={day} value="state" states={VEHICLE_STATES} lane={vehicle.plate} name={vehicle.plate} />
))}
<Legend placement="top" />
<Tooltip mode="x" />
</Chart>
);
}
Schedule from @umriss-ui/scheduleThe keys of Chart apply here.
StateBandProps<T>| Name | Type | Default | Description |
|---|---|---|---|
valuerequired | Value<T> | — | 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, Lay a state under a course, Give each one a lane and 4 more |
statesrequired | readonly StateEntry[] | — | The closed set of states, in the order of their codes. Shown in: One vehicle through the day, Lay a state under a course, Give each one a lane 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, Mark bands, limits and cells (Chart), Read limits above the states (LimitLine) |
data | readonly T[] | — | Series-own data; overrides the container data. Shown in: Lay a state under a course, Give each one a lane, Mark bands, limits and cells (Chart) 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, Lay a state under a course, Give each one a lane 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). Not together with laneFrom or laneTo.Shown in: Give each one a lane, Mark bands, limits and cells (Chart), Read limits above the states (LimitLine) 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 |
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 |
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>;StateEntryOne state of the closed set a state series knows.
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;