# LimitLine

> Package `@umriss-ui/charts`, version 0.40.0. Demo page: <https://romanhaendler.github.io/umriss-ui/charts/limitline/>. 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).

The limits a course is read against (a threshold, a reference line): a line above the series, a band beneath them. Reach for it for objectives, budgets and alert levels.

```ts
import { LimitLine, LimitBand } from "@umriss-ui/charts";
```

Demo page: https://romanhaendler.github.io/umriss-ui/charts/limitline/

A limit's tone follows its `severity`, never a colour of its own. The model is the one `@umriss-ui/core` uses for its verdicts, so a chart and a tile agree ([ADR-0006](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0006-the-limit-model-lives-in-both-packages.md)).

## Examples

### Draw a limit

A `LimitLine` lies above the series with its label; `severity` picks its tone, and its value counts in the y extent.

```tsx
import { LimitLine, Tooltip, useChart } from "@umriss-ui/charts";

/* Data from the operations world, written out here so the example runs on its own. */
/** A small LCG - the same numbers on every computer. */
function random(seed: number): () => number {
  let s = seed >>> 0;
  return () => {
    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
    return s / 4294967296;
  };
}

const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
const MINUTE = 60_000;

/** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
const NOW = at(17, 10, 30);

interface Service {
  id: string;
  name: string;
  team: string;
  /** 1 is customer-facing and pages at night; 3 waits for the morning. */
  tier: 1 | 2 | 3;
  /** The latency objective: the 95th percentile stays below this, in ms. */
  latencySlo: number;
  /** The availability promised for a month, in per cent. */
  availabilityTarget: number;
}

const SERVICES: readonly Service[] = [
  { id: "checkout", name: "Checkout", team: "Payments", tier: 1, latencySlo: 300, availabilityTarget: 99.95 },
  { id: "billing", name: "Billing", team: "Payments", tier: 1, latencySlo: 400, availabilityTarget: 99.9 },
  { id: "sign-in", name: "Sign-in", team: "Identity", tier: 1, latencySlo: 200, availabilityTarget: 99.95 },
  { id: "search", name: "Search", team: "Discovery", tier: 1, latencySlo: 250, availabilityTarget: 99.9 },
  { id: "images", name: "Image service", team: "Discovery", tier: 2, latencySlo: 500, availabilityTarget: 99.5 },
  { id: "notifications", name: "Notifications", team: "Messaging", tier: 2, latencySlo: 800, availabilityTarget: 99.5 },
  { id: "webhooks", name: "Webhooks", team: "Integrations", tier: 2, latencySlo: 1000, availabilityTarget: 99.5 },
  { id: "reports", name: "Reporting", team: "Insights", tier: 3, latencySlo: 2000, availabilityTarget: 99 },
];

interface MetricPoint {
  t: number;
  /** Median and 95th percentile latency, in ms. */
  p50: number;
  p95: number;
  /** Failed requests, in per cent. */
  errorRate: number;
  /** Requests per minute. */
  requests: number;
}

/** Requests per minute at the busiest hour, by tier. */
const VOLUME = { 1: 900, 2: 400, 3: 120 } as const;

/** A service's latency and error rate every five minutes of today, from
    midnight up to now. Busy by day, quiet at night; Checkout's 95th percentile
    climbs well past its objective from 09:40 to 10:10, and its errors with it. */
function metrics(serviceId: string): MetricPoint[] {
  const index = SERVICES.findIndex((one) => one.id === serviceId);
  const service = SERVICES[index];
  if (service === undefined) throw new Error(`No service "${serviceId}".`);
  const r = random(1000 + index * 37);
  const points: MetricPoint[] = [];
  let drift = 0;
  for (let t = at(17, 0); t <= NOW; t += 5 * MINUTE) {
    const hour = (t - at(17, 0)) / (60 * MINUTE);
    const load = 0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16));
    drift = 0.8 * drift + (r() - 0.5) * 0.08;
    const base = service.latencySlo * (0.45 + 0.2 * load + drift);
    const surge = serviceId === "checkout" && t >= at(17, 9, 40) && t < at(17, 10, 10) ? 1.9 : 1;
    const p95 = Math.round(base * surge * (1 + r() * 0.08));
    points.push({
      t,
      p50: Math.round(p95 * (0.42 + r() * 0.06)),
      p95,
      errorRate: Math.round((0.05 + r() * 0.12 + (surge > 1 ? 2.4 + r() : 0)) * 100) / 100,
      requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
    });
  }
  return points;
}

const CHECKOUT = metrics("checkout");
const OBJECTIVE = SERVICES.find((one) => one.id === "checkout")!.latencySlo;

export default function Objective() {
  const { Chart, XAxis, YAxis, Line } = useChart(CHECKOUT);
  return (
    <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>
  );
}
```

### Lay a band under the series

A `LimitBand` fills from `from` to `to` below every series - ground to read against - while a line stays on top as the landmark.

```tsx
import { LimitBand, LimitLine, Tooltip, useChart } from "@umriss-ui/charts";

/* Data from the operations world, written out here so the example runs on its own. */
/** A small LCG - the same numbers on every computer. */
function random(seed: number): () => number {
  let s = seed >>> 0;
  return () => {
    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
    return s / 4294967296;
  };
}

const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
const MINUTE = 60_000;

/** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
const NOW = at(17, 10, 30);

interface Service {
  id: string;
  name: string;
  team: string;
  /** 1 is customer-facing and pages at night; 3 waits for the morning. */
  tier: 1 | 2 | 3;
  /** The latency objective: the 95th percentile stays below this, in ms. */
  latencySlo: number;
  /** The availability promised for a month, in per cent. */
  availabilityTarget: number;
}

const SERVICES: readonly Service[] = [
  { id: "checkout", name: "Checkout", team: "Payments", tier: 1, latencySlo: 300, availabilityTarget: 99.95 },
  { id: "billing", name: "Billing", team: "Payments", tier: 1, latencySlo: 400, availabilityTarget: 99.9 },
  { id: "sign-in", name: "Sign-in", team: "Identity", tier: 1, latencySlo: 200, availabilityTarget: 99.95 },
  { id: "search", name: "Search", team: "Discovery", tier: 1, latencySlo: 250, availabilityTarget: 99.9 },
  { id: "images", name: "Image service", team: "Discovery", tier: 2, latencySlo: 500, availabilityTarget: 99.5 },
  { id: "notifications", name: "Notifications", team: "Messaging", tier: 2, latencySlo: 800, availabilityTarget: 99.5 },
  { id: "webhooks", name: "Webhooks", team: "Integrations", tier: 2, latencySlo: 1000, availabilityTarget: 99.5 },
  { id: "reports", name: "Reporting", team: "Insights", tier: 3, latencySlo: 2000, availabilityTarget: 99 },
];

interface MetricPoint {
  t: number;
  /** Median and 95th percentile latency, in ms. */
  p50: number;
  p95: number;
  /** Failed requests, in per cent. */
  errorRate: number;
  /** Requests per minute. */
  requests: number;
}

/** Requests per minute at the busiest hour, by tier. */
const VOLUME = { 1: 900, 2: 400, 3: 120 } as const;

/** A service's latency and error rate every five minutes of today, from
    midnight up to now. Busy by day, quiet at night; Checkout's 95th percentile
    climbs well past its objective from 09:40 to 10:10, and its errors with it. */
function metrics(serviceId: string): MetricPoint[] {
  const index = SERVICES.findIndex((one) => one.id === serviceId);
  const service = SERVICES[index];
  if (service === undefined) throw new Error(`No service "${serviceId}".`);
  const r = random(1000 + index * 37);
  const points: MetricPoint[] = [];
  let drift = 0;
  for (let t = at(17, 0); t <= NOW; t += 5 * MINUTE) {
    const hour = (t - at(17, 0)) / (60 * MINUTE);
    const load = 0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16));
    drift = 0.8 * drift + (r() - 0.5) * 0.08;
    const base = service.latencySlo * (0.45 + 0.2 * load + drift);
    const surge = serviceId === "checkout" && t >= at(17, 9, 40) && t < at(17, 10, 10) ? 1.9 : 1;
    const p95 = Math.round(base * surge * (1 + r() * 0.08));
    points.push({
      t,
      p50: Math.round(p95 * (0.42 + r() * 0.06)),
      p95,
      errorRate: Math.round((0.05 + r() * 0.12 + (surge > 1 ? 2.4 + r() : 0)) * 100) / 100,
      requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
    });
  }
  return points;
}

const CHECKOUT = metrics("checkout");

export default function Band() {
  const { Chart, XAxis, YAxis, Line } = useChart(CHECKOUT);
  return (
    <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>
  );
}
```

### Read limits above the states

A course, the limits it is read against and the states beneath it, on one x axis: where the charge runs low, the lanes say why.

```tsx
import { Legend, LimitBand, LimitLine, Tooltip, useChart } from "@umriss-ui/charts";

/* Data from the logistics world, written out here so the example runs on its own. */

/** A small LCG - the same numbers on every computer. */
function random(seed: number): () => number {
  let s = seed >>> 0;
  return () => {
    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
    return s / 4294967296;
  };
}

const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();

const MINUTE = 60_000;

interface Vehicle {
  id: string;
  plate: string;
  type: "van" | "e-van" | "truck";
  /** Payload, in kg. */
  capacity: number;
  depot: string;
}

const VEHICLES: readonly Vehicle[] = [
  { id: "v1", plate: "FP 214 K", type: "van", capacity: 1200, depot: "north" },
  { id: "v2", plate: "FP 377 K", type: "e-van", capacity: 900, depot: "north" },
  { id: "v3", plate: "FP 118 R", type: "truck", capacity: 7500, depot: "north" },
  { id: "v4", plate: "FP 402 R", type: "van", capacity: 1200, depot: "river" },
  { id: "v5", plate: "FP 455 R", type: "e-van", capacity: 900, depot: "river" },
  { id: "v6", plate: "FP 290 E", type: "van", capacity: 1200, depot: "east" },
  { id: "v7", plate: "FP 311 E", type: "e-van", capacity: 900, depot: "east" },
  { id: "v8", plate: "FP 520 E", type: "truck", capacity: 7500, depot: "east" },
];

const DRIVERS = [
  { id: "d1", name: "Martin Hale", depot: "north" },
  { id: "d2", name: "Nadia Petrova", depot: "north" },
  { id: "d3", name: "Owen Carter", depot: "north" },
  { id: "d4", name: "Lucia Romero", depot: "river" },
  { id: "d5", name: "Ben Adeyemi", depot: "river" },
  { id: "d6", name: "Hanna Berg", depot: "east" },
  { id: "d7", name: "Yusuf Demir", depot: "east" },
  { id: "d8", name: "Clara Wendt", depot: "east" },
] as const;

const CUSTOMERS = [
  "Holloway Garden Supplies",
  "Marlow & Finch Books",
  "Oakridge Pharmacy",
  "Brixley Cycles",
  "Tamsin's Bakery",
  "Northfold Office",
  "Pellham Hardware",
  "Greywick Studio",
  "Juniper Lane Florist",
  "Ashcombe Dental",
] as const;

interface Stop {
  id: string;
  shipment: string;
  customer: string;
  /** The window the customer was promised, `[from, to]`. */
  window: readonly [number, number];
  /** When the tour plans to be there. */
  arrival: number;
}

interface Tour {
  id: string;
  vehicle: string;
  driver: string;
  depot: string;
  /** Leaves the depot, loaded; back at the depot. */
  from: number;
  to: number;
  /** Minutes of loading before `from`. */
  loading: number;
  km: number;
  stops: readonly Stop[];
}

/** One tour per vehicle. Stops every half hour or so, each with a two-hour
    window; every seventh arrival misses its window - the late ones a
    dispatcher looks for. */
function tour(index: number): Tour {
  const r = random(700 + index);
  const vehicle = VEHICLES[index]!;
  const truck = vehicle.type === "truck";
  const from = at(truck ? 6 : 7, index % 2 === 0 ? 0 : 30);
  const count = truck ? 5 : 8 + Math.floor(r() * 4);
  const stops: Stop[] = [];
  let arrival = from + (20 + Math.floor(r() * 15)) * MINUTE;
  for (let i = 0; i < count; i++) {
    const n = index * 20 + i;
    const slot = Math.floor((arrival - at(0)) / (2 * 60 * MINUTE)) * 2;
    /* A late stop's window closed half an hour or more before the arrival. */
    const late = i >= 2 && n % 7 === 3;
    const halfHour = 30 * MINUTE;
    const windowFrom = late ? Math.floor(arrival / halfHour) * halfHour - 5 * halfHour : at(slot);
    stops.push({
      id: `s-${index + 1}-${i + 1}`,
      shipment: `FP-${(1_004_210 + n * 13).toString()}`,
      customer: CUSTOMERS[(n * 3) % CUSTOMERS.length]!,
      window: [windowFrom, windowFrom + 2 * 60 * MINUTE],
      arrival,
    });
    arrival += (truck ? 45 : 22) * MINUTE + Math.floor(r() * 18) * MINUTE;
  }
  return {
    id: `T-${String(index + 1).padStart(2, "0")}`,
    vehicle: vehicle.id,
    driver: DRIVERS[index]!.id,
    depot: vehicle.depot,
    from,
    to: arrival + 30 * MINUTE,
    loading: truck ? 45 : 30,
    km: Math.round((truck ? 140 : 70) + r() * 40),
    stops,
  };
}

const TOURS: readonly Tour[] = VEHICLES.map((_, index) => tour(index));

/** The closed set of vehicle states. The order is the code. */
const VEHICLE_STATES = [
  { label: "Driving", color: "#2f6fb4" },
  { label: "Loading", color: "#c08a2e" },
  { label: "Idle", color: "#8a8f98" },
  { label: "Break", color: "#6b8e5a" },
] as const;

interface StatePoint {
  t: number;
  /** A code in `VEHICLE_STATES`; `null` before the tour and after it. */
  state: number | null;
}

/** What a vehicle did from 05:00 to 19:00, quarter hour by quarter hour:
    loading before its tour, driving between stops, idle at them now and then,
    and a break around noon. */
function vehicleDay(vehicleId: string): StatePoint[] {
  const one = TOURS.find((t) => t.vehicle === vehicleId);
  if (one === undefined) throw new Error(`No vehicle "${vehicleId}".`);
  const r = random(900 + TOURS.indexOf(one));
  const points: StatePoint[] = [];
  for (let t = at(5); t <= at(19); t += 15 * MINUTE) {
    let state: number | null = null;
    if (t >= one.from - one.loading * MINUTE && t < one.from) state = 1;
    else if (t >= one.from && t < one.to) state = t >= at(12) && t < at(12, 30) ? 3 : r() < 0.18 ? 2 : 0;
    points.push({ t, state });
  }
  return points;
}

interface BatteryPoint {
  t: number;
  /** State of charge, in per cent; `null` before the tour and after it. */
  charge: number | null;
}

/** An e-van's battery through the day, with `vehicleDay`'s quarter hours:
    full when the tour leaves, draining while it drives, a little while it
    idles, and topped up at the depot's charger over the noon break. */
function batteryDay(vehicleId: string): BatteryPoint[] {
  let charge = 100;
  return vehicleDay(vehicleId).map(({ t, state }) => {
    if (state === null) return { t, charge: null };
    const now = charge;
    charge = Math.max(0, Math.min(100, charge + [-4.8, 0, -0.6, 3][state]!));
    return { t, charge: Math.round(now * 10) / 10 };
  });
}

const NORTH = VEHICLES.filter((vehicle) => vehicle.depot === "north");
const BATTERY = batteryDay("v2");

/* The lanes: one per vehicle, named by its plate. */
const LANES = NORTH.map((vehicle) => ({ vehicle, day: vehicleDay(vehicle.id) }));

export default function LimitsAndState() {
  const { Chart, XAxis, YAxis, StateBand, Line } = useChart(BATTERY);
  return (
    <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>
  );
}
```

### Watch a kiln zone against its tolerance

In the plant: the tolerance as a band a tile must be fired in, the alarm limit above it - the burner's overshoot crosses both.

```tsx
import { Legend, LimitBand, LimitLine, Tooltip, useChart } from "@umriss-ui/charts";

/* Data from the plant world, written out here so the example runs on its own. */
/** A small LCG - reproducible across runs and platforms. */
function random(seed: number): () => number {
  let s = seed >>> 0;
  return () => {
    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
    return s / 4294967296;
  };
}

/** The early shift, 06:00 to 14:00. */
const SHIFT_MINUTES = 480;

/** The kiln's zone 3, in °C: the numbers every part reads it against, once.
    Between the two warnings lies the tolerance - a tile fired outside it is
    scrap; `returnTo` is the alarm's dead band. */
const KILN = { target: 1200, tolerance: [1185, 1215], alarm: 1230, returnTo: 1222 } as const;

/** The kiln fires one tile every quarter of a minute when nothing holds it up -
    the ideal cycle time of the OEE's performance. */
const IDEAL_CYCLE_MINUTES = 0.25;

/** One minute of the line. */
interface Reading {
  /** Minutes since the start of the shift. */
  minute: number;
  /** Zone 3 of the kiln, in °C. */
  kiln: number;
  /** The exit pyrometer, in °C - `null` while it sends nothing. */
  exit: number | null;
  /** Whether the belt runs. */
  running: boolean;
  /** Tiles out of the kiln in this minute. */
  fired: number;
  /** Of them, the ones fired inside the tolerance. */
  good: number;
}

/** A tile taken off the belt and measured - one every ten minutes while it runs. */
interface Sample {
  minute: number;
  /** The tile's length after firing, in mm (nominal 600). */
  length: number;
}

/** A batch of tiles through the three stations. */
interface Batch {
  id: string;
  name: string;
  /** How many tiles the batch is planned for. */
  tiles: number;
  /** Minutes since the start of the shift, per station. */
  press: readonly [number, number];
  dryer: readonly [number, number];
  kiln: readonly [number, number];
}

interface Plant {
  readings: readonly Reading[];
  samples: readonly Sample[];
  batches: readonly Batch[];
}

/* When the exit pyrometer goes silent, and for how long. Fixed rather than
   seeded, so that a running control room shows its reading turn stale, then
   lost, and come back. */
const SILENT_FROM = 288;
const SILENT_FOR = 50;

/** The shift, from a seed. */
function plant(seed: number): Plant {
  const r = random(seed);

  /* The one excursion: a burner overshoots somewhere between 02:30 and 03:30
     into the shift, for half an hour, peaking near 1242 °C. The operator stops
     the feed twenty-two minutes in, for eighteen minutes. The noise around it
     is too small to reach a warning on its own, so this is the only crossing -
     which is what the tests hold. */
  const surgeFrom = 150 + Math.floor(r() * 60);
  const surgeFor = 30;
  const stopFrom = surgeFrom + 22;
  const stopFor = 18;

  /* The plan: batches through the kiln one after another, each pressed an
     hour and dried half an hour before - the first ones during the night
     shift. Tiles come out of the kiln only while a batch is in it, so the
     OEE's count and the batches' counts are the same tiles. The plan is a
     plan: a stop of the belt does not move it. */
  const batches: Batch[] = [];
  for (let at = 0, i = 0; at < SHIFT_MINUTES; i++) {
    const length = 70 + Math.floor(r() * 25);
    batches.push({
      id: `b-${4121 + i}`,
      name: `B-${4121 + i}`,
      tiles: Math.round(length / IDEAL_CYCLE_MINUTES / 10) * 10,
      press: [at - 60, at - 60 + length],
      dryer: [at - 30, at - 30 + length],
      kiln: [at, at + length],
    });
    at += length + 5;
  }

  const readings: Reading[] = [];
  const samples: Sample[] = [];
  let drift = 0;
  for (let minute = 0; minute < SHIFT_MINUTES; minute++) {
    drift = 0.9 * drift + (r() - 0.5) * 3;
    const into = minute - surgeFrom;
    const surge = into >= 0 && into <= surgeFor ? 42 * Math.sin((Math.PI * into) / surgeFor) : 0;
    const kiln = Math.round((KILN.target + drift + surge) * 10) / 10;

    const running = !(minute >= stopFrom && minute < stopFrom + stopFor);
    const loaded = batches.some((batch) => batch.kiln[0] <= minute && minute < batch.kiln[1]);
    const jam = r() < 0.12;
    const fired = running && loaded ? (jam ? 3 : 4) : 0;
    const inTolerance = kiln >= KILN.tolerance[0] && kiln <= KILN.tolerance[1];
    const silent = minute >= SILENT_FROM && minute < SILENT_FROM + SILENT_FOR;
    const exit = silent ? null : Math.round((kiln - 380 + (r() - 0.5) * 4) * 10) / 10;

    readings.push({ minute, kiln, exit, running, fired, good: inTolerance ? fired : 0 });

    if (running && minute % 10 === 0) {
      /* Hotter shrinks more: the length follows the kiln, so the control
         chart sees the excursion the trend sees. */
      const length = 600 + (KILN.target - kiln) * 0.05 + (r() - 0.5) * 0.6;
      samples.push({ minute, length: Math.round(length * 100) / 100 });
    }
  }

  return { readings, samples, batches };
}

const SHIFT = plant(7);
/* The line counts in minutes; the early shift began at 06:00. */
const SHIFT_START = new Date(2026, 2, 17, 6).getTime();

export default function KilnZone() {
  const { Chart, XAxis, YAxis, Line } = useChart(SHIFT.readings);
  return (
    <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>
  );
}
```

### Mark moments and windows along time

With `orientation="x"` a limit lies on the time axis: a line for a moment, a band for a window, each named as a `Date` or in milliseconds. Neither carries a severity, so each takes a `color` of its own.

```tsx
import { LimitBand, LimitLine, Tooltip, useChart } from "@umriss-ui/charts";

/* Data from the operations world, written out here so the example runs on its own. */
/** A small LCG - the same numbers on every computer. */
function random(seed: number): () => number {
  let s = seed >>> 0;
  return () => {
    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
    return s / 4294967296;
  };
}

const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
const MINUTE = 60_000;

/** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
const NOW = at(17, 10, 30);

interface Service {
  id: string;
  name: string;
  team: string;
  /** 1 is customer-facing and pages at night; 3 waits for the morning. */
  tier: 1 | 2 | 3;
  /** The latency objective: the 95th percentile stays below this, in ms. */
  latencySlo: number;
  /** The availability promised for a month, in per cent. */
  availabilityTarget: number;
}

const SERVICES: readonly Service[] = [
  { id: "checkout", name: "Checkout", team: "Payments", tier: 1, latencySlo: 300, availabilityTarget: 99.95 },
  { id: "billing", name: "Billing", team: "Payments", tier: 1, latencySlo: 400, availabilityTarget: 99.9 },
  { id: "sign-in", name: "Sign-in", team: "Identity", tier: 1, latencySlo: 200, availabilityTarget: 99.95 },
  { id: "search", name: "Search", team: "Discovery", tier: 1, latencySlo: 250, availabilityTarget: 99.9 },
  { id: "images", name: "Image service", team: "Discovery", tier: 2, latencySlo: 500, availabilityTarget: 99.5 },
  { id: "notifications", name: "Notifications", team: "Messaging", tier: 2, latencySlo: 800, availabilityTarget: 99.5 },
  { id: "webhooks", name: "Webhooks", team: "Integrations", tier: 2, latencySlo: 1000, availabilityTarget: 99.5 },
  { id: "reports", name: "Reporting", team: "Insights", tier: 3, latencySlo: 2000, availabilityTarget: 99 },
];

interface MetricPoint {
  t: number;
  /** Median and 95th percentile latency, in ms. */
  p50: number;
  p95: number;
  /** Failed requests, in per cent. */
  errorRate: number;
  /** Requests per minute. */
  requests: number;
}

/** Requests per minute at the busiest hour, by tier. */
const VOLUME = { 1: 900, 2: 400, 3: 120 } as const;

/** A service's latency and error rate every five minutes of today, from
    midnight up to now. Busy by day, quiet at night; Checkout's 95th percentile
    climbs well past its objective from 09:40 to 10:10, and its errors with it. */
function metrics(serviceId: string): MetricPoint[] {
  const index = SERVICES.findIndex((one) => one.id === serviceId);
  const service = SERVICES[index];
  if (service === undefined) throw new Error(`No service "${serviceId}".`);
  const r = random(1000 + index * 37);
  const points: MetricPoint[] = [];
  let drift = 0;
  for (let t = at(17, 0); t <= NOW; t += 5 * MINUTE) {
    const hour = (t - at(17, 0)) / (60 * MINUTE);
    const load = 0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16));
    drift = 0.8 * drift + (r() - 0.5) * 0.08;
    const base = service.latencySlo * (0.45 + 0.2 * load + drift);
    const surge = serviceId === "checkout" && t >= at(17, 9, 40) && t < at(17, 10, 10) ? 1.9 : 1;
    const p95 = Math.round(base * surge * (1 + r() * 0.08));
    points.push({
      t,
      p50: Math.round(p95 * (0.42 + r() * 0.06)),
      p95,
      errorRate: Math.round((0.05 + r() * 0.12 + (surge > 1 ? 2.4 + r() : 0)) * 100) / 100,
      requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
    });
  }
  return points;
}

const CHECKOUT = metrics("checkout");

export default function AlongTime() {
  const { Chart, XAxis, YAxis, Line } = useChart(CHECKOUT);
  return (
    <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>
  );
}
```

### Set specification and control limits side by side

In the plant: a specification is chosen, a control limit is computed from the process - `role` draws the two apart, so neither passes for the other ([ADR-0008](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0008-control-limits-are-not-specification-limits.md)).

```tsx
import { LimitBand, LimitLine, Tooltip, controlLimits, useChart } from "@umriss-ui/charts";

/* Data from the plant world, written out here so the example runs on its own. */

/** A small LCG - reproducible across runs and platforms. */
function random(seed: number): () => number {
  let s = seed >>> 0;
  return () => {
    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
    return s / 4294967296;
  };
}

/** The early shift, 06:00 to 14:00. */
const SHIFT_MINUTES = 480;

/** The kiln's zone 3, in °C: the numbers every part reads it against, once.
    Between the two warnings lies the tolerance - a tile fired outside it is
    scrap; `returnTo` is the alarm's dead band. */
const KILN = { target: 1200, tolerance: [1185, 1215], alarm: 1230, returnTo: 1222 } as const;

/** The kiln fires one tile every quarter of a minute when nothing holds it up -
    the ideal cycle time of the OEE's performance. */
const IDEAL_CYCLE_MINUTES = 0.25;

/** One minute of the line. */
interface Reading {
  /** Minutes since the start of the shift. */
  minute: number;
  /** Zone 3 of the kiln, in °C. */
  kiln: number;
  /** The exit pyrometer, in °C - `null` while it sends nothing. */
  exit: number | null;
  /** Whether the belt runs. */
  running: boolean;
  /** Tiles out of the kiln in this minute. */
  fired: number;
  /** Of them, the ones fired inside the tolerance. */
  good: number;
}

/** A tile taken off the belt and measured - one every ten minutes while it runs. */
interface Sample {
  minute: number;
  /** The tile's length after firing, in mm (nominal 600). */
  length: number;
}

/** A batch of tiles through the three stations. */
interface Batch {
  id: string;
  name: string;
  /** How many tiles the batch is planned for. */
  tiles: number;
  /** Minutes since the start of the shift, per station. */
  press: readonly [number, number];
  dryer: readonly [number, number];
  kiln: readonly [number, number];
}

interface Plant {
  readings: readonly Reading[];
  samples: readonly Sample[];
  batches: readonly Batch[];
}

/* When the exit pyrometer goes silent, and for how long. Fixed rather than
   seeded, so that a running control room shows its reading turn stale, then
   lost, and come back. */
const SILENT_FROM = 288;

const SILENT_FOR = 50;

/** The shift, from a seed. */
function plant(seed: number): Plant {
  const r = random(seed);

  /* The one excursion: a burner overshoots somewhere between 02:30 and 03:30
     into the shift, for half an hour, peaking near 1242 °C. The operator stops
     the feed twenty-two minutes in, for eighteen minutes. The noise around it
     is too small to reach a warning on its own, so this is the only crossing -
     which is what the tests hold. */
  const surgeFrom = 150 + Math.floor(r() * 60);
  const surgeFor = 30;
  const stopFrom = surgeFrom + 22;
  const stopFor = 18;

  /* The plan: batches through the kiln one after another, each pressed an
     hour and dried half an hour before - the first ones during the night
     shift. Tiles come out of the kiln only while a batch is in it, so the
     OEE's count and the batches' counts are the same tiles. The plan is a
     plan: a stop of the belt does not move it. */
  const batches: Batch[] = [];
  for (let at = 0, i = 0; at < SHIFT_MINUTES; i++) {
    const length = 70 + Math.floor(r() * 25);
    batches.push({
      id: `b-${4121 + i}`,
      name: `B-${4121 + i}`,
      tiles: Math.round(length / IDEAL_CYCLE_MINUTES / 10) * 10,
      press: [at - 60, at - 60 + length],
      dryer: [at - 30, at - 30 + length],
      kiln: [at, at + length],
    });
    at += length + 5;
  }

  const readings: Reading[] = [];
  const samples: Sample[] = [];
  let drift = 0;
  for (let minute = 0; minute < SHIFT_MINUTES; minute++) {
    drift = 0.9 * drift + (r() - 0.5) * 3;
    const into = minute - surgeFrom;
    const surge = into >= 0 && into <= surgeFor ? 42 * Math.sin((Math.PI * into) / surgeFor) : 0;
    const kiln = Math.round((KILN.target + drift + surge) * 10) / 10;

    const running = !(minute >= stopFrom && minute < stopFrom + stopFor);
    const loaded = batches.some((batch) => batch.kiln[0] <= minute && minute < batch.kiln[1]);
    const jam = r() < 0.12;
    const fired = running && loaded ? (jam ? 3 : 4) : 0;
    const inTolerance = kiln >= KILN.tolerance[0] && kiln <= KILN.tolerance[1];
    const silent = minute >= SILENT_FROM && minute < SILENT_FROM + SILENT_FOR;
    const exit = silent ? null : Math.round((kiln - 380 + (r() - 0.5) * 4) * 10) / 10;

    readings.push({ minute, kiln, exit, running, fired, good: inTolerance ? fired : 0 });

    if (running && minute % 10 === 0) {
      /* Hotter shrinks more: the length follows the kiln, so the control
         chart sees the excursion the trend sees. */
      const length = 600 + (KILN.target - kiln) * 0.05 + (r() - 0.5) * 0.6;
      samples.push({ minute, length: Math.round(length * 100) / 100 });
    }
  }

  return { readings, samples, batches };
}

const SAMPLES = plant(7).samples;

/* Computed from the first fifteen samples, when the kiln ran in control. */
const LIMITS = controlLimits(
  SAMPLES.map((s) => s.length),
  { kind: "referenceWindow", from: 0, to: 15 },
);

export default function SpecificationAndControl() {
  const { Chart, XAxis, YAxis, Line } = useChart(SAMPLES);
  return (
    <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>
  );
}
```

## When to use something else

- Limits computed from the process itself → ControlChart

## Keyboard

The keys of [Chart](https://romanhaendler.github.io/umriss-ui/charts/chart/#keyboard-chart) apply here.

## API

### `LimitLineProps`

| Prop | Type | Default | Description |
|---|---|---|---|
| `value` *required* | `number \| Date` | — | Where the line lies, in domain units of its axis; along a time axis an instant, in milliseconds or as a `Date`.<br>Shown in: [Draw a limit](https://romanhaendler.github.io/umriss-ui/charts/limitline/#objective), [Lay a band under the series](https://romanhaendler.github.io/umriss-ui/charts/limitline/#band), [Read limits above the states](https://romanhaendler.github.io/umriss-ui/charts/limitline/#limits-and-state) and 11 more |
| `axisId` | `string` | the first axis of the orientation | Axis on which the value lies.<br>Shown in: [Sort causes and collect the tail (Pareto)](https://romanhaendler.github.io/umriss-ui/charts/pareto/#pareto) |
| `orientation` | `"x" \| "y"` | `"y"` | Whether the value lies on the x or on the y axis - a limit is nearly always a value and not a moment.<br>Shown in: [Mark moments and windows along time](https://romanhaendler.github.io/umriss-ui/charts/limitline/#along-time) |
| `severity` | [`Severity`](https://romanhaendler.github.io/umriss-ui/charts/limitline/#type-Severity)<br>`"warning" \| "alarm"` | `"alarm"` | Two severities, not five: five urgencies are five colours, and five colours on a dense screen are noise.<br>Shown in: [Draw a limit](https://romanhaendler.github.io/umriss-ui/charts/limitline/#objective), [Lay a band under the series](https://romanhaendler.github.io/umriss-ui/charts/limitline/#band), [Read limits above the states](https://romanhaendler.github.io/umriss-ui/charts/limitline/#limits-and-state) and 10 more |
| `label` | `string` | — | What the value means. A line at 90 does not say what 90 is.<br>Shown in: [Draw a limit](https://romanhaendler.github.io/umriss-ui/charts/limitline/#objective), [Lay a band under the series](https://romanhaendler.github.io/umriss-ui/charts/limitline/#band), [Read limits above the states](https://romanhaendler.github.io/umriss-ui/charts/limitline/#limits-and-state) and 11 more |
| `color` | `string` | — | Any CSS colour value, in place of the one the severity yields. A colour handed in instead of a severity puts the rule in the application and the presentation in the library, and the two drift apart as soon as a bound moves - so this is for the case that has no severity, not for taste. The line or band takes it; its label keeps the axis's text colour, which stays legible in both themes, and a short stroke before the text in the line's pattern takes this colour.<br>Shown in: [Mark moments and windows along time](https://romanhaendler.github.io/umriss-ui/charts/limitline/#along-time) |
| `inExtent` | `boolean` | `true` | Whether the value widens the axis extent, so that the limit is certainly visible.<br>Shown in: [Sort causes and collect the tail (Pareto)](https://romanhaendler.github.io/umriss-ui/charts/pareto/#pareto), [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) |

#### Accessibility

| Prop | Type | Default | Description |
|---|---|---|---|
| `role` | [`LimitRole`](https://romanhaendler.github.io/umriss-ui/charts/limitline/#type-LimitRole)<br>`"specification" \| "control" \| "zone"` | `"specification"` | A chosen specification limit or a calculated control limit. They look different because they mean different things ([ADR-0008](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0008-control-limits-are-not-specification-limits.md)).<br>Shown in: [Set specification and control limits side by side](https://romanhaendler.github.io/umriss-ui/charts/limitline/#specification-and-control) |

### `LimitBandProps`

| Prop | Type | Default | Description |
|---|---|---|---|
| `from` *required* | `number \| Date` | — | Lower edge of the band, in domain units of its axis; along a time axis an instant, in milliseconds or as a `Date`.<br>Shown in: [Lay a band under the series](https://romanhaendler.github.io/umriss-ui/charts/limitline/#band), [Read limits above the states](https://romanhaendler.github.io/umriss-ui/charts/limitline/#limits-and-state), [Watch a kiln zone against its tolerance](https://romanhaendler.github.io/umriss-ui/charts/limitline/#kiln-zone) and 5 more |
| `to` *required* | `number \| Date` | — | Upper edge, in domain units of its axis. A band drawn backwards (`from` above `to`) is drawn between the two all the same - the pair is a range, not a direction.<br>Shown in: [Lay a band under the series](https://romanhaendler.github.io/umriss-ui/charts/limitline/#band), [Read limits above the states](https://romanhaendler.github.io/umriss-ui/charts/limitline/#limits-and-state), [Watch a kiln zone against its tolerance](https://romanhaendler.github.io/umriss-ui/charts/limitline/#kiln-zone) and 5 more |
| `axisId` | `string` | the first axis of the orientation | Axis on which the value lies.<br>Shown in: [Sort causes and collect the tail (Pareto)](https://romanhaendler.github.io/umriss-ui/charts/pareto/#pareto) |
| `orientation` | `"x" \| "y"` | `"y"` | Whether the value lies on the x or on the y axis - a limit is nearly always a value and not a moment.<br>Shown in: [Mark moments and windows along time](https://romanhaendler.github.io/umriss-ui/charts/limitline/#along-time) |
| `severity` | [`Severity`](https://romanhaendler.github.io/umriss-ui/charts/limitline/#type-Severity)<br>`"warning" \| "alarm"` | `"warning"` | Two severities, not five: five urgencies are five colours, and five colours on a dense screen are noise.<br>Shown in: [Draw a limit](https://romanhaendler.github.io/umriss-ui/charts/limitline/#objective), [Lay a band under the series](https://romanhaendler.github.io/umriss-ui/charts/limitline/#band), [Read limits above the states](https://romanhaendler.github.io/umriss-ui/charts/limitline/#limits-and-state) and 10 more |
| `label` | `string` | — | What the value means. A line at 90 does not say what 90 is.<br>Shown in: [Draw a limit](https://romanhaendler.github.io/umriss-ui/charts/limitline/#objective), [Lay a band under the series](https://romanhaendler.github.io/umriss-ui/charts/limitline/#band), [Read limits above the states](https://romanhaendler.github.io/umriss-ui/charts/limitline/#limits-and-state) and 11 more |
| `color` | `string` | — | Any CSS colour value, in place of the one the severity yields. A colour handed in instead of a severity puts the rule in the application and the presentation in the library, and the two drift apart as soon as a bound moves - so this is for the case that has no severity, not for taste. The line or band takes it; its label keeps the axis's text colour, which stays legible in both themes, and a short stroke before the text in the line's pattern takes this colour.<br>Shown in: [Mark moments and windows along time](https://romanhaendler.github.io/umriss-ui/charts/limitline/#along-time) |
| `inExtent` | `boolean` | `true` | Whether the value widens the axis extent, so that the limit is certainly visible.<br>Shown in: [Sort causes and collect the tail (Pareto)](https://romanhaendler.github.io/umriss-ui/charts/pareto/#pareto), [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) |

#### Accessibility

| Prop | Type | Default | Description |
|---|---|---|---|
| `role` | [`LimitRole`](https://romanhaendler.github.io/umriss-ui/charts/limitline/#type-LimitRole)<br>`"specification" \| "control" \| "zone"` | `"specification"` | A chosen specification limit or a calculated control limit. They look different because they mean different things ([ADR-0008](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0008-control-limits-are-not-specification-limits.md)).<br>Shown in: [Set specification and control limits side by side](https://romanhaendler.github.io/umriss-ui/charts/limitline/#specification-and-control) |

### Types on this page

#### `Severity`

How seriously a violation weighs. Exactly two: one severity that advises and
one that calls for action. Five severities would be five colours, and five
colours on a dense screen are noise. Whoever needs a third
distinction sets a second limit.

```ts
type Severity = "warning" | "alarm";
```

#### `LimitRole`

What a limit stands for. The difference is not cosmetic: a specification
limit is chosen (what the customer assumes), a control limit is calculated
(what the process normally does). A process can be in control and outside the
specification and the other way round, and each of the four cases calls for
something different - which is why the two lines have to look
distinguishable ([ADR-0008](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0008-control-limits-are-not-specification-limits.md)).

```ts
type LimitRole = "specification" | "control" | "zone";
```
