Axis

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).

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

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); a working calendar maps time before it is drawn and marks every seam it took out (ADR-0001).

An x axis zooms where it is zoomable; domain is then where it starts. That has a page of its own, 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.

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.

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.

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.

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.

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.

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.

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 apply here.

API

XAxisProps<T>

NameTypeDefaultDescription
valuerequiredAxisField<T> | ((d: T, index: number) => 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). 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" />.

Shown in: Fix the domain and name the ticks, Add axes for other magnitudes, Leave out the hours nobody works 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 Dates, 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.

Shown in: Bind every kind of series to a second axis, Read it by keyboard and screen reader (Chart), Gestures and keys (Zoom and pan) 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.

Shown in: Add axes for other magnitudes, Bind every kind of series to a second axis

timeboolean—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.

Shown in: Align a second axis to the grid, Draw a first chart (Installation), Size a chart by its container (Installation) and 36 more

calendarreadonly 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).

Shown in: Leave out the hours nobody works

zoomablebooleanfalseZoom 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.

Shown in: Read it by keyboard and screen reader (Chart), Gestures and keys (Zoom and pan), Limit the zoom (Zoom and pan) and 6 more

zoomLimitsZoomLimitsat most the data's extent or this axis' own domain, whichever is wider, at least three data stepsThe narrowest and the widest span zoom may reach, in the axis' units - milliseconds on a time axis.

Shown in: Limit the zoom (Zoom and pan)

idstring"x"Axis id, through which series bind themselves.

Shown in: Add axes for other magnitudes, Align a second axis to the grid, Bind every kind of series to a second axis and 7 more

labelstring—Axis title.

Shown in: Fix the domain and name the ticks, Add axes for other magnitudes, Leave out the hours nobody works and 66 more

tickCountnumber—Target value; the 1-2-5 algorithm may deviate.

Shown in: Fix the domain and name the ticks, Leave out the hours nobody works, Mix series kinds (Chart) 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.

Shown in: Fix the domain and name the ticks, Leave out the hours nobody works, Bind every kind of series to a second axis and 21 more

gridbooleanonly on the first axis of its orientationGrid lines.

Shown in: One vehicle through the day (StateBand), Give each one a lane (StateBand), See where the vans stand idle (Scenarios)

ticksreadonly 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.

Shown in: Bind every kind of series to a second axis, Mark bands, limits and cells (Chart), Compare progress with a plan (Line) and 7 more

categoriesreadonly 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).

Shown in: Fix the order and keep an empty place

categoryLabel(category: string) => stringthe category itselfWhat 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.

Shown in: Fix the order and keep an empty place, Tell series apart without colour (Chart), Legend above or below (Legend) and 1 more

YAxisProps

NameTypeDefaultDescription
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.

Shown in: Fix the domain and name the ticks, Bind every kind of series to a second axis, Mark bands, limits and cells (Chart) and 11 more

alignTicksboolean—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.

Shown in: Align a second axis to the grid

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.

Shown in: Add axes for other magnitudes, Align a second axis to the grid, Bind every kind of series to a second axis and 7 more

idstring"y"Axis id, through which series bind themselves.

Shown in: Add axes for other magnitudes, Align a second axis to the grid, Bind every kind of series to a second axis and 7 more

labelstring—Axis title.

Shown in: Fix the domain and name the ticks, Add axes for other magnitudes, Leave out the hours nobody works and 66 more

tickCountnumber—Target value; the 1-2-5 algorithm may deviate.

Shown in: Fix the domain and name the ticks, Leave out the hours nobody works, Mix series kinds (Chart) 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.

Shown in: Fix the domain and name the ticks, Leave out the hours nobody works, Bind every kind of series to a second axis and 21 more

gridbooleanonly on the first axis of its orientationGrid lines.

Shown in: One vehicle through the day (StateBand), Give each one a lane (StateBand), See where the vans stand idle (Scenarios)

ticksreadonly 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.

Shown in: Bind every kind of series to a second axis, Mark bands, limits and cells (Chart), Compare progress with a plan (Line) and 7 more

categoriesreadonly 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).

Shown in: Fix the order and keep an empty place

categoryLabel(category: string) => stringthe category itselfWhat 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.

Shown in: Fix the order and keep an empty place, Tell series apart without colour (Chart), Legend above or below (Legend) 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.

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): 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.

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.

NameTypeDefaultDescription
fromrequirednumber—
torequirednumber—

ZoomLimits

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

NameTypeDefaultDescription
minrequirednumber—

Shown in: Limit the zoom (Zoom and pan)

maxrequirednumber—

Shown in: Limit the zoom (Zoom and pan)

Known limits

What umriss deliberately does not build, and why: ADR-0032.

Every page of @umriss-ui/charts

Getting started

Chart

Series

Limits and alarms

Around the chart

API index