# Chart

> Package `@umriss-ui/charts`, version 0.40.0. Demo page: <https://romanhaendler.github.io/umriss-ui/charts/chart/>. Every page in one line: [llms.txt](https://romanhaendler.github.io/umriss-ui/charts/llms.txt); every page in full: [llms-full.txt](https://romanhaendler.github.io/umriss-ui/charts/llms-full.txt).

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](https://romanhaendler.github.io/umriss-ui/charts/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 (
    <Chart height={320} ariaLabel="North depot: pallets in, out and on hand over fourteen days">
      <XAxis value="day" label="Day of March" tickCount={7} />
      <YAxis label="Pallets" />
      <Bar value="arrived" name="Arrived" />
      <Bar value="dispatched" name="Dispatched" />
      <Area value="targetHigh" baseline="targetLow" name="Target range" />
      <Line value="onHand" name="On hand" strokeWidth={2} />
      <Scatter value="counted" name="Counted" />
      <Legend placement="top" />
      <Tooltip mode="x" />
    </Chart>
  );
}
```

### 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 (
    <Chart height={220} ariaLabel="Latency of a service without readings" empty={empty}>
      <XAxis value="t" time />
      <YAxis label="ms" />
      <Line value="p95" name="Refunds" />
    </Chart>
  );
}

export default function Empty() {
  return (
    <div className="pair">
      <div>
        <p className="pair-caption">no empty - the default</p>
        <Latency />
      </div>
      <div>
        <p className="pair-caption">empty="Refunds reports no metrics yet"</p>
        <Latency empty="Refunds reports no metrics yet" />
      </div>
    </div>
  );
}
```

### 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<MetricPoint>(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<MetricPoint> = useChart(SEARCH_WEEK, { onViewChange: (v) => german.setView(v) });
  const german: ChartParts<MetricPoint> = useChart(SEARCH_WEEK, { onViewChange: (v) => english.setView(v) });
  const { Chart, XAxis, YAxis, Line } = english;
  const { Chart: Diagramm } = german;
  return (
    <div>
      <Chart height={200} ariaLabel="Search latency over last week">
        <XAxis value="t" time domain="data" zoomable />
        <YAxis label="ms" />
        <Line value="p95" name="p95" />
        <Line value="p50" name="p50" />
        <Tooltip mode="x" />
      </Chart>
      <Diagramm height={200} ariaLabel="Antwortzeiten der Suche in der letzten Woche" wording={GERMAN_CHARTS_WORDING}>
        <XAxis value="t" time domain="data" zoomable />
        <YAxis label="ms" />
        <Line value="p95" name="p95" />
        <Line value="p50" name="p50" />
        <Tooltip mode="x" />
      </Diagramm>
    </div>
  );
}
```

### 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<Record<string, number>> = { "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 (
    <Chart height={240} ariaLabel="Forecast of four cost centres per month" encoding={encoding}>
      <XAxis value="month" categoryLabel={monthName} />
      <YAxis label="€" />
      {CENTRES.map((c) => (
        <Line
          key={c.id}
          data={LEDGER.filter((row) => row.costCentre === c.id)}
          value="forecast"
          name={c.name}
        />
      ))}
      <Legend />
      <Tooltip mode="x" />
    </Chart>
  );
}

export default function ToldApartWithoutColour() {
  return (
    <div className="pair">
      <div>
        <p className="pair-caption">by colour - the default</p>
        <Forecasts />
      </div>
      <div>
        <p className="pair-caption">encoding="marks"</p>
        <Forecasts encoding="marks" />
      </div>
    </div>
  );
}
```

### 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 (
    <Chart height={320} ariaLabel="North depot's pallets, told apart by marks" encoding="marks">
      <XAxis value="day" label="Day of March" tickCount={7} />
      <YAxis label="Pallets" />
      <Bar value="arrived" name="Arrived" />
      <Bar value="dispatched" name="Dispatched" />
      <Area value="targetHigh" baseline="targetLow" name="Target range" />
      <Line value="onHand" name="On hand" strokeWidth={2} />
      <Scatter value="counted" name="Counted" />
      <Legend placement="top" />
      <Tooltip mode="x" />
    </Chart>
  );
}
```

### 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 (
    <Chart height={280} ariaLabel="An e-van's battery against its reserve, above its states" encoding="marks">
      <XAxis value="t" time label="Time" tickCount={4} />
      <YAxis domain={[-40, 100]} ticks={[0, 20, 40, 60, 80, 100]} label="%" />
      {/* One lane, and four places of room above it for the course. */}
      <YAxis id="lane" position="right" domain={[-0.5, 4.5]} />
      <LimitBand from={10} to={20} severity="warning" label="Reserve" />
      <LimitLine value={10} severity="alarm" label="Empty soon" />
      <StateBand data={STATES} value="state" states={VEHICLE_STATES} yAxisId="lane" lane="FP 377 K" name="FP 377 K" />
      <Line value="charge" name="Charge" strokeWidth={1.75} />
      <Legend placement="top" />
      <Tooltip mode="x" />
    </Chart>
  );
}

function SuccessCells() {
  const { Chart, XAxis, YAxis, Matrix } = useChart(SUCCESS_BY_HOUR);
  return (
    <Chart height={280} ariaLabel="Successful requests per service and hour, by limits and by marks" encoding="marks">
      <XAxis value="hour" ticks={[0, 6, 12, 18]} tickFormat={(v) => `${v}:00`} label="Hour" />
      <YAxis />
      <Matrix
        value="service"
        level="success"
        coloring={{
          kind: "assessment",
          limits: {
            limits: [
              { value: 99, side: "lower", severity: "warning" },
              { value: 98, side: "lower", severity: "alarm" },
            ],
          },
        }}
        name="Success rate"
      />
      <Legend placement="top" />
      <Tooltip mode="nearest" />
    </Chart>
  );
}

export default function BandsLimitsAndCells() {
  return (
    <div className="side-by-side">
      <BatteryAndStates />
      <SuccessCells />
    </div>
  );
}
```

### 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 (
    <Chart height={220} ariaLabel={label} loading={loading}>
      <XAxis value="t" time />
      <YAxis label="ms" />
      <Line value="p95" name="Refunds" />
    </Chart>
  );
}

export default function Loading() {
  const [answers, setAnswers] = useState(0);
  const [loading, setLoading] = useState(false);
  const pending = useRef<ReturnType<typeof setTimeout> | 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 (
    <div className="pair">
      <div>
        <p className="pair-caption">loading, before the first rows</p>
        <Latency rows={NOTHING} loading label="Latency of Refunds, loading" />
      </div>
      <div>
        <p className="pair-caption">loading over a course - the answer takes 1.5 s</p>
        <Latency rows={rows} loading={loading} label="Latency of Refunds" />
        <div className="demo-actions">
          <button className="demo-button" onClick={reload} disabled={loading}>
            Reload
          </button>
        </div>
      </div>
    </div>
  );
}
```

### 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 (
    <Chart height={260} ariaLabel="Failed requests per service and hour this morning">
      <XAxis value="t" time />
      <YAxis label="Errors %" />
      {BY_SERVICE.map(({ service, rows }) => (
        <Line key={service} data={rows} value="errors" name={service} markers="always" />
      ))}
      <Legend placement="top" />
      <Tooltip mode="x" />
    </Chart>
  );
}
```

## 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](https://romanhaendler.github.io/umriss-ui/charts/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.<br>Shown in: [Size a chart by its container (Installation)](https://romanhaendler.github.io/umriss-ui/charts/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)).<br>Shown in: [Mix series kinds](https://romanhaendler.github.io/umriss-ui/charts/chart/#mixed), [Say there is nothing to show](https://romanhaendler.github.io/umriss-ui/charts/chart/#empty), [Read it by keyboard and screen reader](https://romanhaendler.github.io/umriss-ui/charts/chart/#keyboard-and-screen-reader) and 74 more |
| `padding` | `number \| Partial<`[`Padding`](https://romanhaendler.github.io/umriss-ui/charts/chart/#type-Padding)`>` | — | Outer spacing of the plot area in CSS pixels.<br>Shown in: [Fix the domain and name the ticks (Axis)](https://romanhaendler.github.io/umriss-ui/charts/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`.<br>Shown in: [Say there is nothing to show](https://romanhaendler.github.io/umriss-ui/charts/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.<br>Shown in: [Show that rows are on their way](https://romanhaendler.github.io/umriss-ui/charts/chart/#loading) |
| `wording` | `Partial<`[`ChartsWording`](https://romanhaendler.github.io/umriss-ui/charts/chart/#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`.<br>Shown in: [Read it by keyboard and screen reader](https://romanhaendler.github.io/umriss-ui/charts/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.<br>Shown in: [Tell series apart without colour](https://romanhaendler.github.io/umriss-ui/charts/chart/#told-apart-without-colour), [Mark every kind](https://romanhaendler.github.io/umriss-ui/charts/chart/#marks-on-every-kind), [Mark bands, limits and cells](https://romanhaendler.github.io/umriss-ui/charts/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`).<br>Shown in: [Keep charts in step (Zoom and pan)](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#cursor-sync), [Watch latency against its objective (Scenarios)](https://romanhaendler.github.io/umriss-ui/charts/#watch-latency-against-its-objective), [Hold the tile length in control (Scenarios)](https://romanhaendler.github.io/umriss-ui/charts/#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.<br>Shown in: [Mix series kinds](https://romanhaendler.github.io/umriss-ui/charts/chart/#mixed), [Say there is nothing to show](https://romanhaendler.github.io/umriss-ui/charts/chart/#empty), [Read it by keyboard and screen reader](https://romanhaendler.github.io/umriss-ui/charts/chart/#keyboard-and-screen-reader) and 74 more |

#### Events

| Prop | Type | Default | Description |
|---|---|---|---|
| `onPerf` | `(perf: `[`ChartPerf`](https://romanhaendler.github.io/umriss-ui/charts/chart/#type-ChartPerf)`) => void` | — | Instrumentation for the benchmark page; not needed otherwise.<br>Shown in: [Measure a million points (Benchmark)](https://romanhaendler.github.io/umriss-ui/charts/benchmark/#benchmark), [Draw a week of seconds (Benchmark)](https://romanhaendler.github.io/umriss-ui/charts/benchmark/#a-week-of-seconds) |

#### Accessibility

| Prop | Type | Default | Description |
|---|---|---|---|
| `ariaLabel` | `string` | — | Required in DEV through a warning.<br>Shown in: [Mix series kinds](https://romanhaendler.github.io/umriss-ui/charts/chart/#mixed), [Say there is nothing to show](https://romanhaendler.github.io/umriss-ui/charts/chart/#empty), [Read it by keyboard and screen reader](https://romanhaendler.github.io/umriss-ui/charts/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` | — | <br>Shown in: [Fix the domain and name the ticks (Axis)](https://romanhaendler.github.io/umriss-ui/charts/axis/#configuration) |
| `right` *required* | `number` | — | <br>Shown in: [Fix the domain and name the ticks (Axis)](https://romanhaendler.github.io/umriss-ui/charts/axis/#configuration) |
| `bottom` *required* | `number` | — | <br>Shown in: [Fix the domain and name the ticks (Axis)](https://romanhaendler.github.io/umriss-ui/charts/axis/#configuration) |
| `left` *required* | `number` | — | <br>Shown in: [Fix the domain and name the ticks (Axis)](https://romanhaendler.github.io/umriss-ui/charts/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` | — | <br>Shown in: [Measure a million points (Benchmark)](https://romanhaendler.github.io/umriss-ui/charts/benchmark/#benchmark) |
| `seriesDrawMs` *required* | `number` | — | <br>Shown in: [Measure a million points (Benchmark)](https://romanhaendler.github.io/umriss-ui/charts/benchmark/#benchmark), [Draw a week of seconds (Benchmark)](https://romanhaendler.github.io/umriss-ui/charts/benchmark/#a-week-of-seconds) |
| `points` *required* | `number` | — | <br>Shown in: [Measure a million points (Benchmark)](https://romanhaendler.github.io/umriss-ui/charts/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/).
