# Margin Note: Annotated Line Chart for React

> A line chart that reads itself. A reader sweeps across the series and inks it as it goes; wherever something happened it stops, marks the point and writes a note in the margin, level with the point and tied to it by a leader. Hover a note and its stretch of the chart lights up.

Annotated Line Chart for React: it draws itself and notes key points in the margin. Built with Tailwind CSS and Motion; install it with the shadcn CLI. It's a KeyframeUI component: the file lands in your project as source, yours to edit.

- Docs and live preview: https://keyframeui.com/components/margin-note
- Tags: Chart, Data, AI
- npm dependencies: motion, clsx, tailwind-merge

## Installation

Register the @keyframeui registry in `components.json` once per project:

```json
{
  "registries": {
    "@keyframeui": "https://keyframeui.com/r/{name}.json"
  }
}
```

Then add the component. It lands in `components/ui/margin-note.tsx`, and the CLI also adds `lib/utils.ts` (the `cn` helper) and installs the npm dependencies:

```bash
npx shadcn@latest add @keyframeui/margin-note
```

Or install by URL, without registering the namespace:

```bash
npx shadcn@latest add https://keyframeui.com/r/margin-note.json
```

## Usage

The main demo shows typical usage.

### Weekly Signups

Sixteen weeks of signups for a design tool, read with notes on launch week, a login outage and the free plan going to three seats.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/margin-note-demo
```

```tsx title="components/margin-note-demo.tsx"
"use client";

import * as React from "react";

import { MarginNote, type MarginNoteNote } from "@/components/ui/margin-note";

const WEEKS = [
  "Jul 7", "Jul 14", "Jul 21", "Jul 28", "Aug 4", "Aug 11", "Aug 18", "Aug 25",
  "Sep 1", "Sep 8", "Sep 15", "Sep 22", "Sep 29", "Oct 6", "Oct 13", "Oct 20",
];
const SIGNUPS = [612, 640, 655, 1720, 1180, 905, 880, 862, 498, 870, 892, 1150, 1205, 1238, 1262, 1290];

const NOTES: MarginNoteNote[] = [
  { at: 3, title: "Launch week", body: "1,720 signups, 2.6× a normal week.", delta: "+163%" },
  { at: 8, title: "Login outage", body: "Sign-in was down for six hours on Tuesday.", delta: "−42%" },
  { at: 11, to: 14, title: "Free plan, three seats", body: "Signups held 38% higher for four weeks.", delta: "+38%" },
];

export function MarginNoteDemo() {
  const [run, setRun] = React.useState(0);
  return (
    <div className="w-full max-w-3xl rounded-2xl bg-white p-5 text-zinc-900 shadow-sm ring-1 ring-zinc-950/[0.08] sm:p-7 dark:bg-zinc-900 dark:text-zinc-100 dark:ring-white/10">
      <div className="mb-5 flex items-start justify-between gap-4">
        <div>
          <p className="text-xs font-medium text-zinc-500 dark:text-zinc-400">Kiln · Weekly signups</p>
          <p className="mt-1 flex items-baseline gap-2">
            <span className="text-2xl font-semibold tracking-tight tabular-nums">15,859</span>
            <span className="text-xs text-zinc-500 dark:text-zinc-400">last 16 weeks</span>
          </p>
        </div>
        <button
          type="button"
          onClick={() => setRun((r) => r + 1)}
          className="inline-flex h-8 items-center gap-1.5 rounded-full px-3 text-xs font-medium text-zinc-600 ring-1 ring-zinc-950/10 transition-colors hover:bg-zinc-50 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-950 dark:text-zinc-300 dark:ring-white/15 dark:hover:bg-white/5 dark:hover:text-white dark:focus-visible:outline-white"
        >
          <svg viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth={1.6} aria-hidden>
            <path d="M2.75 8a5.25 5.25 0 1 0 1.6-3.77M2.75 2.75v2.5h2.5" strokeLinecap="round" strokeLinejoin="round" />
          </svg>
          Read again
        </button>
      </div>
      <MarginNote
        key={run}
        label="Kiln weekly signups"
        data={SIGNUPS}
        labels={WEEKS}
        notes={NOTES}
        summary="Signups are up 38% since the free plan went to three seats."
        format={(value) => value.toLocaleString("en-US")}
      />
    </div>
  );
}
```

## Examples

Finished sections built with Margin Note. Each one installs whole, with the component included.

### Incident Read

A day of p95 latency on an API. The reader stops at the morning peak, the deploy that sent it to 842 ms and the rollback that brought it down.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/margin-note-latency-demo
```

```tsx title="components/margin-note-latency-demo.tsx"
"use client";

import * as React from "react";

import { MarginNote, type MarginNoteNote } from "@/components/ui/margin-note";

const HOURS = Array.from({ length: 24 }, (_, h) => `${String(h).padStart(2, "0")}:00`);
const P95 = [182, 176, 171, 169, 172, 180, 196, 214, 229, 238, 231, 224, 219, 221, 842, 516, 207, 199, 194, 190, 188, 185, 183, 181];

const NOTES: MarginNoteNote[] = [
  { at: 9, title: "Morning peak", body: "EU traffic tops out; p95 stays under 240 ms.", delta: "238 ms" },
  { at: 14, title: "Deploy 4812", body: "An N+1 query in /orders sent p95 to 842 ms.", delta: "+281%" },
  { at: 15, to: 16, title: "Rolled back to 4811", body: "Under 210 ms again by 16:00.", delta: "−75%" },
];

export function MarginNoteLatencyDemo() {
  const [run, setRun] = React.useState(0);
  return (
    <div className="w-full max-w-3xl overflow-hidden rounded-2xl bg-white text-zinc-900 shadow-sm ring-1 ring-zinc-950/[0.08] dark:bg-zinc-950 dark:text-zinc-100 dark:ring-white/10">
      <div className="flex items-center justify-between gap-3 border-b border-zinc-950/[0.06] px-5 py-3 dark:border-white/[0.08]">
        <div className="flex min-w-0 items-center gap-2.5">
          <span className="size-2 shrink-0 rounded-full bg-emerald-500" />
          <p className="truncate font-mono text-xs text-zinc-600 dark:text-zinc-400">
            api.harbor.dev <span className="text-zinc-400 dark:text-zinc-600">/</span> p95 latency{" "}
            <span className="text-zinc-400 dark:text-zinc-600">/</span> today
          </p>
        </div>
        <div className="flex shrink-0 items-center gap-2">
          <span className="hidden rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-700 sm:inline dark:text-emerald-400">
            Resolved 16:04
          </span>
          <button
            type="button"
            onClick={() => setRun((r) => r + 1)}
            className="rounded-md px-2 py-1 font-mono text-[11px] text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-zinc-950 dark:hover:bg-white/10 dark:hover:text-white dark:focus-visible:outline-white"
          >
            replay
          </button>
        </div>
      </div>
      <div className="p-5 sm:p-6">
        <MarginNote
          key={run}
          label="p95 latency for api.harbor.dev today"
          data={P95}
          labels={HOURS}
          notes={NOTES}
          summary="One deploy cost two slow hours at checkout. Rolling it back fixed it."
          format={(value) => `${Math.round(value)} ms`}
          className="[--margin-note-accent:#e11d48] dark:[--margin-note-accent:#fb7185]"
        />
      </div>
    </div>
  );
}
```

### Year Against Year

Monthly revenue against last year's, dashed underneath, with notes on annual plans, a team tier and a record December.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/margin-note-revenue-demo
```

```tsx title="components/margin-note-revenue-demo.tsx"
"use client";

import * as React from "react";

import { MarginNote, type MarginNoteNote } from "@/components/ui/margin-note";

const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const THIS_YEAR = [42, 45, 58, 61, 63, 79, 86, 91, 88, 94, 102, 131];
const LAST_YEAR = [38, 39, 41, 43, 44, 46, 47, 49, 48, 52, 55, 70];

const NOTES: MarginNoteNote[] = [
  { at: 2, title: "Annual plans", body: "Two months free on yearly billing.", delta: "+29%" },
  { at: 5, to: 7, title: "Team tier", body: "Studios moved up; the average order rose to $212.", delta: "+25%" },
  { at: 11, title: "Gift cards", body: "The best December yet, 1.9× last year's.", delta: "+87%" },
];

export function MarginNoteRevenueDemo() {
  const [run, setRun] = React.useState(0);
  return (
    <div className="w-full max-w-3xl rounded-2xl bg-stone-50 p-5 text-stone-900 ring-1 ring-stone-950/[0.08] sm:p-7 dark:bg-stone-900 dark:text-stone-100 dark:ring-white/10">
      <div className="mb-5 flex flex-wrap items-end justify-between gap-x-6 gap-y-3">
        <div>
          <p className="text-xs font-medium text-stone-500 dark:text-stone-400">Tinta · Revenue by month</p>
          <p className="mt-1 flex items-baseline gap-2">
            <span className="text-2xl font-semibold tracking-tight tabular-nums">$940k</span>
            <span className="text-xs font-medium text-emerald-700 tabular-nums dark:text-emerald-400">+64% on 2024</span>
          </p>
        </div>
        <div className="flex items-center gap-4 text-xs text-stone-500 dark:text-stone-400">
          <span className="flex items-center gap-1.5">
            <svg width="16" height="2" aria-hidden>
              <line x1="0" y1="1" x2="16" y2="1" stroke="currentColor" strokeWidth="1.5" className="text-stone-900 dark:text-stone-100" />
            </svg>
            2025
          </span>
          <span className="flex items-center gap-1.5">
            <svg width="16" height="2" aria-hidden>
              <line x1="0" y1="1" x2="16" y2="1" stroke="currentColor" strokeWidth="1.25" strokeDasharray="3 4" opacity="0.6" />
            </svg>
            2024
          </span>
          <button
            type="button"
            onClick={() => setRun((r) => r + 1)}
            className="rounded-full px-2.5 py-1 font-medium text-stone-600 ring-1 ring-stone-950/10 transition-colors hover:bg-white hover:text-stone-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-stone-900 dark:text-stone-300 dark:ring-white/15 dark:hover:bg-white/5 dark:hover:text-white dark:focus-visible:outline-white"
          >
            Read again
          </button>
        </div>
      </div>
      <MarginNote
        key={run}
        label="Tinta monthly revenue, 2025 against 2024"
        data={THIS_YEAR}
        compare={LAST_YEAR}
        labels={MONTHS}
        notes={NOTES}
        summary="2025 closed 64% ahead of 2024, most of it after the team tier."
        format={(value) => (value === 0 ? "$0" : `$${value}k`)}
        className="[--margin-note-accent:#047857] dark:[--margin-note-accent:#34d399]"
      />
    </div>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `data` (required) | `number[]` |  | The series, one value per point, oldest first. |
| `labels` | `string[]` |  | A label per point, like a date. A few run under the chart, and the crosshair shows the one it's on. |
| `notes` | `MarginNoteNote[]` | `[]` | What the reader writes in the margin: `{ at, to?, title, body?, delta? }`. `at` is an index in `data`; give a `to` for a stretch of time and its band shades, with the note written at the stretch's end. They're numbered in reading order. |
| `summary` | `React.ReactNode` |  | The takeaway above the chart, written once the read ends. Plain text rises in word by word. |
| `format` | `(value: number) => string` |  | Formats values on the axis and under the crosshair. Defaults to compact numbers (1.2K). |
| `compare` | `number[]` |  | A second series to compare against, like last period. Drawn dashed and never read. |
| `play` | `boolean \| "view"` | `"view"` | `"view"` reads once the chart is 35% in view. `true` reads now, and again each time it flips back to `true`. `false` shows it already read. |
| `duration` | `number` | `2400` | How long the read takes, in ms, pauses at the notes included. |
| `hold` | `number` | `280` | How long the reader stops at each noted point, in ms, as if writing. The pauses never take more than 45% of `duration`. |
| `label` | `string` | `"Chart"` | What the chart shows, like "Weekly signups". Screen readers hear it first. |
| `onComplete` | `() => void` |  | Called when a read ends. |
| `className` | `string` |  | Classes for the root. Set `--margin-note-accent` for the reader, points, bands and numbers, and `--margin-note-height` for the plot's height (12.5rem narrow, 15rem wide). Ink comes from the text color. |

## What to keep

To adapt it, change its props first. When you do edit `components/ui/margin-note.tsx`, keep these, which make it feel and behave right:

- The read is one motion `animate` whose `onUpdate` writes the clip, the glow and the reader's transforms straight to the DOM; React re-renders only when the reader passes a noted point.
- The reader holds at each noted point, the holds never take more than `holdShare` of `duration`, and between them it sweeps at one steady, linear pace.
- Every note is rendered from the start, so the margin holds its room, and each is placed level with its point once measured; nothing shifts when the notes arrive.
- Inputs are keyed by content (`dataKey`, `notesKey`), so a re-render with equal inline arrays doesn't restart the read; new data or notes read again from the start.
- Under reduced motion or `play={false}` nothing sweeps: the chart arrives read with every note in place, and highlights and the crosshair still work.

Its timing (durations, delays, springs and easings) is all in the `TIMING` block at the top of the file; the timing props default to it.

## Source

```tsx title="components/ui/margin-note.tsx"
"use client";

import * as React from "react";
import { animate, motion, useReducedMotion } from "motion/react";

import { cn } from "@/lib/utils";

/**
 * Keep:
 * - The read is one motion `animate` whose `onUpdate` writes the clip, the glow and the reader's
 *   transforms straight to the DOM; React re-renders only when the reader passes a noted point.
 * - The reader holds at each noted point, the holds never take more than `holdShare` of `duration`,
 *   and between them it sweeps at one steady, linear pace.
 * - Every note is rendered from the start, so the margin holds its room, and each is placed level
 *   with its point once measured; nothing shifts when the notes arrive.
 * - Inputs are keyed by content (`dataKey`, `notesKey`), so a re-render with equal inline arrays
 *   doesn't restart the read; new data or notes read again from the start.
 * - Under reduced motion or `play={false}` nothing sweeps: the chart arrives read with every note
 *   in place, and highlights and the crosshair still work.
 */
const TIMING = {
  /** The whole read, pauses included, in ms (the `duration` prop). */
  duration: 2400,
  /** The reader stopping at each noted point, in ms (the `hold` prop). */
  hold: 280,
  /** The shortest a read can take, in ms; a shorter `duration` is raised to it. */
  shortest: 400,
  /** The reader's pace between holds. */
  sweepEase: "linear",
  /** A noted point popping in. */
  pop: { type: "spring", visualDuration: 0.3, bounce: 0.25 },
  /** Its ring spreading out and fading, in seconds. */
  ring: { duration: 0.6, ease: [0.22, 1, 0.36, 1] },
  /** The leader drawing out to the margin, in seconds. */
  leader: { duration: 0.32, ease: [0.22, 1, 0.36, 1], delay: 0.04 },
  /** The note sliding in behind it, after a delay in seconds. */
  note: { type: "spring", visualDuration: 0.3, bounce: 0, delay: 0.1 },
  /** Between summary words, in seconds. */
  word: 0.035,
  /** The longest the summary's stagger runs, in seconds. */
  wordSpread: 0.3,
  /** A summary word rising. */
  rise: { type: "spring", visualDuration: 0.3, bounce: 0 },
  /** The reader fading once the read ends, in seconds. */
  reader: { duration: 0.25, ease: "easeOut" },
  /** Highlights and dimming, in seconds. */
  light: { duration: 0.15, ease: "easeOut" },
  /** The same highlights where CSS runs them: a point's halo, a leader's stroke, a note dimming. */
  lightClass: "duration-150",
} as const;

/* ─────────────────────────────────────────────────────────
 * MARGIN NOTE STORYBOARD
 *
 *  rest     the chart's frame: four hairlines with their values,
 *           a few dates along the bottom and the series in a
 *           faint print, not read yet. The margin beside it is
 *           empty but already holds its room, so nothing moves
 *           when the notes arrive
 *  view     35% in view, a reader (a hairline, a dot riding the
 *           series and a soft glow behind it) sweeps left to
 *           right at a steady pace, inking the line and its fill
 *           as it passes. 2.4s for the whole read, pauses
 *           included
 *  note     at a noted point the reader stops for 280ms, as if
 *           writing. The point pops (scale 0.6 → 1 with a ring
 *           spreading out and fading), the leader draws from it
 *           to the margin in 0.32s and the note slides 8px in
 *           behind it. Then the reader moves on. A note about a
 *           stretch shades its band as the reader crosses it and
 *           is written at the stretch's end
 *  margin   each note sits level with its point, tied to it by a
 *           dotted leader, so the eye runs straight across.
 *           Crowded notes step out of each other's way and their
 *           leaders bend to reach them
 *  summary  the read ends, the reader fades (0.25s) and the
 *           takeaway above the chart rises in word by word, 35ms
 *           apart
 *  link     hover or focus a note: its point rings, its band
 *           shades, its leader firms up and the other notes fade
 *           to half. Hover the chart: a crosshair snaps to the
 *           nearest point with its label and value, and lights
 *           the note there if it has one
 *  again    new data or notes read again from the start
 *  narrow   under 34rem the margin drops under the chart as a
 *           numbered list, the points carry the numbers and the
 *           leaders go
 *  touch    tap a note to light it, tap it again to clear. Drag
 *           across the chart to move the crosshair
 *  keys     the chart takes focus: ← → step the crosshair point
 *           by point, Home / End jump to the ends, Esc clears.
 *           Notes are buttons in reading order. The value under
 *           the crosshair is announced
 *  reduced  no sweep: the chart arrives read, every note in
 *           place. Highlights and the crosshair work, unanimated
 * ───────────────────────────────────────────────────────── */

const MARGIN = {
  holdShare: 0.45, // the pauses never take more than this share of the read
  view: 0.35, // share of the chart in view before it reads
  bend: 14, // px a leader runs before it turns toward a note that had to step aside
  slide: 8, // px a note slides in from
  lift: 6, // px a summary word rises from
  dim: 0.45, // opacity of the other notes while one is lit
  glow: 56, // px of glow trailing the reader
  gap: 10, // px between notes stacked in the margin
  hang: 28, // px a note may hang below the plot, beside the labels, before the stack lifts
  badge: 9, // px from a note's top to its number's middle, level with the point
} as const;

/* ── Public types ───────────────────────────────────────── */

export interface MarginNoteNote {
  /** Index in `data` of the point the note is about. */
  at: number;
  /** Index where a note about a stretch of time ends. The band between `at` and `to` shades. */
  to?: number;
  /** The note's first line. Keep it short. */
  title: string;
  /** One line of detail under the title. */
  body?: string;
  /** A small figure beside the title, like "+38%". */
  delta?: string;
}

export interface MarginNoteProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
  /** The series, one value per point, oldest first. */
  data: number[];
  /** A label per point, like a date. A few run under the chart, and the crosshair shows the one it's on. */
  labels?: string[];
  /** What the reader writes in the margin. Each note points at an index in `data`; they're numbered in reading order. */
  notes?: MarginNoteNote[];
  /** The takeaway above the chart, written once the read ends. */
  summary?: React.ReactNode;
  /** Formats values on the axis and under the crosshair. Defaults to compact numbers (1.2K). */
  format?: (value: number) => string;
  /** A second series to compare against, like last period. Drawn dashed and never read. */
  compare?: number[];
  /** `"view"` reads once the chart is 35% in view. `true` reads now, and again each time it flips back to `true`. `false` shows it already read. */
  play?: boolean | "view";
  /** How long the read takes, in ms, pauses at the notes included. */
  duration?: number;
  /** How long the reader stops at each noted point, in ms, as if writing. The pauses never take more than 45% of `duration`. */
  hold?: number;
  /** What the chart shows, like "Weekly signups". Screen readers hear it first. */
  label?: string;
  /** Called when a read ends. */
  onComplete?: () => void;
}

/* ── Geometry ───────────────────────────────────────────── */

// Everything is worked out in a unit square (x right, y down) and scaled to the measured box at
// draw time, so a resize never needs the data again and strokes stay a true 1.5px.
type Point = { x: number; y: number };
type Segment = { x0: number; y0: number; c1: number; c2: number; x1: number; y1: number };
type Series = { points: Point[]; segments: Segment[] };
type Geometry = { min: number; max: number; ticks: number[]; zero: number | null; main: Series; compare: Series | null };

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const round = (v: number) => Math.round(v * 100) / 100;
const compact = new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 });
const formatCompact = (value: number) => compact.format(value);
const noopSubscribe = () => () => {};
const NO_NOTES: MarginNoteNote[] = [];

/** Round numbers for the hairlines, a step of 1, 2, 2.5 or 5 × 10ⁿ, with a little air above the peak. */
function niceScale(values: number[]) {
  let lo = Math.min(...values);
  let hi = Math.max(...values);
  if (!Number.isFinite(lo)) return { min: 0, max: 1, ticks: [0, 1] };
  if (lo === hi) {
    const pad = Math.abs(lo) * 0.1 || 1;
    lo -= pad;
    hi += pad;
  }
  const span = hi - lo;
  hi += span * 0.08;
  lo = lo >= 0 ? Math.max(0, lo - span * 0.04) : lo - span * 0.04;
  const raw = (hi - lo) / 4;
  const mag = 10 ** Math.floor(Math.log10(raw));
  const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) ?? raw;
  const min = Math.floor(lo / step) * step;
  const max = Math.ceil(hi / step) * step;
  const ticks: number[] = [];
  for (let v = min; v <= max + step / 2; v += step) ticks.push(Number(v.toPrecision(12)));
  return { min, max, ticks };
}

/** A monotone cubic through the points (Fritsch–Carlson), so the curve never overshoots a value. */
function monotone(points: Point[]): Segment[] {
  const n = points.length;
  if (n < 2) return [];
  const d: number[] = [];
  for (let i = 0; i < n - 1; i++) d.push((points[i + 1].y - points[i].y) / (points[i + 1].x - points[i].x));
  const m: number[] = new Array(n);
  m[0] = d[0];
  m[n - 1] = d[n - 2];
  for (let i = 1; i < n - 1; i++) m[i] = d[i - 1] * d[i] <= 0 ? 0 : (d[i - 1] + d[i]) / 2;
  for (let i = 0; i < n - 1; i++) {
    if (d[i] === 0) {
      m[i] = 0;
      m[i + 1] = 0;
      continue;
    }
    const a = m[i] / d[i];
    const b = m[i + 1] / d[i];
    const s = a * a + b * b;
    if (s > 9) {
      const t = 3 / Math.sqrt(s);
      m[i] = t * a * d[i];
      m[i + 1] = t * b * d[i];
    }
  }
  return points.slice(0, -1).map((p, i) => {
    const q = points[i + 1];
    const dx = q.x - p.x;
    return { x0: p.x, y0: p.y, c1: p.y + (m[i] * dx) / 3, c2: q.y - (m[i + 1] * dx) / 3, x1: q.x, y1: q.y };
  });
}

function series(values: number[], min: number, max: number): Series {
  const n = values.length;
  const points = values.map((v, i) => ({ x: n === 1 ? 0.5 : i / (n - 1), y: (max - v) / (max - min) }));
  return { points, segments: monotone(points) };
}

function buildGeometry(data: number[], compare: number[] | null): Geometry {
  const { min, max, ticks } = niceScale(compare ? [...data, ...compare] : data);
  return {
    min,
    max,
    ticks,
    zero: min < 0 && max > 0 ? max / (max - min) : null,
    main: series(data, min, max),
    compare: compare && compare.length ? series(compare, min, max) : null,
  };
}

/** The series' height at a point across, exact on the drawn curve: its x runs linearly in each segment. */
function heightAt({ points, segments }: Series, fx: number) {
  if (!segments.length) return points[0]?.y ?? 0.5;
  const i = clamp(Math.floor(fx * segments.length), 0, segments.length - 1);
  const s = segments[i];
  const t = clamp((fx - s.x0) / (s.x1 - s.x0), 0, 1);
  const u = 1 - t;
  return u * u * u * s.y0 + 3 * u * u * t * s.c1 + 3 * u * t * t * s.c2 + t * t * t * s.y1;
}

function linePath({ points, segments }: Series, w: number, h: number) {
  if (!points.length) return "";
  if (!segments.length) return `M${round(points[0].x * w - 0.5)},${round(points[0].y * h)}h1`;
  let d = `M${round(segments[0].x0 * w)},${round(segments[0].y0 * h)}`;
  for (const s of segments) {
    const third = (s.x1 - s.x0) / 3;
    d += `C${round((s.x0 + third) * w)},${round(s.c1 * h)},${round((s.x1 - third) * w)},${round(s.c2 * h)},${round(s.x1 * w)},${round(s.y1 * h)}`;
  }
  return d;
}

function areaPath(s: Series, base: number, w: number, h: number) {
  if (s.segments.length === 0) return "";
  const first = s.points[0];
  const last = s.points[s.points.length - 1];
  return `${linePath(s, w, h)}L${round(last.x * w)},${round(base * h)}L${round(first.x * w)},${round(base * h)}Z`;
}

/** Which labels fit along the bottom: evenly stepped from the first, plus the last when it has room. */
function labelIndices(labels: string[] | undefined, n: number, width: number) {
  if (!labels?.length || n === 0) return [];
  if (n === 1) return [0];
  const longest = Math.max(...labels.map((l) => l.length));
  const room = longest * 6.4 + 20; // px one label needs at 11px, with air
  const fit = clamp(Math.floor(width / room), 2, 8);
  const step = Math.max(1, Math.ceil((n - 1) / (fit - 1)));
  const picked: number[] = [];
  for (let i = 0; i < n; i += step) picked.push(i);
  const last = n - 1;
  if (picked[picked.length - 1] !== last) {
    // The last label always shows; it takes the place of a neighbor that would crowd it.
    if (last - picked[picked.length - 1] < step * 0.6) picked.pop();
    picked.push(last);
  }
  return picked;
}

/* ── Notes in reading order ─────────────────────────────── */

type Note = MarginNoteNote & {
  index: number; // position in reading order, from 0
  anchor: number; // the point the note is written at: the end of its stretch, or its point
  from: number; // the stretch's start (equals anchor for a point)
};

function readingOrder(notes: MarginNoteNote[], n: number): Note[] {
  if (n === 0) return [];
  return notes
    .map((note) => {
      const a = clamp(Math.round(note.at), 0, n - 1);
      const b = note.to === undefined ? a : clamp(Math.round(note.to), 0, n - 1);
      return { ...note, from: Math.min(a, b), anchor: Math.max(a, b) };
    })
    .sort((p, q) => p.anchor - q.anchor || p.from - q.from)
    .map((note, index) => ({ ...note, index }));
}

/* ── Layout, measured ───────────────────────────────────── */

type Layout = {
  w: number; // the plot's size in px
  h: number;
  plotX: number; // the plot's offset inside the body
  plotY: number;
  wide: boolean; // the margin beside the chart (34rem and up), not under it
  column: { x: number; y: number; h: number } | null; // the margin column, when its notes are placed by hand
  tops: number[]; // each note's top inside the column, in reading order
};

const sameLayout = (a: Layout | null, b: Layout) =>
  !!a &&
  a.w === b.w &&
  a.h === b.h &&
  a.plotX === b.plotX &&
  a.plotY === b.plotY &&
  a.wide === b.wide &&
  a.column?.x === b.column?.x &&
  a.column?.y === b.column?.y &&
  a.column?.h === b.column?.h &&
  a.tops.length === b.tops.length &&
  a.tops.every((t, i) => t === b.tops[i]);

/** Puts each note level with its point, then steps crowded ones down (and back up from the floor). */
function placeNotes(want: number[], heights: number[], floor: number, gap: number) {
  const order = want.map((_, i) => i).sort((a, b) => want[a] - want[b]);
  const tops = new Array<number>(want.length);
  let cursor = -Infinity;
  for (const i of order) {
    tops[i] = Math.max(want[i], cursor, 0);
    cursor = tops[i] + heights[i] + gap;
  }
  // Pushed past the floor? Lift the stack back up, as far as the top allows.
  let limit = floor;
  for (let k = order.length - 1; k >= 0; k--) {
    const i = order[k];
    if (tops[i] + heights[i] > limit) tops[i] = Math.max(0, limit - heights[i]);
    limit = tops[i] - gap;
  }
  return tops.map(Math.round);
}

/* ── MarginNote ─────────────────────────────────────────── */

type Run = { token: number; phase: "idle" | "reading" | "done"; shown: number };
const startRun = (r: Run): Run => ({ token: r.token + 1, phase: "reading", shown: 0 });

export function MarginNote({
  data,
  labels,
  notes = NO_NOTES,
  summary,
  format = formatCompact,
  compare,
  play = "view",
  duration = TIMING.duration,
  hold: holdProp = TIMING.hold,
  label = "Chart",
  onComplete,
  className,
  ...props
}: MarginNoteProps) {
  const uid = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
  const hydrated = React.useSyncExternalStore(noopSubscribe, () => true, () => false);
  const reduced = (useReducedMotion() ?? false) && hydrated;

  // Content keys, so inline arrays don't restart the read on every render.
  const dataKey = data.join(",");
  const compareKey = compare?.join(",") ?? "";
  const notesKey = JSON.stringify(notes);
  const geo = React.useMemo(
    () =>
      buildGeometry(
        dataKey ? dataKey.split(",").map(Number) : [],
        compareKey ? compareKey.split(",").map(Number) : null,
      ),
    [dataKey, compareKey],
  );
  const n = geo.main.points.length;
  const order = React.useMemo(() => readingOrder(JSON.parse(notesKey) as MarginNoteNote[], n), [notesKey, n]);

  /* the run: idle → reading → done */
  const [run, setRun] = React.useState<Run>({ token: 0, phase: "idle", shown: 0 });
  const contentKey = `${dataKey}|${notesKey}`;
  const [seenKey, setSeenKey] = React.useState(contentKey);
  const [seenPlay, setSeenPlay] = React.useState<MarginNoteProps["play"] | null>(null);
  if (seenKey !== contentKey) {
    // New data reads again from the start (in view: right away, see the observer below).
    setSeenKey(contentKey);
    setRun((r) => (play === true ? startRun(r) : { token: r.token + 1, phase: "idle", shown: 0 }));
  }
  if (seenPlay !== play) {
    setSeenPlay(play);
    if (play === true) setRun(startRun);
    else if (play === "view" && seenPlay !== null) setRun((r) => ({ token: r.token + 1, phase: "idle", shown: 0 }));
  }

  const still = play === false || reduced; // never sweeps: arrives read
  const phase = play === false ? "done" : run.phase;
  const done = phase === "done" || still;
  const shown = done ? order.length : run.shown;

  const rootRef = React.useRef<HTMLDivElement>(null);
  const bodyRef = React.useRef<HTMLDivElement>(null);
  const plotRef = React.useRef<HTMLDivElement>(null);
  const columnRef = React.useRef<HTMLOListElement>(null);
  const clipRef = React.useRef<SVGRectElement>(null);
  const glowRef = React.useRef<SVGRectElement>(null);
  const readerRef = React.useRef<HTMLDivElement>(null);
  const readerDotRef = React.useRef<HTMLDivElement>(null);
  const layoutRef = React.useRef<Layout | null>(null);
  const callbacks = React.useRef({ onComplete });
  React.useEffect(() => {
    callbacks.current = { onComplete };
  });

  /* measure: the plot in px, and where each note sits in the margin */
  const [layout, setLayout] = React.useState<Layout | null>(null);
  React.useEffect(() => {
    layoutRef.current = layout;
  }, [layout]);

  const anchorsKey = order.map((note) => note.anchor).join(",");
  React.useEffect(() => {
    const root = rootRef.current;
    const body = bodyRef.current;
    const plot = plotRef.current;
    if (!root || !body || !plot) return;
    const anchors = anchorsKey ? anchorsKey.split(",").map(Number) : [];
    const measure = () => {
      const b = body.getBoundingClientRect();
      const p = plot.getBoundingClientRect();
      const column = columnRef.current;
      const c = column?.getBoundingClientRect();
      const wide = !!c && c.left >= p.right - 1; // the margin sits beside the plot, not under it
      const next: Layout = {
        w: Math.round(p.width),
        h: Math.round(p.height),
        plotX: Math.round(p.left - b.left),
        plotY: Math.round(p.top - b.top),
        wide,
        column: null,
        tops: [],
      };
      if (wide && column && c && anchors.length) {
        const items = Array.from(column.children) as HTMLElement[];
        const heights = items.map((item) => item.offsetHeight);
        const want = anchors.map(
          (a) => next.plotY - (c.top - b.top) + geo.main.points[a].y * next.h - MARGIN.badge,
        );
        const floor = Math.max(next.h + MARGIN.hang, heights.reduce((sum, h) => sum + h + MARGIN.gap, 0));
        const tops = placeNotes(want, heights, floor, MARGIN.gap);
        const bottom = Math.max(...tops.map((t, i) => t + heights[i]));
        next.column = { x: Math.round(c.left - b.left), y: Math.round(c.top - b.top), h: Math.max(next.h, bottom) };
        next.tops = tops;
      }
      setLayout((prev) => (sameLayout(prev, next) ? prev : next));
    };
    const observer = new ResizeObserver(measure);
    observer.observe(root);
    observer.observe(plot);
    if (columnRef.current) {
      observer.observe(columnRef.current);
      for (const item of Array.from(columnRef.current.children)) observer.observe(item);
    }
    let alive = true;
    document.fonts?.ready.then(() => alive && measure());
    return () => {
      alive = false;
      observer.disconnect();
    };
  }, [anchorsKey, geo]);

  /* play once 35% in view */
  const auto = play === "view";
  React.useEffect(() => {
    const root = rootRef.current;
    if (!auto || !root || run.phase !== "idle") return;
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (!entry || entry.intersectionRatio < MARGIN.view - 0.01) return;
        observer.disconnect();
        setRun((r) => (r.phase === "idle" ? startRun(r) : r));
      },
      { threshold: MARGIN.view },
    );
    observer.observe(root);
    return () => observer.disconnect();
  }, [auto, run.phase, run.token]);

  /* the read */
  const stopsKey = order.map((note) => note.anchor).join(",");
  React.useEffect(() => {
    if (run.phase !== "reading" || play === false) return;
    const token = run.token;
    const finish = () => {
      setRun((r) => (r.token === token && r.phase === "reading" ? { ...r, phase: "done", shown: Infinity } : r));
      callbacks.current.onComplete?.();
    };
    if (still) {
      const id = window.setTimeout(finish, 0);
      return () => window.clearTimeout(id);
    }

    // The reader's path through time: steady sweeps, with a hold at each noted point.
    const count = Math.max(1, n - 1);
    const marks = stopsKey ? [...new Set(stopsKey.split(",").map(Number))].map((a) => a / count) : [];
    const total = Math.max(duration, TIMING.shortest);
    const hold = Math.min(Math.max(0, holdProp), (total * MARGIN.holdShare) / Math.max(1, marks.length));
    const sweep = total - hold * marks.length;
    const xs = [0];
    const ts = [0];
    let t = 0;
    let at = 0;
    for (const mark of marks) {
      t += (mark - at) * sweep;
      xs.push(mark, mark);
      ts.push(t, t + hold);
      t += hold;
      at = mark;
    }
    xs.push(1);
    ts.push(t + (1 - at) * sweep);
    const end = ts[ts.length - 1] || 1;
    const position = (u: number) => {
      const time = u * end;
      for (let i = 1; i < ts.length; i++) {
        if (time <= ts[i]) {
          const span = ts[i] - ts[i - 1];
          return span <= 0 ? xs[i] : xs[i - 1] + ((time - ts[i - 1]) / span) * (xs[i] - xs[i - 1]);
        }
      }
      return 1;
    };
    const anchors = stopsKey ? stopsKey.split(",").map((a) => Number(a) / count) : [];

    let revealed = 0;
    const paint = (fx: number) => {
      const lay = layoutRef.current;
      if (!lay) return;
      const x = fx * lay.w;
      clipRef.current?.setAttribute("width", String(round(x + 1)));
      glowRef.current?.setAttribute("x", String(round(x - MARGIN.glow)));
      if (readerRef.current) readerRef.current.style.transform = `translate3d(${round(x)}px,0,0)`;
      if (readerDotRef.current) {
        const y = heightAt(geo.main, fx) * lay.h;
        readerDotRef.current.style.transform = `translate3d(${round(x)}px,${round(y)}px,0)`;
      }
      let passed = 0;
      while (passed < anchors.length && anchors[passed] <= fx + 1e-6) passed++;
      if (passed > revealed) {
        revealed = passed;
        setRun((r) => (r.token === token ? { ...r, shown: passed } : r));
      }
    };
    const controls = animate(0, 1, {
      duration: end / 1000,
      ease: TIMING.sweepEase,
      onUpdate: (u) => paint(position(u)),
      onComplete: () => {
        paint(1);
        finish();
      },
    });
    return () => controls.stop();
  }, [run.phase, run.token, play, still, n, stopsKey, duration, holdProp, geo]);

  /* lighting a note: hover, focus or a tap on it, or the crosshair landing on its point */
  const [pinned, setPinned] = React.useState<number | null>(null);
  const [cross, setCross] = React.useState<number | null>(null);
  const [spoken, setSpoken] = React.useState(""); // the value the keyboard last stepped to, read out
  const reading = run.phase === "reading" && !still;
  const crossNote = cross === null ? -1 : order.findIndex((note) => cross >= note.from && cross <= note.anchor);
  const lit = pinned ?? (crossNote >= 0 && crossNote < shown ? crossNote : null);
  const litNote = lit === null ? null : order[lit];

  const valueAt = (i: number) => data[i];
  const describe = (i: number) =>
    `${labels?.[i] ?? `Point ${i + 1}`}: ${format(valueAt(i))}${compare?.[i] !== undefined ? `, compared with ${format(compare[i])}` : ""}`;
  const indexAt = (clientX: number) => {
    const rect = plotRef.current?.getBoundingClientRect();
    if (!rect || n === 0) return null;
    return Math.round(clamp((clientX - rect.left) / rect.width, 0, 1) * (n - 1));
  };
  const follow = (event: React.PointerEvent) => {
    if (reading) return;
    if (event.pointerType !== "mouse" && event.buttons === 0) return;
    const i = indexAt(event.clientX);
    if (i !== cross) setCross(i);
  };
  const onKeyDown = (event: React.KeyboardEvent) => {
    if (n === 0) return;
    const last = n - 1;
    const from = cross ?? last;
    let next: number | null = from;
    if (event.key === "ArrowRight") next = cross === null ? last : Math.min(last, from + 1);
    else if (event.key === "ArrowLeft") next = cross === null ? last : Math.max(0, from - 1);
    else if (event.key === "Home") next = 0;
    else if (event.key === "End") next = last;
    else if (event.key === "Escape") next = null;
    else return;
    event.preventDefault();
    setCross(next);
    setSpoken(next === null ? "" : describe(next));
    if (event.key === "Escape") setPinned(null);
  };

  /* drawing sizes: measured px, or a stretched unit box until the first measure */
  const w = layout?.w ?? 1000;
  const h = layout?.h ?? 1000;
  const measured = layout !== null;
  const base = geo.zero ?? 1;
  const line = linePath(geo.main, w, h);
  const area = areaPath(geo.main, base, w, h);
  const ghost = geo.compare ? linePath(geo.compare, w, h) : "";
  const xLabels = labelIndices(labels, n, layout?.w ?? 600);
  const gutter = `calc(${Math.max(...geo.ticks.map((tick) => format(tick).length))}ch + 0.75rem)`;

  const clipId = `${uid}-read`;
  const fillId = `${uid}-fill`;
  const glowId = `${uid}-glow`;
  const descId = `${uid}-desc`;

  const placed = layout?.wide && layout.column && layout.tops.length === order.length ? layout : null;

  return (
    <div
      ref={rootRef}
      className={cn(
        "@container/margin-note relative w-full [--margin-note-accent:#5b54d6] dark:[--margin-note-accent:#a5a0ff]",
        className,
      )}
      {...props}
    >
      {summary !== undefined && <Summary summary={summary} shown={done} instant={still} />}

      <div
        ref={bodyRef}
        className={cn(
          "relative grid grid-cols-1 gap-x-7 gap-y-5",
          order.length > 0 && "@[34rem]/margin-note:grid-cols-[minmax(0,1fr)_minmax(10rem,13.5rem)]",
        )}
      >
        <div className="min-w-0 pt-2">
          <div className="flex">
            {/* the values, in a gutter so the series never runs through them */}
            <div
              aria-hidden
              className="relative shrink-0 text-[11px] leading-4 text-current/55 tabular-nums"
              style={{ width: gutter }}
            >
              {geo.ticks.map((tick) => (
                <span
                  key={tick}
                  className="absolute right-2.5 -translate-y-1/2 whitespace-nowrap"
                  style={{ top: `${((geo.max - tick) / (geo.max - geo.min)) * 100}%` }}
                >
                  {format(tick)}
                </span>
              ))}
            </div>
            <div
              ref={plotRef}
              role="group"
              aria-roledescription="chart"
              tabIndex={0}
              aria-label={`${label}. Arrow keys read the values.`}
              aria-describedby={descId}
              onPointerMove={follow}
              onPointerDown={(event) => {
                if (event.pointerType === "mouse") return;
                (event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId);
                const i = indexAt(event.clientX);
                if (!reading) setCross(i);
              }}
              onPointerLeave={(event) => {
                if (event.pointerType === "mouse") setCross(null);
              }}
              onKeyDown={onKeyDown}
              onBlur={() => setCross(null)}
              className="relative h-[var(--margin-note-height,12.5rem)] min-w-0 flex-1 touch-pan-y rounded-[2px] outline-offset-4 outline-[color:var(--margin-note-accent)] select-none focus-visible:outline-2 @[34rem]/margin-note:h-[var(--margin-note-height,15rem)]"
            >
              {/* hairlines */}
              <div aria-hidden className="pointer-events-none absolute inset-0">
                {geo.ticks.map((tick) => (
                  <div
                    key={tick}
                    className={cn(
                      "absolute inset-x-0 h-px",
                      tick === 0 && geo.zero !== null ? "bg-current/25" : "bg-current/[0.08]",
                    )}
                    style={{ top: `${((geo.max - tick) / (geo.max - geo.min)) * 100}%` }}
                  />
                ))}
              </div>

              <svg
                aria-hidden
                className="pointer-events-none absolute inset-0 h-full w-full overflow-visible"
                viewBox={`0 0 ${w} ${h}`}
                preserveAspectRatio="none"
              >
                <defs>
                  <linearGradient id={fillId} x1="0" x2="0" y1="0" y2="1">
                    <stop offset="0" style={{ stopColor: "var(--margin-note-accent)", stopOpacity: 0.2 }} />
                    <stop offset="1" style={{ stopColor: "var(--margin-note-accent)", stopOpacity: 0 }} />
                  </linearGradient>
                  <linearGradient id={glowId} x1="0" x2="1" y1="0" y2="0">
                    <stop offset="0" style={{ stopColor: "var(--margin-note-accent)", stopOpacity: 0 }} />
                    <stop offset="1" style={{ stopColor: "var(--margin-note-accent)", stopOpacity: 0.14 }} />
                  </linearGradient>
                  <clipPath id={clipId}>
                    <rect ref={clipRef} x={-8} y={-16} width={done ? w + 16 : 0} height={h + 32} />
                  </clipPath>
                </defs>

                {/* a lit note's stretch, or a narrow column at its point */}
                {litNote && (
                  <motion.g
                    key={`lit-${litNote.index}`}
                    initial={{ opacity: 0 }}
                    animate={{ opacity: 1 }}
                    transition={reduced ? { duration: 0 } : TIMING.light}
                  >
                    <rect
                      x={bandX(litNote.from, n, w)}
                      width={Math.max(bandX(litNote.anchor, n, w, true) - bandX(litNote.from, n, w), 2)}
                      y={0}
                      height={h}
                      style={{ fill: "var(--margin-note-accent)", fillOpacity: 0.1 }}
                    />
                  </motion.g>
                )}

                {/* the comparison, printed and never read */}
                {ghost && (
                  <path
                    d={ghost}
                    fill="none"
                    stroke="currentColor"
                    strokeOpacity={0.32}
                    strokeWidth={1.25}
                    strokeDasharray="3 4"
                    strokeLinecap="round"
                    vectorEffect="non-scaling-stroke"
                  />
                )}

                {/* the series before it's read: a faint print */}
                <path
                  d={line}
                  fill="none"
                  stroke="currentColor"
                  strokeOpacity={done ? 0 : 0.16}
                  strokeWidth={1.5}
                  strokeLinejoin="round"
                  strokeLinecap="round"
                  vectorEffect="non-scaling-stroke"
                />

                {/* what the reader has inked */}
                <g clipPath={`url(#${clipId})`}>
                  {order.map((note) =>
                    note.from === note.anchor ? null : (
                      <rect
                        key={`band-${note.index}`}
                        x={bandX(note.from, n, w)}
                        width={bandX(note.anchor, n, w, true) - bandX(note.from, n, w)}
                        y={0}
                        height={h}
                        style={{ fill: "var(--margin-note-accent)", fillOpacity: 0.06 }}
                      />
                    ),
                  )}
                  {area && <path d={area} fill={`url(#${fillId})`} />}
                  <path
                    d={line}
                    fill="none"
                    stroke="currentColor"
                    strokeWidth={1.5}
                    strokeLinejoin="round"
                    strokeLinecap="round"
                    vectorEffect="non-scaling-stroke"
                  />
                </g>

                {/* the glow trailing the reader */}
                {measured && (
                  <motion.g initial={false} animate={{ opacity: reading ? 1 : 0 }} transition={TIMING.reader}>
                    <rect ref={glowRef} x={-MARGIN.glow} width={MARGIN.glow} y={0} height={h} fill={`url(#${glowId})`} />
                  </motion.g>
                )}
              </svg>

              {/* the reader */}
              <motion.div
                aria-hidden
                initial={false}
                animate={{ opacity: reading && measured ? 1 : 0 }}
                transition={TIMING.reader}
                className="pointer-events-none absolute inset-0"
              >
                <div ref={readerRef} className="absolute inset-y-0 left-0 w-px bg-[var(--margin-note-accent)]/60" />
                <div ref={readerDotRef} className="absolute top-0 left-0">
                  <span className="absolute -top-1 -left-1 size-2 rounded-full bg-[var(--margin-note-accent)] shadow-[0_0_0_4px_color-mix(in_oklab,var(--margin-note-accent)_22%,transparent)]" />
                </div>
              </motion.div>

              {/* noted points */}
              {order.map((note) => {
                const p = geo.main.points[note.anchor];
                const on = note.index < shown;
                return (
                  <div
                    key={`point-${note.index}`}
                    aria-hidden
                    className="pointer-events-none absolute"
                    style={{ left: `${p.x * 100}%`, top: `${p.y * 100}%` }}
                  >
                    {on && !still && (
                      <motion.span
                        key={`ring-${run.token}`}
                        initial={{ opacity: 0.55, transform: "translate(-50%, -50%) scale(1)" }}
                        animate={{ opacity: 0, transform: "translate(-50%, -50%) scale(3.2)" }}
                        transition={TIMING.ring}
                        className="absolute top-0 left-0 size-3 rounded-full border border-[var(--margin-note-accent)]"
                      />
                    )}
                    <motion.span
                      initial={false}
                      animate={
                        on
                          ? { opacity: 1, transform: "translate(-50%, -50%) scale(1)" }
                          : { opacity: 0, transform: "translate(-50%, -50%) scale(0.6)" }
                      }
                      transition={still ? { duration: 0 } : TIMING.pop}
                      className={cn(
                        `absolute top-0 left-0 flex items-center justify-center rounded-full bg-[var(--margin-note-accent)] text-[10px] leading-none font-semibold text-white tabular-nums transition-shadow ${TIMING.lightClass} dark:text-zinc-950`,
                        "size-4 @[34rem]/margin-note:size-2.5",
                        lit === note.index
                          ? "shadow-[0_0_0_5px_color-mix(in_oklab,var(--margin-note-accent)_28%,transparent)]"
                          : "shadow-[0_0_0_3px_color-mix(in_oklab,var(--margin-note-accent)_20%,transparent)]",
                      )}
                    >
                      <span className="@[34rem]/margin-note:hidden">{note.index + 1}</span>
                    </motion.span>
                  </div>
                );
              })}

              {/* the crosshair */}
              {cross !== null && n > 0 && (
                <Crosshair
                  x={geo.main.points[cross].x}
                  y={geo.main.points[cross].y}
                  label={labels?.[cross]}
                  value={format(valueAt(cross))}
                  compared={compare?.[cross] !== undefined ? format(compare[cross]) : undefined}
                />
              )}
            </div>
          </div>

          {/* labels along the bottom */}
          {xLabels.length > 0 && (
            <div
              aria-hidden
              className="relative mt-2 h-4 text-[11px] leading-4 text-current/55 tabular-nums"
              style={{ marginLeft: gutter }}
            >
              {xLabels.map((i) => {
                const x = geo.main.points[i].x;
                const edge = x < 0.04 ? "translate-x-0" : x > 0.96 ? "-translate-x-full" : "-translate-x-1/2";
                return (
                  <span key={i} className={cn("absolute top-0 whitespace-nowrap", edge)} style={{ left: `${x * 100}%` }}>
                    {labels?.[i]}
                  </span>
                );
              })}
            </div>
          )}
        </div>

        {/* the margin */}
        {order.length > 0 && (
          <ol
            ref={columnRef}
            className={cn("relative flex flex-col gap-2.5", placed && "block")}
            style={placed ? { height: placed.column!.h } : undefined}
          >
            {order.map((note) => (
              <NoteItem
                key={note.index}
                note={note}
                shown={note.index < shown && (!still || layout !== null)}
                still={still}
                dimmed={lit !== null && lit !== note.index}
                lit={lit === note.index}
                top={placed ? placed.tops[note.index] : null}
                onLight={(on) => setPinned(on ? note.index : null)}
                onToggle={() => setPinned((p) => (p === note.index ? null : note.index))}
              />
            ))}
          </ol>
        )}

        {/* leaders, dotted, from each point to its note */}
        {placed && (
          <svg aria-hidden className="pointer-events-none absolute inset-0 h-full w-full overflow-visible">
            {order.map((note) => {
              const p = geo.main.points[note.anchor];
              const x1 = placed.plotX + p.x * placed.w + 8;
              const y1 = placed.plotY + p.y * placed.h;
              const x2 = placed.column!.x - 7;
              const y2 = placed.column!.y + placed.tops[note.index] + MARGIN.badge;
              const on = note.index < shown;
              // A note that stepped aside needs a bend. Run the long stretch at whichever level, the
              // point's or the note's, crosses the series fewer times; ties keep the point's level.
              const k = Math.max(0, Math.min(MARGIN.bend, (x2 - x1) / 4));
              const level = (y: number) => (y - placed.plotY) / placed.h;
              const early =
                Math.abs(y2 - y1) >= 1 && crossings(geo.main, p.x, level(y2)) < crossings(geo.main, p.x, level(y1));
              const d =
                Math.abs(y2 - y1) < 1
                  ? `M${round(x1)},${round(y1)}H${round(x2)}`
                  : early
                    ? `M${round(x1)},${round(y1)}C${round(x1 + k)},${round(y1)},${round(x1 + k)},${round(y2)},${round(x1 + 2 * k)},${round(y2)}H${round(x2)}`
                    : `M${round(x1)},${round(y1)}H${round(x2 - 2 * k)}C${round(x2 - k)},${round(y1)},${round(x2 - k)},${round(y2)},${round(x2)},${round(y2)}`;
              const isLit = lit === note.index;
              return (
                <motion.path
                  key={`leader-${note.index}`}
                  d={d}
                  fill="none"
                  strokeWidth={1.5}
                  strokeLinecap="round"
                  strokeDasharray="0 4"
                  initial={false}
                  animate={{
                    clipPath: on ? "inset(-8px 0% -8px 0%)" : "inset(-8px 100% -8px 0%)",
                    opacity: lit === null || isLit ? 1 : 0.5,
                  }}
                  transition={still ? { duration: 0 } : { clipPath: on ? TIMING.leader : { duration: 0 }, opacity: TIMING.light }}
                  className={cn(
                    `transition-[stroke] ${TIMING.lightClass}`,
                    isLit ? "stroke-[color:var(--margin-note-accent)]" : "stroke-current/30",
                  )}
                />
              );
            })}
          </svg>
        )}
      </div>

      {/* for screen readers: the whole chart in words */}
      <p id={descId} className="sr-only">
        {n > 0
          ? `${n} points${labels?.length ? `, ${labels[0]} to ${labels[n - 1]}` : ""}, from ${format(Math.min(...data))} to ${format(Math.max(...data))}. `
          : "No data. "}
        {order.map((note) => `Note ${note.index + 1}, at ${labels?.[note.anchor] ?? `point ${note.anchor + 1}`}: ${note.title}${note.delta ? ` (${note.delta})` : ""}. ${note.body ?? ""} `)}
      </p>
      <p className="sr-only" aria-live="polite">
        {spoken}
      </p>
    </div>
  );
}

/** The x of a point in px, or of the edge of its column when a band ends there. */
function bandX(index: number, n: number, w: number, end = false) {
  if (n < 2) return end ? w : 0;
  const step = w / (n - 1);
  return clamp(index * step + (end ? step / 2 : -step / 2), 0, w);
}

/** How many times the series crosses a level between a point and the right edge. */
function crossings(main: Series, from: number, level: number) {
  let count = 0;
  let side = 0;
  for (let fx = from + 0.01; fx <= 1; fx += 1 / 160) {
    const next = Math.sign(heightAt(main, fx) - level);
    if (next !== 0 && side !== 0 && next !== side) count++;
    if (next !== 0) side = next;
  }
  return count;
}

/* ── Summary ────────────────────────────────────────────── */

// The takeaway, written last: word by word when it's plain text, as one line otherwise.
function Summary({ summary, shown, instant }: { summary: React.ReactNode; shown: boolean; instant: boolean }) {
  const words = typeof summary === "string" ? summary.split(/\s+/).filter(Boolean) : null;
  const step = words ? Math.min(TIMING.word, TIMING.wordSpread / Math.max(1, words.length - 1)) : 0;
  const hidden = { opacity: 0, transform: `translateY(${MARGIN.lift}px)` };
  const visible = { opacity: 1, transform: "translateY(0px)" };
  return (
    <p className="mb-3 text-[15px]/6 font-medium tracking-[-0.01em] text-balance">
      {words
        ? words.map((word, i) => (
            <React.Fragment key={i}>
              <motion.span
                className="inline-block"
                initial={false}
                animate={shown ? visible : hidden}
                transition={instant ? { duration: 0 } : { ...TIMING.rise, delay: shown ? i * step : 0 }}
              >
                {word}
              </motion.span>{" "}
            </React.Fragment>
          ))
        : (
            <motion.span
              className="inline-block"
              initial={false}
              animate={shown ? visible : hidden}
              transition={instant ? { duration: 0 } : TIMING.rise}
            >
              {summary}
            </motion.span>
          )}
    </p>
  );
}

/* ── NoteItem ───────────────────────────────────────────── */

// One note in the margin: its number, title, figure and a line of detail. A button, so it can be lit
// from the keyboard; level with its point once the margin is measured.
function NoteItem({
  note,
  shown,
  still,
  dimmed,
  lit,
  top,
  onLight,
  onToggle,
}: {
  note: Note;
  shown: boolean;
  still: boolean;
  dimmed: boolean;
  lit: boolean;
  top: number | null;
  onLight: (on: boolean) => void;
  onToggle: () => void;
}) {
  // A mouse lights the note on hover; a tap or Enter toggles it instead.
  const pointer = React.useRef("");
  return (
    <motion.li
      initial={false}
      animate={
        shown || still
          ? { opacity: shown ? 1 : 0, transform: "translateX(0px)" }
          : { opacity: 0, transform: `translateX(-${MARGIN.slide}px)` }
      }
      // Written by the reader: slides in behind its leader. Arriving read: fades in once placed.
      transition={!shown ? { duration: 0 } : still ? TIMING.light : TIMING.note}
      className={cn(top !== null && "absolute inset-x-0", !shown && "pointer-events-none")}
      style={top !== null ? { top } : undefined}
    >
      <button
        type="button"
        aria-pressed={lit}
        tabIndex={shown ? 0 : -1}
        onPointerDown={(event) => {
          pointer.current = event.pointerType;
        }}
        onPointerEnter={(event) => {
          if (event.pointerType === "mouse") onLight(true);
        }}
        onPointerLeave={(event) => {
          if (event.pointerType === "mouse") onLight(false);
        }}
        onFocus={(event) => {
          if (event.currentTarget.matches(":focus-visible")) onLight(true);
        }}
        onBlur={() => onLight(false)}
        onClick={() => {
          if (pointer.current !== "mouse") onToggle();
          pointer.current = "";
        }}
        onKeyDown={(event) => {
          if (event.key !== "Escape") return;
          event.currentTarget.blur();
          onLight(false);
        }}
        className={cn(
          `group flex w-full items-start gap-2.5 rounded-lg py-0.5 pr-1 text-left outline-offset-2 outline-[color:var(--margin-note-accent)] transition-opacity ${TIMING.lightClass} focus-visible:outline-2`,
          dimmed ? "opacity-45" : "opacity-100",
        )}
      >
        <span className="mt-px flex size-[18px] shrink-0 items-center justify-center rounded-full bg-[var(--margin-note-accent)] text-[10px] font-semibold text-white tabular-nums dark:text-zinc-950">
          {note.index + 1}
        </span>
        <span className="min-w-0 flex-1">
          <span className="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5">
            <span className="text-[13px]/5 font-medium tracking-[-0.005em]">{note.title}</span>
            {note.delta && (
              <span className="rounded-full bg-[color-mix(in_oklab,var(--margin-note-accent)_14%,transparent)] px-1.5 text-[11px]/[18px] font-medium text-[color:var(--margin-note-accent)] tabular-nums">
                {note.delta}
              </span>
            )}
          </span>
          {note.body && <span className="mt-0.5 block text-xs/[1.45] text-pretty text-current/60">{note.body}</span>}
        </span>
      </button>
    </motion.li>
  );
}

/* ── Crosshair ──────────────────────────────────────────── */

// A hairline at the nearest point, a dot on the series and its value beside the dot: above it, or
// under it when the point sits near the top.
function Crosshair({
  x,
  y,
  label,
  value,
  compared,
}: {
  x: number;
  y: number;
  label?: string;
  value: string;
  compared?: string;
}) {
  const align = x < 0.15 ? "translate-x-0" : x > 0.85 ? "-translate-x-full" : "-translate-x-1/2";
  const above = y > 0.28;
  return (
    <div aria-hidden className="pointer-events-none absolute inset-0">
      <div className="absolute inset-y-0 w-px bg-current/25" style={{ left: `${x * 100}%` }} />
      <div
        className="absolute size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-current shadow-[0_0_0_3px_color-mix(in_oklab,currentColor_18%,transparent)]"
        style={{ left: `${x * 100}%`, top: `${y * 100}%` }}
      />
      <div
        className={cn(
          "absolute flex items-baseline gap-2 rounded-md bg-zinc-950 px-2 py-1 text-[11px] leading-4 whitespace-nowrap text-white shadow-sm tabular-nums dark:bg-white dark:text-zinc-950",
          align,
          above ? "-translate-y-[calc(100%+12px)]" : "translate-y-3",
        )}
        style={{ left: `${x * 100}%`, top: `${y * 100}%` }}
      >
        {label && <span className="opacity-60">{label}</span>}
        <span className="font-medium">{value}</span>
        {compared && <span className="opacity-60">vs {compared}</span>}
      </div>
    </div>
  );
}
```
