# Axis

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

What a value is measured against: extent, ticks and labels, categories, several axes per side, time on the local clock, and a working-time axis that leaves out the hours nobody works (a scale).

```ts
import { useChart, workingCalendar } from "@umriss-ui/charts";
```

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

Every scale is linear. An axis reads what its rows hold: a number is a position, a string a category, which gets a place, a tick and its name ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)); a working calendar maps time before it is drawn and marks every seam it took out ([ADR-0001](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0001-affine-scale-contract.md)).

An x axis zooms where it is `zoomable`; `domain` is then where it starts. That has a page of its own, [Zoom and pan](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/).

## Examples

### Fix the domain and name the ticks

A fixed `domain` holds the axis still, `tickFormat` writes the labels, and a typed `Tooltip` renders its own content.

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

interface Week {
  week: number;
  /** Share of the team's capacity booked on client work, in per cent. */
  web: number;
  apps: number;
}

const BOOKED: Week[] = [
  { week: 1, web: 62, apps: 71 },
  { week: 2, web: 74, apps: 78 },
  { week: 3, web: 81, apps: 76 },
  { week: 4, web: 79, apps: 69 },
  { week: 5, web: 86, apps: 72 },
  { week: 6, web: 91, apps: 80 },
  { week: 7, web: 88, apps: 84 },
  { week: 8, web: 70, apps: 88 },
  { week: 9, web: 77, apps: 93 },
  { week: 10, web: 83, apps: 90 },
  { week: 11, web: 85, apps: 82 },
  { week: 12, web: 80, apps: 86 },
];

const percent = (v: number) => `${v.toFixed(0)} %`;

function WeekTooltip({ hit }: { hit: TooltipHit<Week> }) {
  const point = hit.points[0];
  return (
    <div className="custom-tooltip">
      <strong>Week {hit.xValue}</strong>
      {point === undefined ? null : (
        <span>
          {point.seriesName}: {percent(point.yValue)} booked
        </span>
      )}
    </div>
  );
}

export default function Configuration() {
  const { Chart, XAxis, YAxis, Line } = useChart(BOOKED);
  return (
    <Chart height={280} padding={{ top: 12, right: 16, bottom: 8, left: 8 }} ariaLabel="Share of capacity booked per team and week">
      <XAxis value="week" label="Week" tickCount={6} />
      <YAxis domain={[0, 100]} tickFormat={percent} label="Booked" />
      <Line value="web" name="Web" markers="always" strokeWidth={2} />
      <Line value="apps" name="Apps" markers="always" color="#7c3aed" />
      <Legend placement="bottom" />
      {/* The type argument makes the hit fully typed in the render prop. */}
      <Tooltip<Week> mode="nearest" render={(hit) => <WeekTooltip hit={hit} />} />
    </Chart>
  );
}
```

### Add axes for other magnitudes

Several y axes stack outwards on their side, and a series picks its axes by `yAxisId` and `xAxisId`; only the first axis draws a grid.

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

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

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

/** When this morning's load test of Search began. */
interface LoadTestPoint {
  /** Minutes since the test began. */
  minute: number;
  /** CPU load of the busiest node, in per cent. */
  cpu: number;
  requestsPerHour: number;
  /** 95th percentile latency, in ms. */
  p95: number;
}

/** An hour of load test, minute by minute: three magnitudes far apart. */
const LOAD_TEST: readonly LoadTestPoint[] = (() => {
  const r = random(99);
  let cpu = 21;
  let requestsPerHour = 128_000;
  let p95 = 640;
  return Array.from({ length: 60 }, (_, minute) => {
    cpu += (r() - 0.5) * 0.9;
    requestsPerHour += (r() - 0.5) * 9000;
    p95 += (r() - 0.5) * 40;
    return { minute, cpu, requestsPerHour, p95 };
  });
})();

export default function Axes() {
  const { Chart, XAxis, YAxis, Line } = useChart(LOAD_TEST);
  return (
    <Chart height={340} ariaLabel="A load test: CPU, throughput and latency on three y axes">
      <XAxis value="minute" label="Minute of the test" />
      <XAxis id="seconds" position="top" value={(d) => d.minute * 60} label="Seconds into the test" />
      <YAxis id="cpu" position="left" label="CPU %" />
      <YAxis id="requests" position="right" label="Requests/h" />
      <YAxis id="latency" position="right" label="p95 ms" />
      <Line value="cpu" yAxisId="cpu" name="CPU" />
      <Line value="requestsPerHour" xAxisId="seconds" yAxisId="requests" name="Throughput" dash={[4, 4]} />
      <Line value="p95" yAxisId="latency" name="p95" />
      <Legend placement="top" />
      <Tooltip mode="x" />
    </Chart>
  );
}
```

### Leave out the hours nobody works

Pass the working hours as `calendar` and the axis drops nights and the weekend, marking every seam where time was taken out.

```tsx
import { 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 MINUTE = 60_000;

const on = (month: number, day: number, hours = 0) => new Date(2026, month - 1, day, hours).getTime();

/** The North depot's sorting hours: Monday to Friday, 06:00 to 22:00. */
const SORTING_HOURS = Array.from({ length: 5 }, (_, day) => ({ from: on(3, 9 + day, 6), to: on(3, 9 + day, 22) }));

interface SortedPoint {
  t: number;
  /** Parcels sorted per hour. */
  parcels: number;
}

/** Last week's sorting at the North depot, quarter hour by quarter hour -
    only in its sorting hours. */
const SORTED: readonly SortedPoint[] = (() => {
  const r = random(1963);
  const points: SortedPoint[] = [];
  let parcels = 420;
  for (const { from } of SORTING_HOURS) {
    for (let quarter = 0; quarter < 4 * 16; quarter++) {
      parcels += (r() - 0.5) * 26;
      points.push({ t: from + quarter * 15 * MINUTE, parcels: Math.round(parcels) });
    }
  }
  return points;
})();

const weekdayAndTime = (v: number) =>
  new Date(v).toLocaleString("en-GB", { weekday: "short", hour: "2-digit", minute: "2-digit" });

export default function WorkingTime() {
  const { Chart, XAxis, YAxis, Line } = useChart(SORTED);
  return (
    <Chart height={280} ariaLabel="Parcels sorted per hour across last week's sorting hours">
      <XAxis
        value="t"
        calendar={SORTING_HOURS}
        tickFormat={weekdayAndTime}
        tickCount={8}
        label="Sorting hours"
      />
      <YAxis label="Parcels/h" />
      <Line value="parcels" name="Sorted" />
      <Tooltip mode="x" />
    </Chart>
  );
}
```

### Plot instants on a time axis

Rows that hold a `Date` put the axis in time; `time` does the same for milliseconds and for ISO strings. Ticks stand on the local clock, labelled by hour, with the date where a day begins.

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

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

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

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

const MINUTE = 60_000;

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

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

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

interface MetricPoint {
  t: Date;
  /** 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: new Date(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 TimeAxis() {
  const { Chart, XAxis, YAxis, Line } = useChart(CHECKOUT);
  return (
    <Chart height={260} ariaLabel="Checkout's error rate today">
      <XAxis value="t" />
      <YAxis label="Errors %" />
      <Line value="errorRate" name="Error rate" />
      <Tooltip mode="x" />
    </Chart>
  );
}
```

### Align a second axis to the grid

With `alignTicks` a further y axis widens its domain until its ticks, in 1-2-5 steps, fall on the first axis' grid lines.

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

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

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

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

const MINUTE = 60_000;

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 AlignedTicks() {
  const { Chart, XAxis, YAxis, Line } = useChart(SEARCH_WEEK);
  return (
    <Chart height={260} ariaLabel="Search requests and latency over last week on one grid">
      <XAxis value="t" time />
      <YAxis label="Requests/min" />
      <YAxis id="latency" position="right" label="ms" alignTicks />
      <Line value="requests" name="Requests" />
      <Line value="p95" yAxisId="latency" name="p95" />
      <Legend placement="top" />
      <Tooltip mode="x" />
    </Chart>
  );
}
```

### Bind every kind of series to a second axis

In the plant: `xAxisId` and `yAxisId` work alike on every series kind. An area, bars, a line and a scatter read hourly counts on the right and top axes; the kiln's minutes keep the first two.

```tsx
import { Legend, 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 };
}

/** One hour of the shift, counted. */
interface HourCount {
  /** The middle of the hour, in hours since the start of the shift. */
  hour: number;
  /** Tiles the plan expects while a batch is in the kiln. */
  planned: number;
  fired: number;
  good: number;
  /** Fired outside the tolerance. */
  scrap: number;
}

/** The minutes of the shift, summed up hour by hour. */
function hourly(shift: Plant): HourCount[] {
  return Array.from({ length: SHIFT_MINUTES / 60 }, (_, h) => {
    const minutes = shift.readings.slice(h * 60, (h + 1) * 60);
    const loaded = minutes.filter((m) => shift.batches.some((b) => b.kiln[0] <= m.minute && m.minute < b.kiln[1])).length;
    const fired = minutes.reduce((sum, m) => sum + m.fired, 0);
    const good = minutes.reduce((sum, m) => sum + m.good, 0);
    return { hour: h + 0.5, planned: loaded / IDEAL_CYCLE_MINUTES, fired, good, scrap: fired - good };
  });
}

const SHIFT = plant(7);
const HOURS = hourly(SHIFT);

export default function EveryKind() {
  const { Chart, XAxis, YAxis, Line, Area, Bar, Scatter } = useChart(SHIFT.readings);
  return (
    <Chart height={340} ariaLabel="The kiln's temperature by the minute above the tiles it fired by the hour">
      <XAxis value="minute" domain={[0, SHIFT_MINUTES]} label="Minute of the shift" />
      {/* This axis reads the hourly rows its series bring, so it names their type. */}
      <XAxis<HourCount>
        id="hour"
        position="top"
        value="hour"
        domain={[0, SHIFT_MINUTES / 60]}
        ticks={HOURS.map((d) => d.hour)}
        tickFormat={(v) => `Hour ${Math.ceil(v)}`}
      />
      {/* The kiln above, the counts below: each axis keeps to its own half. */}
      <YAxis domain={[1050, 1250]} label="°C" />
      <YAxis id="tiles" position="right" domain={[0, 500]} label="Tiles per hour" />
      <Line value="kiln" name="Zone 3" />
      <Area data={HOURS} value="planned" xAxisId="hour" yAxisId="tiles" name="Planned" strokeWidth={0} />
      <Bar data={HOURS} value="fired" xAxisId="hour" yAxisId="tiles" name="Fired" />
      <Line data={HOURS} value="good" xAxisId="hour" yAxisId="tiles" name="Good" />
      <Scatter data={HOURS} value="scrap" xAxisId="hour" yAxisId="tiles" name="Scrap" />
      <Legend placement="top" />
      <Tooltip mode="x" />
    </Chart>
  );
}
```

### Fix the order and keep an empty place

`categories` is the order and the extent: a day without a row keeps its place, and the rows may come as they like. `categoryLabel` writes a name where the rows hold a key.

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

/* Minutes late per working day, as the tour log sends them: only the days
   that had a delay, the worst first. */
const LATE = [
  { day: "thu", minutes: 164 },
  { day: "mon", minutes: 71 },
  { day: "fri", minutes: 58 },
  { day: "tue", minutes: 43 },
];

const WEEK = ["mon", "tue", "wed", "thu", "fri"];
const NAMES: Record<string, string> = { mon: "Monday", tue: "Tuesday", wed: "Wednesday", thu: "Thursday", fri: "Friday" };

export default function Categories() {
  const { Chart, XAxis, YAxis, Bar } = useChart(LATE);
  return (
    <Chart height={260} ariaLabel="Minutes late per working day">
      <XAxis value="day" categories={WEEK} categoryLabel={(day) => NAMES[day] ?? day} />
      <YAxis label="Minutes late" />
      <Bar value="minutes" name="Late" />
      <Tooltip mode="x" />
    </Chart>
  );
}
```

## Keyboard

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

## API

### `XAxisProps<T>`

| Prop | Type | Default | Description |
|---|---|---|---|
| `value` *required* | [`AxisField`](https://romanhaendler.github.io/umriss-ui/charts/axis/#type-AxisField)`<T> \| ((d: T, index: number) => `[`AxisValue`](https://romanhaendler.github.io/umriss-ui/charts/axis/#type-AxisValue)`)` | — | Where a row lies on this axis: a field of the row, compared by its name, or a function of it, compared by its source text as a series' is (`ValueFunction`) - with the same closure limit. A number is a position, a `Date` an instant, a string a category: every distinct one gets a place of its own, in the order the rows bring them, a tick and its name as the label ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)). The type decides, never the content - `"2024"` is a category, `2024` a number - and a row without a value has no place. The axis reads the rows of every series bound to it; where those bring their own `data`, name their row: `<XAxis<HourCount> value="hour" />`.<br>Shown in: [Fix the domain and name the ticks](https://romanhaendler.github.io/umriss-ui/charts/axis/#configuration), [Add axes for other magnitudes](https://romanhaendler.github.io/umriss-ui/charts/axis/#axes), [Leave out the hours nobody works](https://romanhaendler.github.io/umriss-ui/charts/axis/#working-time) and 74 more |
| `domain` | `"nice" \| "data" \| readonly [number, number] \| readonly [Date, Date]` | `"nice"` | `"nice"` widens the data's extent to ticks - to named `ticks` where there are any, on a time axis to whole units of a step of hours or longer -, `"data"` keeps it, a pair is fixed: numbers, on a time axis milliseconds or two `Date`s, on a category axis places. On a `zoomable` axis it is the start: what the axis shows while the chart's view names no span for it.<br>Shown in: [Bind every kind of series to a second axis](https://romanhaendler.github.io/umriss-ui/charts/axis/#every-kind-on-a-second-axis), [Read it by keyboard and screen reader (Chart)](https://romanhaendler.github.io/umriss-ui/charts/chart/#keyboard-and-screen-reader), [Gestures and keys (Zoom and pan)](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#gestures-and-keys) and 11 more |
| `position` | `"bottom" \| "top"` | `"bottom"` | Which edge the axis stands at. A second x axis on the opposite edge is how a series counts in a unit of its own.<br>Shown in: [Add axes for other magnitudes](https://romanhaendler.github.io/umriss-ui/charts/axis/#axes), [Bind every kind of series to a second axis](https://romanhaendler.github.io/umriss-ui/charts/axis/#every-kind-on-a-second-axis) |
| `time` | `boolean` | — | The values are instants: milliseconds since the epoch, or with `time` said ISO 8601 strings as an API sends them, read by the platform's rule - a date with a time and no offset is local time, a date alone is UTC; a string that is none is not drawn. Rows that hold a `Date` put the axis in time without `time`. Ticks on local boundaries from the minute to the month, labels by level in en-GB with a 24-hour clock - `15:00`, `17 Mar`, `Mar 2026` - and the date on the first tick and on the first of a new day (`17 Mar 00:00`); a day tick there carries the year. The tooltip's x value carries the seconds where the readings lie less than a minute apart. Another language is a `tickFormat`, which is handed the instant in milliseconds. `calendar` implies it.<br>Shown in: [Align a second axis to the grid](https://romanhaendler.github.io/umriss-ui/charts/axis/#aligned-ticks), [Draw a first chart (Installation)](https://romanhaendler.github.io/umriss-ui/charts/installation/#first-chart), [Size a chart by its container (Installation)](https://romanhaendler.github.io/umriss-ui/charts/installation/#in-its-container) and 36 more |
| `calendar` | `readonly `[`WorkingInterval`](https://romanhaendler.github.io/umriss-ui/charts/axis/#type-WorkingInterval)`[]` | — | Working calendar: the intervals in which time counts. With it the axis stands in working time - nights and weekends are out, and every removed span gets a break mark. The scale stays affine; the mapping happens in materialisation ([ADR-0001](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0001-affine-scale-contract.md)).<br>Shown in: [Leave out the hours nobody works](https://romanhaendler.github.io/umriss-ui/charts/axis/#working-time) |
| `zoomable` | `boolean` | `false` | Zoom and pan: Ctrl or ⌘ with the wheel - and a pinch - zoom around the pointer, a drag, a horizontal wheel or Shift with the wheel pan, a double click goes back to `domain`; with the focus on the chart, + and − zoom, Shift with ← or → pans and 0 goes back. The span shown is part of the chart's view, by this axis' `id` (`useChart`'s `initialView`, `onViewChange`, `setView`, `setDomain`). The plain wheel always scrolls the page.<br>Shown in: [Read it by keyboard and screen reader (Chart)](https://romanhaendler.github.io/umriss-ui/charts/chart/#keyboard-and-screen-reader), [Gestures and keys (Zoom and pan)](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#gestures-and-keys), [Limit the zoom (Zoom and pan)](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#zoom-limits) and 6 more |
| `zoomLimits` | [`ZoomLimits`](https://romanhaendler.github.io/umriss-ui/charts/axis/#type-ZoomLimits) | at most the data's extent or this axis' own `domain`, whichever is wider, at least three data steps | The narrowest and the widest span zoom may reach, in the axis' units - milliseconds on a time axis.<br>Shown in: [Limit the zoom (Zoom and pan)](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#zoom-limits) |
| `id` | `string` | `"x"` | Axis id, through which series bind themselves.<br>Shown in: [Add axes for other magnitudes](https://romanhaendler.github.io/umriss-ui/charts/axis/#axes), [Align a second axis to the grid](https://romanhaendler.github.io/umriss-ui/charts/axis/#aligned-ticks), [Bind every kind of series to a second axis](https://romanhaendler.github.io/umriss-ui/charts/axis/#every-kind-on-a-second-axis) and 7 more |
| `label` | `string` | — | Axis title.<br>Shown in: [Fix the domain and name the ticks](https://romanhaendler.github.io/umriss-ui/charts/axis/#configuration), [Add axes for other magnitudes](https://romanhaendler.github.io/umriss-ui/charts/axis/#axes), [Leave out the hours nobody works](https://romanhaendler.github.io/umriss-ui/charts/axis/#working-time) and 66 more |
| `tickCount` | `number` | — | Target value; the 1-2-5 algorithm may deviate.<br>Shown in: [Fix the domain and name the ticks](https://romanhaendler.github.io/umriss-ui/charts/axis/#configuration), [Leave out the hours nobody works](https://romanhaendler.github.io/umriss-ui/charts/axis/#working-time), [Mix series kinds (Chart)](https://romanhaendler.github.io/umriss-ui/charts/chart/#mixed) and 8 more |
| `tickFormat` | `(v: number) => string` | — | Label of a tick. Compared by its source text: one that reads a changed closure variable under the same text does not relabel the axis - give it a new text or remount. A bound `Intl.NumberFormat#format` is compared by identity.<br>Shown in: [Fix the domain and name the ticks](https://romanhaendler.github.io/umriss-ui/charts/axis/#configuration), [Leave out the hours nobody works](https://romanhaendler.github.io/umriss-ui/charts/axis/#working-time), [Bind every kind of series to a second axis](https://romanhaendler.github.io/umriss-ui/charts/axis/#every-kind-on-a-second-axis) and 21 more |
| `grid` | `boolean` | only on the first axis of its orientation | Grid lines.<br>Shown in: [One vehicle through the day (StateBand)](https://romanhaendler.github.io/umriss-ui/charts/stateband/#one-vehicle), [Give each one a lane (StateBand)](https://romanhaendler.github.io/umriss-ui/charts/stateband/#one-lane-each), [See where the vans stand idle (Scenarios)](https://romanhaendler.github.io/umriss-ui/charts/#see-where-the-vans-stand-idle) |
| `ticks` | `readonly number[]` | — | Fixed tick values instead of the 1-2-5 algorithm, on a numeric axis: an hour axis' named hours, a lane grid. An axis that reads categories needs none - every category has its tick.<br>Shown in: [Bind every kind of series to a second axis](https://romanhaendler.github.io/umriss-ui/charts/axis/#every-kind-on-a-second-axis), [Mark bands, limits and cells (Chart)](https://romanhaendler.github.io/umriss-ui/charts/chart/#bands-limits-and-cells), [Compare progress with a plan (Line)](https://romanhaendler.github.io/umriss-ui/charts/line/#burn-down) and 7 more |
| `categories` | `readonly string[]` | — | The categories of the axis, in their order. Without it they stand in the order the rows first bring them. With it the list is the order and the extent: a category no row carries keeps its empty place, and a row whose category is not listed is not drawn - DEV names it. It makes the axis a category axis whatever the rows hold ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)).<br>Shown in: [Fix the order and keep an empty place](https://romanhaendler.github.io/umriss-ui/charts/axis/#categories) |
| `categoryLabel` | `(category: string) => string` | the category itself | What is written for a category - under its tick, in the tooltip, the readout and the data table - where the rows hold a key and not a name. Two categories may share a label and keep two places. Compared by its source text, as `tickFormat` is.<br>Shown in: [Fix the order and keep an empty place](https://romanhaendler.github.io/umriss-ui/charts/axis/#categories), [Tell series apart without colour (Chart)](https://romanhaendler.github.io/umriss-ui/charts/chart/#told-apart-without-colour), [Legend above or below (Legend)](https://romanhaendler.github.io/umriss-ui/charts/legend/#above-or-below) and 1 more |

### `YAxisProps`

| Prop | Type | Default | Description |
|---|---|---|---|
| `domain` | `"nice" \| "data" \| "visible" \| readonly [number, number]` | `"nice"` | As on the x axis, and `"visible"`: what the series show inside their x axis' domain - a zoomed hour gets the hour's range, not the week's -, widened to ticks as `"nice"` is. Where the x domain is not fixed that is every point.<br>Shown in: [Fix the domain and name the ticks](https://romanhaendler.github.io/umriss-ui/charts/axis/#configuration), [Bind every kind of series to a second axis](https://romanhaendler.github.io/umriss-ui/charts/axis/#every-kind-on-a-second-axis), [Mark bands, limits and cells (Chart)](https://romanhaendler.github.io/umriss-ui/charts/chart/#bands-limits-and-cells) and 11 more |
| `alignTicks` | `boolean` | — | On a further y axis: take the first y axis' tick count and widen this axis' domain until its ticks fall on that one's grid lines, the steps still 1-2-5 - one grid serves both. `tickCount` then does not apply; on the first y axis it changes nothing.<br>Shown in: [Align a second axis to the grid](https://romanhaendler.github.io/umriss-ui/charts/axis/#aligned-ticks) |
| `position` | `"left" \| "right"` | `"left"` | Which edge the axis stands at. Several y axes per side are stacked outwards, so that extents of clearly different magnitude stay readable.<br>Shown in: [Add axes for other magnitudes](https://romanhaendler.github.io/umriss-ui/charts/axis/#axes), [Align a second axis to the grid](https://romanhaendler.github.io/umriss-ui/charts/axis/#aligned-ticks), [Bind every kind of series to a second axis](https://romanhaendler.github.io/umriss-ui/charts/axis/#every-kind-on-a-second-axis) and 7 more |
| `id` | `string` | `"y"` | Axis id, through which series bind themselves.<br>Shown in: [Add axes for other magnitudes](https://romanhaendler.github.io/umriss-ui/charts/axis/#axes), [Align a second axis to the grid](https://romanhaendler.github.io/umriss-ui/charts/axis/#aligned-ticks), [Bind every kind of series to a second axis](https://romanhaendler.github.io/umriss-ui/charts/axis/#every-kind-on-a-second-axis) and 7 more |
| `label` | `string` | — | Axis title.<br>Shown in: [Fix the domain and name the ticks](https://romanhaendler.github.io/umriss-ui/charts/axis/#configuration), [Add axes for other magnitudes](https://romanhaendler.github.io/umriss-ui/charts/axis/#axes), [Leave out the hours nobody works](https://romanhaendler.github.io/umriss-ui/charts/axis/#working-time) and 66 more |
| `tickCount` | `number` | — | Target value; the 1-2-5 algorithm may deviate.<br>Shown in: [Fix the domain and name the ticks](https://romanhaendler.github.io/umriss-ui/charts/axis/#configuration), [Leave out the hours nobody works](https://romanhaendler.github.io/umriss-ui/charts/axis/#working-time), [Mix series kinds (Chart)](https://romanhaendler.github.io/umriss-ui/charts/chart/#mixed) and 8 more |
| `tickFormat` | `(v: number) => string` | — | Label of a tick. Compared by its source text: one that reads a changed closure variable under the same text does not relabel the axis - give it a new text or remount. A bound `Intl.NumberFormat#format` is compared by identity.<br>Shown in: [Fix the domain and name the ticks](https://romanhaendler.github.io/umriss-ui/charts/axis/#configuration), [Leave out the hours nobody works](https://romanhaendler.github.io/umriss-ui/charts/axis/#working-time), [Bind every kind of series to a second axis](https://romanhaendler.github.io/umriss-ui/charts/axis/#every-kind-on-a-second-axis) and 21 more |
| `grid` | `boolean` | only on the first axis of its orientation | Grid lines.<br>Shown in: [One vehicle through the day (StateBand)](https://romanhaendler.github.io/umriss-ui/charts/stateband/#one-vehicle), [Give each one a lane (StateBand)](https://romanhaendler.github.io/umriss-ui/charts/stateband/#one-lane-each), [See where the vans stand idle (Scenarios)](https://romanhaendler.github.io/umriss-ui/charts/#see-where-the-vans-stand-idle) |
| `ticks` | `readonly number[]` | — | Fixed tick values instead of the 1-2-5 algorithm, on a numeric axis: an hour axis' named hours, a lane grid. An axis that reads categories needs none - every category has its tick.<br>Shown in: [Bind every kind of series to a second axis](https://romanhaendler.github.io/umriss-ui/charts/axis/#every-kind-on-a-second-axis), [Mark bands, limits and cells (Chart)](https://romanhaendler.github.io/umriss-ui/charts/chart/#bands-limits-and-cells), [Compare progress with a plan (Line)](https://romanhaendler.github.io/umriss-ui/charts/line/#burn-down) and 7 more |
| `categories` | `readonly string[]` | — | The categories of the axis, in their order. Without it they stand in the order the rows first bring them. With it the list is the order and the extent: a category no row carries keeps its empty place, and a row whose category is not listed is not drawn - DEV names it. It makes the axis a category axis whatever the rows hold ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)).<br>Shown in: [Fix the order and keep an empty place](https://romanhaendler.github.io/umriss-ui/charts/axis/#categories) |
| `categoryLabel` | `(category: string) => string` | the category itself | What is written for a category - under its tick, in the tooltip, the readout and the data table - where the rows hold a key and not a name. Two categories may share a label and keep two places. Compared by its source text, as `tickFormat` is.<br>Shown in: [Fix the order and keep an empty place](https://romanhaendler.github.io/umriss-ui/charts/axis/#categories), [Tell series apart without colour (Chart)](https://romanhaendler.github.io/umriss-ui/charts/chart/#told-apart-without-colour), [Legend above or below (Legend)](https://romanhaendler.github.io/umriss-ui/charts/legend/#above-or-below) and 1 more |

### Types on this page

#### `AxisField<T, V = AxisValue>`

The fields of a row an axis can read - or, with `V`, those that hold only
some of what an axis reads: a matrix' row is a position or a category,
never an instant.

```ts
type AxisField<T, V = AxisValue> = NoInfer<
  {
    [K in keyof T & string]-?: T[K] extends V ? K : never;
  }[keyof T & string]
>;
```

#### `AxisValue`

What an axis reads from a row ([ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)): a number - a position, or an
instant on a time axis -, a string, which is a category, or a `Date`,
which is an instant. The type of the value decides what kind the axis is,
never its content: `"2024"` is a category, `2024` a number. On an axis
that says `time` a string is an ISO 8601 instant. null/undefined is no
place.

```ts
type AxisValue = number | string | Date | null | undefined;
```

#### `WorkingInterval`

An interval in which time counts; thought of as half-open, but mappable from
both sides at its edges: both `from` and `to` have a working time.

| Prop | Type | Default | Description |
|---|---|---|---|
| `from` *required* | `number` | — |  |
| `to` *required* | `number` | — |  |

#### `ZoomLimits`

The narrowest and the widest span zoom may reach, in the axis' units - the
schedule's shape.

| Prop | Type | Default | Description |
|---|---|---|---|
| `min` *required* | `number` | — | <br>Shown in: [Limit the zoom (Zoom and pan)](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#zoom-limits) |
| `max` *required* | `number` | — | <br>Shown in: [Limit the zoom (Zoom and pan)](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#zoom-limits) |

## Known limits

- No logarithmic and no band scale: compute the logarithm in `value`; a category is a place on the linear scale ([ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/), [ADR-0052](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0052-a-chart-reads-its-rows-as-they-come.md)).
- umriss shows the reader's time ([ADR-0060](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0060-validation-and-a-layout-editor-are-never-time-zones-not-yet.md)).

What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/).
