# Zoom and pan

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

Look closer at a stretch of a long series and move along it (zoom, pan, a viewport), by wheel, pinch, drag or key, and keep several charts on the same stretch.

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

Demo page: https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/

`zoomable` on an x axis turns the gestures and keys on; nothing else is needed. The span shown is part of the chart's view ([ADR-0047](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0047-a-component-holds-its-own-view.md)): `useChart(rows, { initialView, onViewChange })` takes a start and reports what the reader does, whole, at most once per frame; `setView(view)`, `domains` and `setDomain(axisId, span | null)` stand on what the hook returns. A lone x axis is `"x"`.

Once an axis is zoomed, the chart offers 'Show all' over a corner of its plot - a button of its own, a tab stop beside the plot's, that brings every zoomed axis back and goes. Its words are the wording's `showAll`.

Charts stay in step by sending each other what the reader did: each chart's `onViewChange` calls the other's `setView`. `setView` is not reported, so two charts linked both ways do not answer each other, and a pan does not jump back. `syncId` shares only the pointer.

A zoom stops at `zoomLimits`; without them at three readings and at the data's extent. A y axis with `domain="visible"` follows the stretch shown.

## Examples

### Gestures and keys

With `zoomable` the x axis zooms on Ctrl or ⌘ with the wheel, a pinch or + and −, pans on a drag or Shift with the arrows, and goes back on a double click, 0 or 'Show all' - without a line of state of yours.

```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;

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", 60_000);

export default function GesturesAndKeys() {
  const { Chart, XAxis, YAxis, Line } = useChart(SEARCH_WEEK);
  return (
    <Chart height={260} ariaLabel="Search latency over last week, a reading a minute, zoomable">
      {/* The span shown is the chart's own: `domain` is where it starts. */}
      <XAxis value="t" time domain="data" zoomable />
      <YAxis label="ms" />
      <Line value="p95" name="p95" />
      <Tooltip mode="x" />
    </Chart>
  );
}
```

### Limit the zoom

`zoomLimits` keeps the span between a narrowest and a widest; without it a zoom stops at three readings and at the data's extent.

```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;

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", 60_000);

export default function ZoomLimits() {
  const { Chart, XAxis, YAxis, Line } = useChart(SEARCH_WEEK);
  return (
    <Chart height={260} ariaLabel="Search latency over last week, zoomable between an hour and two days">
      {/* Never closer than an hour, never wider than two days - the week
          shows at the start and once more after a double click. */}
      <XAxis value="t" time domain="data" zoomable zoomLimits={{ min: HOUR, max: 2 * DAY }} />
      <YAxis label="ms" />
      <Line value="p95" name="p95" />
      <Tooltip mode="x" />
    </Chart>
  );
}
```

### Fit the y axis to what is visible

With `domain="visible"` the y axis fits the zoomed stretch - here Saturday evening's outage, handed in as `initialView` - and follows when you zoom out.

```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;

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", 60_000);
const SATURDAY = new Date(2026, 2, 14).getTime();

export default function VisibleDomain() {
  const { Chart, XAxis, YAxis, Line } = useChart(SEARCH_WEEK, {
    // The lone x axis is "x": its span is where the chart starts.
    initialView: { domains: { x: [SATURDAY + 16 * HOUR, SATURDAY + 22 * HOUR] } },
  });
  return (
    <Chart height={260} ariaLabel="Search latency on Saturday evening, the y axis fitted to it">
      <XAxis value="t" time domain="data" zoomable />
      <YAxis label="ms" domain="visible" />
      <Line value="p95" name="p95" />
      <Tooltip mode="x" />
    </Chart>
  );
}
```

### Keep charts in step

Charts with one `syncId` share the pointer; send each one's `onViewChange` to the others' `setView`, and a zoom in any of them zooms all.

```tsx
import { useEffect, useState } from "react";
import { Tooltip, useChart, type ChartView } 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 IMAGES_WEEK = week("images", 5 * 60_000);

const CHANNELS = [
  { name: "Requests", unit: "/min", value: "requests" },
  { name: "p95", unit: "ms", value: "p95" },
  { name: "p50", unit: "ms", value: "p50" },
] as const;

export default function CursorSync() {
  // The three share a view: each sends the others what the reader did to it.
  const [peers] = useState(() => new Map<string, (view: ChartView) => void>());
  return (
    <div>
      {/* Each plot begins where its y axis ends: labels of one width - three
          figures here - keep the crosshairs in one column. */}
      {CHANNELS.map((c) => (
        <Channel key={c.name} channel={c} peers={peers} />
      ))}
    </div>
  );
}

function Channel({ channel: c, peers }: { channel: (typeof CHANNELS)[number]; peers: Map<string, (view: ChartView) => void> }) {
  const { Chart, XAxis, YAxis, Line, setView } = useChart(IMAGES_WEEK, {
    onViewChange: (view) => {
      for (const [name, goTo] of peers) if (name !== c.name) goTo(view);
    },
  });
  useEffect(() => {
    peers.set(c.name, setView);
    return () => void peers.delete(c.name);
  }, [peers, c.name, setView]);
  return (
    <Chart height={140} syncId="images" ariaLabel={`Image service ${c.name} over last week`}>
      <XAxis value="t" time domain="data" zoomable />
      <YAxis label={c.unit} domain="visible" tickCount={4} />
      <Line value={c.value} name={c.name} />
      <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/zoom-and-pan/#type-AxisField)`<T> \| ((d: T, index: number) => `[`AxisValue`](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#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: [Gestures and keys](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#gestures-and-keys), [Limit the zoom](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#zoom-limits), [Fit the y axis to what is visible](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#visible-domain) 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: [Gestures and keys](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#gestures-and-keys), [Limit the zoom](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#zoom-limits), [Fit the y axis to what is visible](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#visible-domain) 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 (Axis)](https://romanhaendler.github.io/umriss-ui/charts/axis/#axes), [Bind every kind of series to a second axis (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: [Gestures and keys](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#gestures-and-keys), [Limit the zoom](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#zoom-limits), [Fit the y axis to what is visible](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#visible-domain) and 36 more |
| `calendar` | `readonly `[`WorkingInterval`](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#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 (Axis)](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: [Gestures and keys](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#gestures-and-keys), [Limit the zoom](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#zoom-limits), [Fit the y axis to what is visible](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#visible-domain) and 6 more |
| `zoomLimits` | [`ZoomLimits`](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#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](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 (Axis)](https://romanhaendler.github.io/umriss-ui/charts/axis/#axes), [Align a second axis to the grid (Axis)](https://romanhaendler.github.io/umriss-ui/charts/axis/#aligned-ticks), [Bind every kind of series to a second axis (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: [Gestures and keys](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#gestures-and-keys), [Limit the zoom](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#zoom-limits), [Fit the y axis to what is visible](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#visible-domain) and 66 more |
| `tickCount` | `number` | — | Target value; the 1-2-5 algorithm may deviate.<br>Shown in: [Keep charts in step](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#cursor-sync), [Fix the domain and name the ticks (Axis)](https://romanhaendler.github.io/umriss-ui/charts/axis/#configuration), [Leave out the hours nobody works (Axis)](https://romanhaendler.github.io/umriss-ui/charts/axis/#working-time) 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 (Axis)](https://romanhaendler.github.io/umriss-ui/charts/axis/#configuration), [Leave out the hours nobody works (Axis)](https://romanhaendler.github.io/umriss-ui/charts/axis/#working-time), [Bind every kind of series to a second axis (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 (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 (Axis)](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 (Axis)](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 |

### `ChartOptions<Z = unknown>`

| Prop | Type | Default | Description |
|---|---|---|---|
| `initialView` | [`ChartView`](https://romanhaendler.github.io/umriss-ui/charts/view/#type-ChartView) | — | The view to start from, read once when the hook first runs; a different value on a later render is ignored, as React ignores a changed `defaultValue`. To go to a view later, call `setView`. The chart remembers none: where a view is kept is the application's decision.<br>Shown in: [Fit the y axis to what is visible](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#visible-domain), [Hide a series from the legend (Legend)](https://romanhaendler.github.io/umriss-ui/charts/legend/#hide-a-series) |

#### Events

| Prop | Type | Default | Description |
|---|---|---|---|
| `onViewChange` | `(view: `[`ChartView`](https://romanhaendler.github.io/umriss-ui/charts/view/#type-ChartView)`) => void` | — | Every change the reader makes, once, always the whole view - the one to keep, or to hand another chart's `setView`. A pan or zoom is reported at most once per frame. Not called for the view the chart starts with, nor for one `setView` applies.<br>Shown in: [Keep charts in step](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#cursor-sync), [Read it by keyboard and screen reader (Chart)](https://romanhaendler.github.io/umriss-ui/charts/chart/#keyboard-and-screen-reader) |
| `onSelect` | `(hit: `[`TooltipHit`](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#type-TooltipHit)`<Z>) => void` | — | The reader chose a point: a click on a hit (a tap too), or Enter on the Active point - the same hit the tooltip's `render` receives. Once per click; the end of a drag does not report, and the second click of a double click does not either. The chart holds no selection and draws none: what is chosen is the application's state. Needs a `<Tooltip>`, which makes the hits; the hit does not travel over `syncId`.<br>Shown in: [Choose a hit (Tooltip)](https://romanhaendler.github.io/umriss-ui/charts/tooltip/#choose-a-hit) |

### 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](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#zoom-limits) |
| `max` *required* | `number` | — | <br>Shown in: [Limit the zoom](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#zoom-limits) |

#### `TooltipHit<T = unknown>`

What the pointer hit: the x value under it and one point per series there.
A custom tooltip's `render` receives it.

| Prop | Type | Default | Description |
|---|---|---|---|
| `xValue` *required* | `number` | — | <br>Shown in: [Fix the domain and name the ticks (Axis)](https://romanhaendler.github.io/umriss-ui/charts/axis/#configuration), [Write the tooltip yourself (Tooltip)](https://romanhaendler.github.io/umriss-ui/charts/tooltip/#own-content), [Choose a hit (Tooltip)](https://romanhaendler.github.io/umriss-ui/charts/tooltip/#choose-a-hit) |
| `points` *required* | `readonly `[`TooltipPoint`](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#type-TooltipPoint)`<T>[]` | — | <br>Shown in: [Fix the domain and name the ticks (Axis)](https://romanhaendler.github.io/umriss-ui/charts/axis/#configuration), [Write the tooltip yourself (Tooltip)](https://romanhaendler.github.io/umriss-ui/charts/tooltip/#own-content), [Choose a hit (Tooltip)](https://romanhaendler.github.io/umriss-ui/charts/tooltip/#choose-a-hit) |
| `xPx` *required* | `number` | — | Pixel position of the crosshair (snapped to the data point). |
| `yPx` *required* | `number` | — | Pixel position of the primary hit. |

#### `TooltipPoint<T = unknown>`

One hit per series in the tooltip.

| Prop | Type | Default | Description |
|---|---|---|---|
| `seriesName` *required* | `string` | — | <br>Shown in: [Fix the domain and name the ticks (Axis)](https://romanhaendler.github.io/umriss-ui/charts/axis/#configuration), [Write the tooltip yourself (Tooltip)](https://romanhaendler.github.io/umriss-ui/charts/tooltip/#own-content), [Choose a hit (Tooltip)](https://romanhaendler.github.io/umriss-ui/charts/tooltip/#choose-a-hit) |
| `color` *required* | `string` | — |  |
| `xValue` *required* | `number` | — | The point's x value on its own x axis - with several x axes it can differ from the hit's. |
| `yValue` *required* | `number` | — | The point's y value; in a stack its own value, not its top - in a normalised stack its share in percent.<br>Shown in: [Fix the domain and name the ticks (Axis)](https://romanhaendler.github.io/umriss-ui/charts/axis/#configuration), [Choose a hit (Tooltip)](https://romanhaendler.github.io/umriss-ui/charts/tooltip/#choose-a-hit) |
| `datum` *required* | `T` | — | <br>Shown in: [Write the tooltip yourself (Tooltip)](https://romanhaendler.github.io/umriss-ui/charts/tooltip/#own-content) |
| `index` *required* | `number` | — |  |
| `level` | `number` | — | Only for the matrix: its level, out of the level channel. It stands here and not in yValue, because yValue is the position on the y axis - for a matrix therefore the row. Putting both into one field would be exactly the overloading [ADR-0011](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0011-named-channels-never-overloaded.md) is written against. |
| `segment` | `{ from: number; to: number; label: string }` | — | Only for state series: the section being pointed at. A duration does not stand here - it is the difference of two numbers the entry already carries, and formatting it would presuppose knowing what the x axis means. The caller knows that, not this library. |
| `box` | [`BoxNumbers`](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#type-BoxNumbers)` & Partial<`[`BoxExtras`](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#type-BoxExtras)`` > & { /** As the caller gave them; only where the series has `outliers`. */ outliers?: readonly number[]; } `` | — | Only for a box: its further numbers; yValue is its median. Fields of their own for the reason `level` has one ([ADR-0011](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0011-named-channels-never-overloaded.md)). |

#### `BoxNumbers<V = number>`

A box's further numbers beside its median, by name.

| Prop | Type | Default | Description |
|---|---|---|---|
| `lowerQuartile` *required* | `V` | — |  |
| `upperQuartile` *required* | `V` | — |  |
| `lowerWhisker` *required* | `V` | — |  |
| `upperWhisker` *required* | `V` | — |  |

#### `BoxExtras<V = number>`

The optional numbers of a box by name.

| Prop | Type | Default | Description |
|---|---|---|---|
| `mean` *required* | `V` | — |  |
| `notchLower` *required* | `V` | — |  |
| `notchUpper` *required* | `V` | — |  |
| `count` *required* | `V` | — |  |

## Known limits

- No zoom along y, no rectangle zoom and no navigator: the x span is the one zoom there is.

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