# View

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

How a reader is looking at a chart - the span in view and the hidden series - kept by the chart, reported whole, and gone back to with `setView` to restore it (saved state, a bookmark, a reload).

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

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

A chart holds its own view ([ADR-0047](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0047-a-component-holds-its-own-view.md)): `{ domains, hidden }` - the span each zoomable x axis shows, by axis `id`, and the hidden series, by `name`. Whatever is at its default is absent, so a chart nobody touched reports `{}`.

`useChart(rows, { initialView, onViewChange })`: `onViewChange` hears every change the reader makes once, always the whole view; `initialView` is the start, read once - a different value on a later render is ignored; `setView(view)` goes to a whole view later and is not reported. An axis id or a series name that no longer occurs falls out. Where a view is kept - state, storage, an address - is the application's decision; the chart remembers none.

## Examples

### Keep and restore a view

`onViewChange` reports the span in view and the hidden series as one view; keep it - here in the browser's storage, so it outlives a reload - and go back to it with `setView`. Zoom, hide a series, keep, reload, restore.

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

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

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

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

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

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

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

const SERIES = [
  { name: "Checkout", data: metrics("checkout") },
  { name: "Billing", data: metrics("billing") },
  { name: "Search", data: metrics("search") },
];

const KEPT = "umriss-charts-demo-view";

/* Storage can be missing or refuse - a private window, a blocked site. The
   view is then simply not kept. */
function readKept(): ChartView | null {
  try {
    const stored = localStorage.getItem(KEPT);
    return stored === null ? null : (JSON.parse(stored) as ChartView);
  } catch {
    return null;
  }
}

function keep(view: ChartView): void {
  try {
    localStorage.setItem(KEPT, JSON.stringify(view));
  } catch {
    /* Not kept; the chart works on regardless. */
  }
}

export default function KeepAndRestore() {
  /* The chart holds its view; `view` is what it shows now, and `setView`
     restores a kept one. */
  const [kept, setKept] = useState<ChartView | null>(readKept);
  const { Chart, XAxis, YAxis, Line, view, setView } = useChart(SERIES[0]!.data);
  return (
    <>
      <Chart height={260} ariaLabel="Latency of three services today">
        <XAxis value="t" time zoomable />
        <YAxis label="ms" domain="visible" />
        {SERIES.map((one) => (
          <Line key={one.name} data={one.data} value="p95" name={one.name} />
        ))}
        <Legend />
        <Tooltip mode="x" />
      </Chart>
      <div className="demo-kept">
        <p className="pair-caption">What the application would keep</p>
        {/* JSON has no space to break at: the class lets it break anywhere. */}
        <code className="demo-view" data-role="view">
          {JSON.stringify(view)}
        </code>
      </div>
      <div className="demo-actions">
        <button
          type="button"
          className="demo-button"
          onClick={() => {
            keep(view);
            setKept(view);
          }}
          data-keep
        >
          Keep this view
        </button>
        <button type="button" className="demo-button" disabled={kept === null} onClick={() => kept && setView(kept)} data-restore>
          Restore the kept view
        </button>
        <button type="button" className="demo-button" onClick={() => setView({})} data-start-over>
          Start over
        </button>
      </div>
    </>
  );
}
```

## Keyboard

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

## API

### `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 (Zoom and pan)](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 (Zoom and pan)](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/view/#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) |

### `ChartView`

| Prop | Type | Default | Description |
|---|---|---|---|
| `domains` | [`AxisSpans`](https://romanhaendler.github.io/umriss-ui/charts/view/#type-AxisSpans) | — | The span each zoomable x axis shows, by axis `id` - a lone x axis is `"x"`. An axis absent here shows its own `domain`. An id that names no zoomable x axis falls out.<br>Shown in: [Fit the y axis to what is visible (Zoom and pan)](https://romanhaendler.github.io/umriss-ui/charts/zoom-and-pan/#visible-domain) |
| `hidden` | `readonly string[]` | — | The hidden series, by `name`; a series without one cannot be hidden. A name no series carries falls out.<br>Shown in: [Hide a series from the legend (Legend)](https://romanhaendler.github.io/umriss-ui/charts/legend/#hide-a-series) |

### Types on this page

#### `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/view/#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. |

#### `AxisSpans`

The span each zoomable x axis shows, by axis `id`, in the axis' units.

```ts
type AxisSpans = Readonly<Record<string, readonly [number, number]>>;
```

#### `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/view/#type-BoxNumbers)` & Partial<`[`BoxExtras`](https://romanhaendler.github.io/umriss-ui/charts/view/#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

- The active point and the open data table are not part of the view: they are where the reader is, not how the chart is looked at.

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