{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "plot",
  "type": "registry:component",
  "title": "Plot",
  "description": "A chart you assemble from marks on one shared scale, for the shapes the named charts do not cover.",
  "dependencies": [
    "@base-ui/react",
    "@tanstack/charts",
    "tailwind-variants"
  ],
  "registryDependencies": [],
  "cssVars": {
    "light": {
      "success": "oklch(0.696 0.17 162.48)",
      "warning": "oklch(0.769 0.188 70.08)",
      "info": "oklch(0.623 0.214 259.815)"
    },
    "dark": {
      "success": "oklch(0.696 0.17 162.48)",
      "warning": "oklch(0.769 0.188 70.08)",
      "info": "oklch(0.623 0.214 259.815)"
    }
  },
  "files": [
    {
      "path": "components/panelui/plot/index.tsx",
      "type": "registry:component",
      "target": "@components/panelui/plot/index.tsx",
      "content": "'use client';\n\n/**\n * Plot: a chart you assemble, for the chart that is not in the box.\n *\n * ```tsx\n * <Plot data={months} xDataKey=\"month\">\n *   <Plot.Grid />\n *   <Plot.Bars dataKey=\"orders\" color=\"chart-3\" />\n *   <Plot.Line dataKey=\"revenue\" />\n *   <Plot.YAxis />\n *   <Plot.XAxis />\n *   <Plot.Cursor />\n *   <Plot.Tooltip />\n * </Plot>\n * ```\n *\n * ## The same parts as the app\n *\n * The parts and their props are the app library's Plot, so a chart written for one\n * reads the same in the other. What differs is what the web changes: a colour is\n * a theme role or any CSS colour, a row is read by hovering, tapping or dragging, a\n * click pins the reading, and the arrow keys walk the rows.\n *\n * ## One scale, however many marks\n *\n * Every mark reads the same plot box and the same y-domain, and the domain is\n * derived from all of them together. See `plot-scale.ts`.\n *\n * ## Four layers, stacked in one box\n *\n * The marks are one chart definition, drawn in the order they are written. Under\n * them is the grid, which stays out of the reveal because it is the frame the\n * marks arrive into. `Plot.Layer` draws on an SVG directly under or directly over\n * the marks, and those two and the chart are clipped together, so everything is\n * uncovered as one drawing. Over all of that are the axes, the rules and\n * `Plot.Overlay`, which are real text. Parts reach their layer through a portal,\n * so they can be written in any order and wrapped in components of your own.\n */\nimport {\n  areaY,\n  barY,\n  defineChart,\n  dot,\n  lineY,\n  type ChartInteractionController,\n  type ChartMotionContext,\n  type ChartPoint,\n} from '@tanstack/charts';\nimport { crosshair } from '@tanstack/charts/crosshair';\nimport { motion } from '@tanstack/charts/motion';\nimport { RendererChart } from '@tanstack/charts/react/tooltip';\nimport { scaleBand } from '@tanstack/charts/scales/band';\nimport { scaleLinear } from '@tanstack/charts/scales/linear';\nimport { scalePoint } from '@tanstack/charts/scales/point';\nimport { tooltip as chartTooltip } from '@tanstack/charts/tooltip';\nimport {\n  Children,\n  createContext,\n  forwardRef,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useImperativeHandle,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type ComponentProps,\n  type PointerEvent as ReactPointerEvent,\n  type ReactNode,\n} from 'react';\nimport { createPortal } from 'react-dom';\nimport { tv } from 'tailwind-variants';\nimport { mergeProps } from '@base-ui/react/merge-props';\nimport { useRender } from '@base-ui/react/use-render';\nimport type { ElementType as RenderElementType } from 'react';\nimport {\n  bandOf,\n  barEnd,\n  columnValues,\n  compactNumber,\n  domainBetween,\n  labelledRows,\n  plotCurve,\n  plotExtent,\n  xOf,\n  yOf,\n  type PlotBound,\n  type PlotBox,\n  type PlotCurve,\n} from './plot-scale';\n\nexport {\n  areaPath,\n  bandOf,\n  barPath,\n  compactNumber,\n  linePath,\n  segment,\n  xAt,\n  xOf,\n  yOf,\n  type PlotBound,\n  type PlotBox,\n  type PlotCurve,\n  type PlotPoint,\n} from './plot-scale';\n\n/** Draws a part as another element, or composes it with a component: `render={<a href=\"/\" />}`. */\ntype RenderProp<Tag extends RenderElementType> = { render?: useRender.ComponentProps<Tag>['render'] };\n\nexport type PlotStatus = 'loading' | 'ready';\nexport type PlotDatum = Record<string, string | number | null | undefined>;\n\n/**\n * How the index of a row becomes an x. `point` puts the first and last rows on the\n * plot's own edges, and `band` gives every row an equal slice and centres it.\n */\nexport type PlotScale = 'point' | 'band';\n\n/** A theme role by name, or any CSS colour: a hex value, `var(--brand)`, `color-mix(…)`. */\nexport type PlotColor =\n  | 'primary'\n  | 'info'\n  | 'success'\n  | 'warning'\n  | 'destructive'\n  | 'chart-1'\n  | 'chart-2'\n  | 'chart-3'\n  | 'chart-4'\n  | 'chart-5'\n  | (string & {});\n\nconst COLOR_VAR: Record<string, string> = {\n  primary: 'var(--primary)',\n  info: 'var(--info)',\n  success: 'var(--success)',\n  warning: 'var(--warning)',\n  destructive: 'var(--destructive)',\n  'chart-1': 'var(--chart-1)',\n  'chart-2': 'var(--chart-2)',\n  'chart-3': 'var(--chart-3)',\n  'chart-4': 'var(--chart-4)',\n  'chart-5': 'var(--chart-5)',\n};\n\nconst colorOf = (color: PlotColor) => COLOR_VAR[color] ?? color;\n\nconst fadedColor = (color: string, opacity: number) =>\n  `color-mix(in srgb, ${color} ${Math.min(Math.max(opacity, 0), 1) * 100}%, transparent)`;\n\nconst PALETTE = ['chart-1', 'chart-2', 'chart-3', 'chart-4', 'chart-5'].map((role) => `var(--${role})`);\n\nconst THEME = {\n  foreground: 'var(--foreground)',\n  muted: 'var(--muted-foreground)',\n  grid: 'var(--border)',\n  background: 'transparent',\n  palette: PALETTE,\n};\n\nconst plotVariants = tv({\n  slots: {\n    root: 'relative flex w-full flex-col text-foreground',\n    // The box comes after the header and the legend, whatever order they are written in.\n    box: 'relative order-1 w-full',\n    layer: 'pointer-events-none absolute inset-0 size-full overflow-visible',\n    reveal: 'absolute inset-0',\n    // `pan-y` hands a vertical drag to the page and a sideways one to the chart.\n    chart:\n      'absolute! inset-0 touch-pan-y rounded-lg text-xs [&_svg]:rounded-lg [&_svg]:outline-none [&_svg:focus-visible]:ring-2 [&_svg:focus-visible]:ring-ring',\n    overlay: 'pointer-events-none absolute inset-0',\n    userOverlay: 'absolute inset-0 *:pointer-events-auto',\n    axisLabel: 'absolute truncate text-xs tabular-nums text-muted-foreground',\n    rule: 'absolute',\n    ruleLabel: 'absolute whitespace-nowrap text-xs font-medium',\n    header: 'flex flex-col gap-0.5',\n    headerTitle: 'truncate text-sm text-muted-foreground',\n    headerValue: 'truncate text-2xl font-semibold tabular-nums tracking-tight',\n    headerCaption: 'truncate text-xs text-muted-foreground',\n    legend: 'flex flex-wrap items-center gap-x-3 gap-y-1 pt-1 pb-2 text-xs text-muted-foreground',\n    legendItem: 'flex items-center gap-1.5',\n    legendSwatch: 'size-2 shrink-0 rounded-full',\n    // A custom body gets no surface from the chart, so the readout draws its own.\n    readout:\n      'flex w-max flex-col gap-0.5 rounded-xl border border-border bg-popover px-2.5 py-1.5 text-xs tabular-nums text-popover-foreground shadow-lg',\n    readoutTitle: 'text-muted-foreground',\n    readoutRow: 'flex items-center gap-1.5 text-sm font-semibold',\n    readoutSwatch: 'size-1.5 shrink-0 rounded-full',\n  },\n});\n\n/** Room left around the plot for the axis labels and the markers' rings. */\nconst PADDING = { top: 12, right: 10, bottom: 22, left: 10 };\nconst COMPACT_PADDING = { top: 2, right: 1, bottom: 2, left: 1 };\n\n/** Left gutter reserved when a `YAxis` is present, for its labels to sit in. */\nconst Y_AXIS_WIDTH = 44;\n\n/** Gap between the value labels and the plot they sit beside. */\nconst Y_AXIS_GUTTER = 6;\n\n/** Box each x label is centred in. A longer one is truncated rather than shoving. */\nconst POINT_LABEL_WIDTH = 56;\n\n/** How far past the box the reveal's clip reaches at rest, for strokes and dots on the edge. */\nconst REVEAL_BLEED = 24;\n\nconst REVEAL_EASING = 'cubic-bezier(0.33, 1, 0.68, 1)';\n\n/** The chart's `ease-out`, so what is drawn outside it keeps time with the marks. */\nconst easeOut = (t: number) => 1 - (1 - t) ** 2;\n\nconst prefersReducedMotion = () =>\n  typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n/** One row's value for one mark. Null where the column has a gap. */\ninterface MarkRow {\n  index: number;\n  value: number | null;\n}\n\ntype MarkSpec =\n  | { kind: 'line'; dataKey: string; color: string; strokeWidth: number; curve?: PlotCurve; dashArray?: string }\n  | { kind: 'area'; dataKey: string; color: string; opacity: number; curve?: PlotCurve }\n  | {\n      kind: 'bars';\n      dataKey: string;\n      color: string;\n      gap: number;\n      radius: number;\n      /** One number for the column, or one per row. */\n      opacity: number | readonly number[];\n      baseline: number;\n      minHeight: number;\n    }\n  | { kind: 'dots'; dataKey: string; color: string; size: number; ringWidth: number };\n\ntype RegisteredMark = MarkSpec & { id: string; order: number };\n\n/** Which child of the root a part was written as, so marks draw in written order. */\nconst OrderContext = createContext(0);\n\ninterface Hosts {\n  grid: SVGSVGElement | null;\n  under: SVGSVGElement | null;\n  over: SVGSVGElement | null;\n  overlay: HTMLDivElement | null;\n}\n\n/** What the root's parts asked for. The readout's formatters are read when it opens. */\ninterface Slots {\n  yAxis?: boolean;\n  cursor?: { color: PlotColor | undefined; showLine: boolean };\n  tooltip?: {\n    className: string | undefined;\n    latest: { current: Pick<PlotTooltipProps, 'formatValue' | 'formatX' | 'children'> };\n  };\n}\n\n/** Everything a mark needs to draw itself. Read it with `usePlot()`. */\nexport interface PlotGeometry {\n  /** The rows, in order. */\n  data: readonly PlotDatum[];\n  /** Key holding the x label. */\n  xDataKey: string;\n  /** The drawable box, after the padding and any axis gutter are taken off. */\n  plot: PlotBox;\n  /** How an index becomes an x. */\n  xScale: PlotScale;\n  /** `loading` draws nothing but the frame. */\n  status: PlotStatus;\n  /** Bottom of the y-domain, moving while the domain settles. Draw with this one, so a mark moves with the rest. */\n  domainMin: number;\n  /** Top of the y-domain, moving while the domain settles. */\n  domainMax: number;\n  /** The domain being settled on. Labels read this, so they hold still while the marks move. */\n  extent: [number, number];\n  /** The five theme series colours, as CSS values. */\n  palette: readonly string[];\n  /** Every mark that registered, as `[dataKey, CSS colour]`. */\n  series: readonly [string, string][];\n  /** Adds a column of your own to the domain and the legend. */\n  registerSeries: (key: string, color: string) => void;\n  /** Takes back what `registerSeries` added. */\n  unregisterSeries: (key: string, color?: string) => void;\n  /** Row being read, or `-1`. */\n  activeIndex: number;\n}\n\ninterface PlotContextValue extends PlotGeometry {\n  hosts: Hosts;\n  /** Written position of the first mark, which decides which side of the marks a layer falls. */\n  firstMarkOrder: number;\n  setMark: (mark: RegisteredMark) => void;\n  removeMark: (id: string) => void;\n  setSlot: <K extends keyof Slots>(slot: K, value: Slots[K]) => void;\n}\n\nconst PlotContext = createContext<PlotContextValue | null>(null);\n\nfunction useChart(component: string): PlotContextValue {\n  const context = useContext(PlotContext);\n  if (!context) {\n    throw new Error(`${component} must be used within a <Plot>`);\n  }\n  return context;\n}\n\n/** The resolved geometry, for a mark of your own. It re-renders on each frame of a domain change. */\nexport function usePlot(): PlotGeometry {\n  return useChart('usePlot');\n}\n\n/** The row being read, for a readout drawn inside the plot. */\nexport function usePlotCursor(): { activeIndex: number; activePoint: PlotDatum | null } {\n  const { data, activeIndex } = useChart('usePlotCursor');\n  return { activeIndex, activePoint: activeIndex >= 0 ? (data[activeIndex] ?? null) : null };\n}\n\nexport interface PlotProps extends Omit<ComponentProps<'div'>, 'ref'> {\n  /** The rows. Each one is a position along the x-axis. */\n  data: readonly PlotDatum[];\n  /** Key holding the x label. Used by the axis and the readout. */\n  xDataKey?: string;\n  /** `loading` draws the frame and nothing in it, and reveals the marks when it turns `ready`. */\n  status?: PlotStatus;\n  /** The plot's width ÷ height. `2` is the wide card shape, and `1.6` suits a narrow column. */\n  aspectRatio?: number;\n  /** Milliseconds for the plot to be uncovered. */\n  animationDuration?: number;\n  /** Milliseconds for the marks and the y-axis to settle after the data changes. */\n  domainDuration?: number;\n  /**\n   * The y-domain, as `[low, high]`. Either end may be a number to pin it there or\n   * `auto` to take it from the data. `[0, 'auto']` keeps the baseline at zero while\n   * the top follows whatever arrives.\n   */\n  yDomain?: [PlotBound, PlotBound];\n  /**\n   * Round the derived ends of the y-domain out to a step of 1, 2 or 5 times a power\n   * of ten, so the axis labels are values a reader can measure against. It only ever\n   * widens the axis, and a pinned end is left alone.\n   */\n  nice?: boolean;\n  /**\n   * How an index becomes an x. Derived from the marks when left out: a plot with bars\n   * in it is banded, and anything else is on points. `Plot.Bars` always needs slices,\n   * so it keeps the plot banded even when this says `point`.\n   */\n  xScale?: PlotScale;\n  /** How series are joined between points, unless a mark overrides it. */\n  curve?: PlotCurve;\n  /**\n   * The row being read, and `-1`/`null` when there is none. This is how a readout\n   * outside the plot gets its value. Fires when the row changes, not per frame.\n   */\n  onActiveIndexChange?: (index: number, datum: PlotDatum | null) => void;\n  /** Drop the padding so the marks reach the edges, for a plot with no axis, grid or cursor. */\n  compact?: boolean;\n  /** What a screen reader calls the chart. */\n  label?: string;\n  /** What a screen reader says for one row. Defaults to its keys and values. */\n  labelForDatum?: (datum: PlotDatum, index: number) => string;\n}\n\n/** Imperative handle: re-run the reveal, for a \"replay\" control. */\nexport interface PlotHandle {\n  replay: () => void;\n}\n\nconst PlotRoot = forwardRef<PlotHandle, PlotProps & RenderProp<'div'>>(function PlotRoot(\n  {\n    data,\n    xDataKey = 'label',\n    status = 'ready',\n    aspectRatio = 2,\n    animationDuration = 700,\n    domainDuration = 500,\n    yDomain,\n    nice = false,\n    xScale: xScaleProp,\n    curve = 'monotone',\n    onActiveIndexChange,\n    compact = false,\n    label,\n    labelForDatum,\n    className,\n    children,\n    render,\n    ...props\n  },\n  ref\n) {\n  const slotClasses = plotVariants();\n  const boxRef = useRef<HTMLDivElement>(null);\n  const revealRef = useRef<HTMLDivElement>(null);\n  const interactionRef = useRef<ChartInteractionController<MarkRow, number, number> | null>(null);\n  const releaseDrag = useRef<(() => void) | null>(null);\n  const [size, setSize] = useState({ width: 0, height: 0 });\n  const [marks, setMarks] = useState<readonly RegisteredMark[]>([]);\n  const [extraSeries, setExtraSeries] = useState<readonly [string, string][]>([]);\n  const [slots, setSlots] = useState<Slots>({});\n  const [activeIndex, setActiveIndex] = useState(-1);\n  const [gridHost, setGridHost] = useState<SVGSVGElement | null>(null);\n  const [underHost, setUnderHost] = useState<SVGSVGElement | null>(null);\n  const [overHost, setOverHost] = useState<SVGSVGElement | null>(null);\n  const [overlayHost, setOverlayHost] = useState<HTMLDivElement | null>(null);\n\n  /*\n   * The chart mounts once its width is known. Mounted at a guess, the first\n   * measurement is a resize, and a resize would redraw the marks under the reveal.\n   */\n  useEffect(() => {\n    const node = boxRef.current;\n    if (!node) return;\n    const read = () => setSize({ width: node.clientWidth, height: node.clientHeight });\n    read();\n    const observer = new ResizeObserver(read);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n\n  const setMark = useCallback((mark: RegisteredMark) => {\n    setMarks((current) =>\n      current.some(({ id }) => id === mark.id)\n        ? current.map((entry) => (entry.id === mark.id ? mark : entry))\n        : [...current, mark]\n    );\n  }, []);\n  const removeMark = useCallback((id: string) => setMarks((current) => current.filter((entry) => entry.id !== id)), []);\n  const setSlot = useCallback<PlotContextValue['setSlot']>(\n    (slot, value) => setSlots((current) => ({ ...current, [slot]: value })),\n    []\n  );\n  const registerSeries = useCallback(\n    (key: string, color: string) => setExtraSeries((current) => [...current, [key, color]]),\n    []\n  );\n  const unregisterSeries = useCallback((key: string, color?: string) => {\n    setExtraSeries((current) => {\n      const matching = current.flatMap((entry, index) =>\n        entry[0] === key && (color === undefined || entry[1] === color) ? [index] : []\n      );\n      const at = matching[matching.length - 1];\n      return at === undefined ? current : current.filter((_entry, index) => index !== at);\n    });\n  }, []);\n\n  const loading = status === 'loading';\n  const { cursor, tooltip } = slots;\n\n  // Stable, so a mark that registered later in time still draws where it was written.\n  const ordered = useMemo(() => [...marks].sort((a, b) => a.order - b.order), [marks]);\n  const firstMarkOrder = ordered[0]?.order ?? Number.POSITIVE_INFINITY;\n  const hasBars = ordered.some(({ kind }) => kind === 'bars');\n  const xScale: PlotScale = hasBars ? 'band' : (xScaleProp ?? 'point');\n  const banded = xScale === 'band';\n\n  // The same key on two marks is one entry, in the colour registered last.\n  const series = useMemo<readonly [string, string][]>(() => {\n    const byKey = new Map<string, string>();\n    for (const { dataKey, color } of ordered) byKey.set(dataKey, color);\n    for (const [key, color] of extraSeries) byKey.set(key, color);\n    return [...byKey];\n  }, [ordered, extraSeries]);\n\n  const seriesKeys = series.map(([key]) => key).join('\\n');\n  const [yLow, yHigh] = yDomain ?? [];\n  const extent = useMemo(\n    () =>\n      plotExtent(data, seriesKeys ? seriesKeys.split('\\n') : [], {\n        yDomain: yDomain ? [yLow as PlotBound, yHigh as PlotBound] : undefined,\n        hasBars,\n        nice,\n      }),\n    [data, seriesKeys, yLow, yHigh, hasBars, nice]\n  );\n\n  /*\n   * The marks move with the chart's own motion. This follows them for everything\n   * drawn outside it (a rule, a mark of your own), over the same duration.\n   */\n  const [domain, setDomain] = useState(extent);\n  const shown = useRef(extent);\n  const [lowEnd, highEnd] = extent;\n  useEffect(() => {\n    const from = shown.current;\n    const settled = from[0] === lowEnd && from[1] === highEnd;\n    if (settled) return;\n    const land = (next: [number, number]) => {\n      shown.current = next;\n      setDomain(next);\n    };\n    // Nothing drawn yet means nothing to move from, so the first real domain lands at once.\n    if (marks.length === 0 || loading || domainDuration <= 0 || prefersReducedMotion()) {\n      land([lowEnd, highEnd]);\n      return;\n    }\n    const start = performance.now();\n    let frame = requestAnimationFrame(function step(now) {\n      // A frame's timestamp can be earlier than `start`, and a negative progress overshoots.\n      const progress = Math.min(1, Math.max(0, (now - start) / domainDuration));\n      land(domainBetween(from, [lowEnd, highEnd], easeOut(progress)));\n      if (progress < 1) frame = requestAnimationFrame(step);\n    });\n    return () => cancelAnimationFrame(frame);\n  }, [lowEnd, highEnd, marks.length > 0, loading, domainDuration]);\n\n  const hasYAxis = slots.yAxis === true;\n  const pad = compact ? COMPACT_PADDING : { ...PADDING, left: hasYAxis ? Y_AXIS_WIDTH : PADDING.left };\n  const plot = useMemo<PlotBox>(\n    () => ({\n      left: pad.left,\n      top: pad.top,\n      width: Math.max(size.width - pad.left - pad.right, 0),\n      height: Math.max(size.height - pad.top - pad.bottom, 0),\n    }),\n    [pad.left, pad.top, pad.right, pad.bottom, size.width, size.height]\n  );\n\n  const readable = ordered.length > 0 && data.length > 0 && !loading;\n  const interactive = cursor !== undefined && readable;\n  const sliceWidth = data.length > 0 ? plot.width / data.length : 0;\n\n  const definition = useMemo(() => {\n    const indexes = Array.from({ length: data.length }, (_unused, index) => index);\n    const curves = { monotone: plotCurve('monotone'), linear: plotCurve('linear'), step: plotCurve('step') };\n\n    const drawn = ordered.map((mark) => {\n      const rows: MarkRow[] = columnValues(data, mark.dataKey).map((value, index) => ({ index, value }));\n      const shared = { id: mark.id, x: 'index' as const, y: 'value' as const };\n      const key = (row: MarkRow) => `${mark.id}:${row.index}`;\n      const present = rows.filter((row) => row.value !== null);\n      switch (mark.kind) {\n        case 'line':\n          return lineY(rows, {\n            ...shared,\n            stroke: mark.color,\n            strokeWidth: mark.strokeWidth,\n            curve: curves[mark.curve ?? curve],\n            ...(mark.dashArray ? { strokeDasharray: mark.dashArray } : {}),\n          });\n        case 'area':\n          return areaY(rows, {\n            id: mark.id,\n            x: 'index' as const,\n            y1: lowEnd,\n            y2: 'value' as const,\n            fill: mark.color,\n            fillOpacity: mark.opacity,\n            curve: curves[mark.curve ?? curve],\n          });\n        case 'bars': {\n          const base = Math.min(Math.max(mark.baseline, lowEnd), highEnd);\n          const { opacity } = mark;\n          return barY(present, {\n            id: mark.id,\n            x: 'index' as const,\n            key,\n            y1: base,\n            y2: (row: MarkRow) => barEnd(row.value ?? base, base, mark.minHeight, plot.height, [lowEnd, highEnd]),\n            // The mark's own opacity is one number, so a per-row one goes into the fill.\n            ...(typeof opacity === 'number'\n              ? { fill: mark.color, fillOpacity: opacity }\n              : { fill: ({ index }: MarkRow) => fadedColor(mark.color, opacity[index] ?? 1) }),\n            radius: { end: mark.radius },\n            maxThickness: Math.max(1, sliceWidth * (1 - Math.min(Math.max(mark.gap, 0), 0.95))),\n          });\n        }\n        case 'dots':\n          return dot(present, {\n            ...shared,\n            key,\n            r: mark.size,\n            fill: mark.color,\n            stroke: 'var(--background)',\n            strokeWidth: mark.ringWidth,\n          });\n      }\n    });\n\n    const guide =\n      cursor?.showLine && interactive\n        ? [\n            crosshair<number, number>({\n              x: { stroke: cursor.color ? colorOf(cursor.color) : 'var(--foreground)', strokeOpacity: 0.45 },\n              y: false,\n              // It follows the pointer, so it does not take the time a change of data does.\n              motion: false,\n            }),\n          ]\n        : [];\n\n    return defineChart({\n      theme: THEME,\n      marks: loading ? [] : [...drawn, ...guide],\n      scales: {\n        x: {\n          // Whole slices, so a line's point and a bar's centre agree. A bar's width is its own.\n          scale: banded ? scaleBand(indexes, [0, 1]) : scalePoint(indexes, [0, 1]),\n          axis: false as const,\n        },\n        y: { scale: scaleLinear([lowEnd, highEnd], [0, 1]), axis: false as const, grid: false as const },\n      },\n      margin: pad,\n      focusRing: false,\n      // The reveal uncovers the marks, so all a mark animates is a change of data.\n      motion: (context: ChartMotionContext<MarkRow>) =>\n        context.phase === 'enter'\n          ? (false as const)\n          : { transition: { type: 'tween' as const, duration: domainDuration, easing: 'ease-out' as const } },\n      keyboard: interactive,\n      pointer: interactive,\n      ...(interactive\n        ? {\n            focus: 'group-x' as const,\n            maxFocusDistance: Number.POSITIVE_INFINITY,\n            /*\n             * There even without a `Plot.Tooltip`, which then draws nothing, because\n             * the chart's pinning and its live region both come with this.\n             */\n            tooltip: {\n              use: chartTooltip,\n              // Beside the line and along the top, clear of the marks being read.\n              anchor: { x: 'value', y: 'plot-top' } as const,\n              placement: ['bottom-right', 'bottom-left'] as const,\n              offset: 10,\n              // Drawn by `renderReadout`. This is what the live region announces.\n              content(points: readonly ChartPoint<MarkRow, number, number>[]) {\n                const datum = points[0] ? data[points[0].datum.index] : undefined;\n                if (!datum) return { rows: [] };\n                const { formatValue, formatX } = tooltip?.latest.current ?? {};\n                return {\n                  title: formatX ? formatX(datum) : String(datum[xDataKey] ?? ''),\n                  rows: series.flatMap(([key]) => {\n                    const value = datum[key];\n                    if (typeof value !== 'number') return [];\n                    return [{ label: key, value: formatValue ? formatValue(value, key) : compactNumber(value) }];\n                  }),\n                };\n              },\n            },\n          }\n        : {}),\n    });\n  }, [\n    data,\n    xDataKey,\n    ordered,\n    series,\n    curve,\n    banded,\n    lowEnd,\n    highEnd,\n    pad.left,\n    pad.top,\n    pad.right,\n    pad.bottom,\n    sliceWidth,\n    plot.height,\n    loading,\n    interactive,\n    cursor,\n    tooltip,\n    domainDuration,\n  ]);\n\n  // Marks take their timing from the definition. This one is what the readout moves with.\n  const renderer = useMemo(() => motion({ transition: { type: 'tween', duration: 160, easing: 'ease-out' } }), []);\n\n  /*\n   * One reveal for every mark, as a clip widening over the chart and the layers\n   * together. It ends unclipped, because the readout reaches past the box. Nothing\n   * is set for the finished state, so reduced motion simply never starts it.\n   */\n  const plotLeft = plot.left;\n  const boxWidth = size.width;\n  const playReveal = useCallback(() => {\n    const node = revealRef.current;\n    if (!node || prefersReducedMotion() || typeof node.animate !== 'function') return;\n    const bleed = `-${REVEAL_BLEED}px`;\n    node.getAnimations().forEach((animation) => animation.cancel());\n    node.animate(\n      [\n        { clipPath: `inset(${bleed} ${boxWidth - plotLeft}px ${bleed} ${bleed})` },\n        { clipPath: `inset(${bleed} ${bleed} ${bleed} ${bleed})` },\n      ],\n      { duration: animationDuration, easing: REVEAL_EASING }\n    );\n  }, [animationDuration, boxWidth, plotLeft]);\n\n  useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);\n\n  // Plays once, and going back to `loading` arms it again, so a refetch is uncovered too.\n  const revealed = useRef(false);\n  const ready = boxWidth > 0 && data.length > 0 && !loading;\n  useLayoutEffect(() => {\n    if (loading) revealed.current = false;\n    if (revealed.current || !ready) return;\n    revealed.current = true;\n    playReveal();\n  }, [loading, ready, playReveal]);\n\n  // The chart also reports a row again when the data is rebuilt under it. Only a\n  // change of row is news, and repeating it can loop a caller that keeps the datum.\n  const reportedIndex = useRef(-1);\n  const reportActive = (index: number) => {\n    if (reportedIndex.current === index) return;\n    reportedIndex.current = index;\n    setActiveIndex(index);\n    onActiveIndexChange?.(index, index >= 0 ? (data[index] ?? null) : null);\n  };\n  useEffect(() => {\n    if (!interactive) reportActive(-1);\n  }, [interactive]);\n\n  // A held reading ends with the next press, or when the plot goes away.\n  useEffect(() => () => releaseDrag.current?.(), []);\n\n  const clearReading = () => {\n    releaseDrag.current?.();\n    interactionRef.current?.setControlledFocus(null);\n  };\n\n  /*\n   * The chart follows a mouse on its own but not a finger, which it treats as a\n   * tap. A sideways drag reads the rows it crosses, and the last one stays until the\n   * next press, the same as a tap's. A vertical drag never gets here: `pan-y` gives\n   * it to the page, and the browser cancels the pointer.\n   */\n  const readDrag = (event: ReactPointerEvent<HTMLDivElement>) => {\n    if (event.pointerType !== 'touch' || !interactive) return;\n    const interaction = interactionRef.current;\n    const target = interaction?.resolvePointer(event.clientX, event.clientY);\n    if (!interaction || !target) return;\n    interaction.setControlledFocus(target);\n    if (releaseDrag.current) return;\n    document.addEventListener('pointerdown', clearReading, { capture: true });\n    releaseDrag.current = () => {\n      document.removeEventListener('pointerdown', clearReading, { capture: true });\n      releaseDrag.current = null;\n    };\n  };\n\n  const renderReadout = (index: number) => {\n    const datum = data[index];\n    if (!datum) return null;\n    const { formatValue, formatX, children: draw } = tooltip?.latest.current ?? {};\n    return (\n      <div data-slot=\"plot-readout\" className={slotClasses.readout({ className: tooltip?.className })}>\n        {draw ? (\n          draw(datum, index)\n        ) : (\n          <>\n            <div className={slotClasses.readoutTitle()}>{formatX ? formatX(datum) : String(datum[xDataKey] ?? '')}</div>\n            {series.map(([key, color]) => {\n              const value = datum[key];\n              if (typeof value !== 'number') return null;\n              return (\n                <div key={key} className={slotClasses.readoutRow()}>\n                  {series.length > 1 ? (\n                    <span className={slotClasses.readoutSwatch()} style={{ background: color }} />\n                  ) : null}\n                  {formatValue ? formatValue(value, key) : compactNumber(value)}\n                </div>\n              );\n            })}\n          </>\n        )}\n      </div>\n    );\n  };\n\n  const summary = label ?? `Plot, ${data.length} ${data.length === 1 ? 'row' : 'rows'}`;\n  const spokenRow = (datum: PlotDatum, index: number) =>\n    labelForDatum?.(datum, index) ??\n    [xDataKey, ...series.map(([key]) => key)]\n      .filter((key) => datum[key] !== null && datum[key] !== undefined)\n      .map((key) => `${key}, ${datum[key]}`)\n      .join('. ');\n\n  const hosts = useMemo<Hosts>(\n    () => ({ grid: gridHost, under: underHost, over: overHost, overlay: overlayHost }),\n    [gridHost, underHost, overHost, overlayHost]\n  );\n\n  const context = useMemo<PlotContextValue>(\n    () => ({\n      data,\n      xDataKey,\n      plot,\n      xScale,\n      status,\n      domainMin: domain[0],\n      domainMax: domain[1],\n      extent: [lowEnd, highEnd],\n      palette: PALETTE,\n      series,\n      registerSeries,\n      unregisterSeries,\n      activeIndex,\n      hosts,\n      firstMarkOrder,\n      setMark,\n      removeMark,\n      setSlot,\n    }),\n    [\n      data,\n      xDataKey,\n      plot,\n      xScale,\n      status,\n      domain,\n      lowEnd,\n      highEnd,\n      series,\n      registerSeries,\n      unregisterSeries,\n      activeIndex,\n      hosts,\n      firstMarkOrder,\n      setMark,\n      removeMark,\n      setSlot,\n    ]\n  );\n\n  const element = useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'plot',\n        className: slotClasses.root({ className }),\n        children: (\n          <>\n            {loading ? null : (\n              <ul aria-label={summary} className=\"sr-only\">\n                {data.map((datum, index) => (\n                  <li key={index}>{spokenRow(datum, index)}</li>\n                ))}\n              </ul>\n            )}\n            <div\n              ref={boxRef}\n              data-slot=\"plot-box\"\n              className={slotClasses.box()}\n              style={{ aspectRatio }}\n              onKeyDown={(event) => {\n                if (event.key === 'Escape') clearReading();\n              }}\n              onPointerMove={readDrag}\n              onPointerCancel={clearReading}\n            >\n              <svg ref={setGridHost} aria-hidden className={slotClasses.layer()} />\n              <div ref={revealRef} data-slot=\"plot-reveal\" className={slotClasses.reveal()}>\n                <svg ref={setUnderHost} aria-hidden className={slotClasses.layer()} />\n                {size.width > 0 ? (\n                  <RendererChart\n                    definition={definition}\n                    renderer={renderer}\n                    ariaLabel={summary}\n                    aspectRatio={aspectRatio}\n                    initialWidth={size.width}\n                    className={slotClasses.chart()}\n                    onFocusChange={(point) => {\n                      const row = point?.datum as MarkRow | undefined;\n                      reportActive(row ? row.index : -1);\n                    }}\n                    onRender={({ interaction }) => {\n                      interactionRef.current = interaction as ChartInteractionController<MarkRow, number, number>;\n                    }}\n                    renderTooltipBody={({ points }) => {\n                      const row = points[0]?.datum as MarkRow | undefined;\n                      return row && tooltip ? renderReadout(row.index) : null;\n                    }}\n                  />\n                ) : null}\n                <svg ref={setOverHost} aria-hidden className={slotClasses.layer()} />\n              </div>\n              <div ref={setOverlayHost} className={slotClasses.overlay()} />\n            </div>\n            {Children.map(children, (child, index) => (\n              <OrderContext.Provider value={index}>{child}</OrderContext.Provider>\n            ))}\n          </>\n        ),\n      },\n      props\n    ),\n  });\n\n  return <PlotContext.Provider value={context}>{element}</PlotContext.Provider>;\n});\nPlotRoot.displayName = 'Plot';\n\n/** Hands a part's formatters to the root without a new function rebuilding the chart. */\nfunction useLatest<T>(value: T) {\n  const [latest] = useState(() => ({ current: value }));\n  useEffect(() => {\n    latest.current = value;\n  });\n  return latest;\n}\n\n/** Tells the root about a mark, keeping its place in the order when its props change. */\nfunction useMark(spec: MarkSpec) {\n  const { setMark, removeMark } = useChart(`Plot.${spec.kind}`);\n  const id = useId();\n  const order = useContext(OrderContext);\n  const signature = JSON.stringify(spec);\n  useEffect(() => {\n    setMark({ ...spec, id, order });\n  }, [signature, id, order, setMark]);\n  useEffect(() => () => removeMark(id), [id, removeMark]);\n}\n\n/* -------------------------------------------------------------------------- */\n/* The marks                                                                  */\n/* -------------------------------------------------------------------------- */\n\nexport interface PlotSeriesProps {\n  /** Column of `data` this mark draws. */\n  dataKey: string;\n  /** A theme role by name, or any CSS colour. */\n  color?: PlotColor;\n}\n\nexport interface PlotLineProps extends PlotSeriesProps {\n  /** Thickness of the stroke, in pixels. */\n  strokeWidth?: number;\n  /**\n   * `monotone` never overshoots between points, `linear` joins them straight, and\n   * `step` holds each value until the next point. The root's by default.\n   */\n  curve?: PlotCurve;\n  /** Dash pattern, such as `\"6 4\"`, for a forecast or a series that is not real yet. */\n  dashArray?: string;\n}\n\n/** A series as a stroked line. A gap in the column breaks the line. */\nfunction PlotLine({\n  dataKey,\n  color = 'chart-1',\n  strokeWidth = 2.5,\n  curve,\n  dashArray,\n}: PlotLineProps) {\n  useMark({ kind: 'line', dataKey, color: colorOf(color), strokeWidth, curve, dashArray });\n  return null;\n}\nPlotLine.displayName = 'Plot.Line';\n\nexport interface PlotAreaProps extends PlotSeriesProps {\n  /** Opacity of the fill. */\n  opacity?: number;\n  /**\n   * `monotone` never overshoots between points, `linear` joins them straight, and\n   * `step` holds each value until the next point. The root's by default.\n   */\n  curve?: PlotCurve;\n}\n\n/** A series as a fill down to the bottom of the plot. Write it before the line it belongs under. */\nfunction PlotArea({\n  dataKey,\n  color = 'chart-1',\n  opacity = 0.18,\n  curve,\n}: PlotAreaProps) {\n  useMark({ kind: 'area', dataKey, color: colorOf(color), opacity, curve });\n  return null;\n}\nPlotArea.displayName = 'Plot.Area';\n\nexport interface PlotBarsProps extends PlotSeriesProps {\n  /** Fraction of each slice left empty, `0` to `1`. */\n  gap?: number;\n  /** Rounds the end the bar grows towards, in pixels. */\n  radius?: number;\n  /**\n   * Opacity of the fill. Pass a function to set it per row, to fade rows that have\n   * not happened yet or dim the ones not being read.\n   */\n  opacity?: number | ((datum: PlotDatum, index: number) => number);\n  /**\n   * The shortest a bar may be drawn, in pixels, so a small value beside a large one\n   * still shows. A value on the baseline draws no bar. The reading keeps the real value.\n   */\n  minHeight?: number;\n  /**\n   * The value the columns grow from, for a chart of movement against a reference.\n   * It is clamped into the axis, so a baseline the domain does not cover falls back\n   * to the nearer edge.\n   */\n  baseline?: number;\n}\n\n/** A series as columns. Its presence puts the whole plot on a band scale. */\nfunction PlotBars({\n  dataKey,\n  color = 'chart-1',\n  gap = 0.35,\n  radius = 4,\n  opacity = 1,\n  minHeight = 0,\n  baseline = 0,\n}: PlotBarsProps) {\n  const { data } = useChart('Plot.Bars');\n  // Resolved here, so an inline function re-registers the mark only when a row's opacity moves.\n  const resolved = typeof opacity === 'number' ? opacity : data.map(opacity);\n  useMark({ kind: 'bars', dataKey, color: colorOf(color), gap, radius, opacity: resolved, baseline, minHeight });\n  return null;\n}\nPlotBars.displayName = 'Plot.Bars';\n\nexport interface PlotDotsProps extends PlotSeriesProps {\n  /** Radius, in pixels. */\n  size?: number;\n  /** Ring around each dot in the page colour, so it reads on top of the line. */\n  ringWidth?: number;\n}\n\n/** A dot per row, for a series short enough that its points are worth marking. */\nfunction PlotDots({\n  dataKey,\n  color = 'chart-1',\n  size = 3.5,\n  ringWidth = 2,\n}: PlotDotsProps) {\n  useMark({ kind: 'dots', dataKey, color: colorOf(color), size, ringWidth });\n  return null;\n}\nPlotDots.displayName = 'Plot.Dots';\n\n/* -------------------------------------------------------------------------- */\n/* The layers                                                                 */\n/* -------------------------------------------------------------------------- */\n\nexport interface PlotGridProps {\n  /** Horizontal rules across the plot. Yields `rows + 1` lines. */\n  rows?: number;\n  /** A theme role by name, or any CSS colour. The border colour by default. */\n  color?: PlotColor;\n  /** Dash pattern, such as `\"4 6\"`. An empty string draws a solid rule. */\n  dashArray?: string;\n  /** Opacity of the lines. */\n  opacity?: number;\n}\n\n/** Horizontal rules, outside the reveal because they are the frame the marks arrive into. */\nfunction PlotGrid({\n  rows = 4,\n  color,\n  dashArray = '4 6',\n  opacity = 1,\n}: PlotGridProps) {\n  const { plot, hosts } = useChart('Plot.Grid');\n  if (!hosts.grid || plot.width <= 0) return null;\n  const intervals = Math.max(1, Math.round(rows));\n  return createPortal(\n    <g\n      data-slot=\"plot-grid\"\n      opacity={opacity}\n      stroke={color ? colorOf(color) : 'var(--border)'}\n      strokeDasharray={dashArray || undefined}\n    >\n      {Array.from({ length: intervals + 1 }, (_unused, index) => {\n        const y = plot.top + (plot.height / intervals) * index;\n        return <line key={index} x1={plot.left} x2={plot.left + plot.width} y1={y} y2={y} />;\n      })}\n    </g>,\n    hosts.grid\n  );\n}\nPlotGrid.displayName = 'Plot.Grid';\n\nexport interface PlotLayerProps {\n  /** SVG elements, in the plot's own pixels. */\n  children?: ReactNode;\n}\n\n/**\n * Marks of your own, in SVG, uncovered with the rest. Written before the first mark\n * it draws under every mark, and written after it, over them all. It takes no\n * pointer events, so anything that does belongs in `Plot.Overlay`.\n */\nfunction PlotLayer({ children }: PlotLayerProps) {\n  const { hosts, firstMarkOrder } = useChart('Plot.Layer');\n  const order = useContext(OrderContext);\n  const host = order < firstMarkOrder ? hosts.under : hosts.over;\n  return host ? createPortal(<g data-slot=\"plot-layer\">{children}</g>, host) : null;\n}\nPlotLayer.displayName = 'Plot.Layer';\n\nexport interface PlotOverlayProps extends ComponentProps<'div'> {}\n\n/** Anything of your own that is text or takes a pointer, laid over the drawing. */\nfunction PlotOverlay({ className, render, ...props }: PlotOverlayProps & RenderProp<'div'>) {\n  const { hosts } = useChart('Plot.Overlay');\n  const element = useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      { 'data-slot': 'plot-overlay', className: plotVariants().userOverlay({ className }) },\n      props\n    ),\n  });\n  return hosts.overlay ? createPortal(element, hosts.overlay) : null;\n}\nPlotOverlay.displayName = 'Plot.Overlay';\n\nexport interface PlotRuleProps {\n  /** Where to draw it, in the data's own units. Wins if `x` is given too. */\n  y?: number;\n  /** A row to draw a vertical rule at, by index: the day a change landed, the release being read against. */\n  x?: number;\n  /** A name for what the line means. */\n  label?: string;\n  /** A theme role by name, or any CSS colour, for the line and its caption. The foreground colour by default. */\n  color?: PlotColor;\n  /** Thickness in pixels. */\n  strokeWidth?: number;\n  /** Break the line into dashes, so it reads as an annotation rather than a series. */\n  dashed?: boolean;\n  /** Fades the line and its caption together. */\n  opacity?: number;\n  /** Which end of the rule the caption sits at. */\n  labelPlacement?: 'start' | 'end';\n  /** Added to the caption. */\n  labelClassName?: string;\n  /** Added to the rule. */\n  className?: string;\n}\n\n/**\n * A reference line at a value or a row, with a caption. It hides itself when the\n * value falls outside the axis, rather than pinning to the edge and claiming a\n * number the chart does not cover.\n */\nfunction PlotRule({\n  y,\n  x,\n  label,\n  color,\n  strokeWidth = 1,\n  dashed = false,\n  opacity = 1,\n  labelPlacement = 'end',\n  labelClassName,\n  className,\n}: PlotRuleProps) {\n  const { plot, domainMin, domainMax, status, data, xScale, hosts } = useChart('Plot.Rule');\n  if (status === 'loading' || !hosts.overlay || plot.width <= 0) return null;\n\n  const stroke = color ? colorOf(color) : 'var(--foreground)';\n  const slots = plotVariants();\n  const border = `${strokeWidth}px ${dashed ? 'dashed' : 'solid'} ${stroke}`;\n\n  if (y !== undefined) {\n    const at = yOf(y, plot, domainMin, domainMax);\n    if (at < plot.top - 0.5 || at > plot.top + plot.height + 0.5) return null;\n    return createPortal(\n      <div\n        data-slot=\"plot-rule\"\n        className={slots.rule({ className })}\n        style={{ left: plot.left, width: plot.width, top: at, opacity, borderTop: border }}\n      >\n        {label ? (\n          <span\n            className={slots.ruleLabel({ className: labelClassName })}\n            style={{ color: stroke, top: 2, ...(labelPlacement === 'start' ? { left: 0 } : { right: 0 }) }}\n          >\n            {label}\n          </span>\n        ) : null}\n      </div>,\n      hosts.overlay\n    );\n  }\n\n  if (x === undefined || data.length === 0) return null;\n  const row = Math.max(0, Math.min(data.length - 1, x));\n  const at = xScale === 'band' ? bandOf(row, data.length, plot) : xOf(row, data.length, plot);\n  return createPortal(\n    <div\n      data-slot=\"plot-rule\"\n      className={slots.rule({ className })}\n      style={{ left: at, top: plot.top, height: plot.height, opacity, borderLeft: border }}\n    >\n      {label ? (\n        <span\n          className={slots.ruleLabel({ className: labelClassName })}\n          style={{ color: stroke, left: 4, ...(labelPlacement === 'start' ? { top: 0 } : { bottom: 0 }) }}\n        >\n          {label}\n        </span>\n      ) : null}\n    </div>,\n    hosts.overlay\n  );\n}\nPlotRule.displayName = 'Plot.Rule';\n\nexport interface PlotXAxisProps {\n  /** How many labels to show. The rest are dropped, evenly. */\n  ticks?: number;\n  /** Turn a row into its label. Defaults to the value at `xDataKey`. */\n  format?: (datum: PlotDatum, index: number) => string;\n  /** Added to each label. */\n  className?: string;\n}\n\n/** Labels along the bottom, each centred on the row it names and held inside the frame. */\nfunction PlotXAxis({\n  ticks = 4,\n  format,\n  className,\n}: PlotXAxisProps) {\n  const { data, xDataKey, plot, xScale, hosts } = useChart('Plot.XAxis');\n  if (!hosts.overlay || plot.width <= 0) return null;\n  const slots = plotVariants();\n  return createPortal(\n    <div data-slot=\"plot-x-axis\" aria-hidden>\n      {labelledRows(data.length, ticks).map((index) => {\n        const datum = data[index];\n        if (!datum) return null;\n        const x = xScale === 'band' ? bandOf(index, data.length, plot) : xOf(index, data.length, plot);\n        return (\n          <span\n            key={index}\n            className={slots.axisLabel({ className })}\n            style={{\n              bottom: 0,\n              width: POINT_LABEL_WIDTH,\n              textAlign: 'center',\n              left: Math.max(0, Math.min(x - POINT_LABEL_WIDTH / 2, plot.left + plot.width + PADDING.right - POINT_LABEL_WIDTH)),\n            }}\n          >\n            {format ? format(datum, index) : String(datum[xDataKey] ?? '')}\n          </span>\n        );\n      })}\n    </div>,\n    hosts.overlay\n  );\n}\nPlotXAxis.displayName = 'Plot.XAxis';\n\nexport interface PlotYAxisProps {\n  /** How many intervals to divide the axis into. Yields `ticks + 1` labels. Give it the same count as the grid. */\n  ticks?: number;\n  /** Turn a value into its label. Defaults to a compact number. */\n  format?: (value: number) => string;\n  /** Added to each label. */\n  className?: string;\n}\n\n/**\n * Value labels down the side, one per grid line, with room made for them. They name\n * the domain being settled on, so they hold still while the marks move.\n */\nfunction PlotYAxis({\n  ticks = 4,\n  format,\n  className,\n}: PlotYAxisProps) {\n  const { plot, extent, status, hosts, setSlot } = useChart('Plot.YAxis');\n  useEffect(() => {\n    setSlot('yAxis', true);\n    return () => setSlot('yAxis', undefined);\n  }, [setSlot]);\n\n  if (!hosts.overlay || plot.width <= 0 || status === 'loading') return null;\n  const [min, max] = extent;\n  const intervals = Math.max(1, Math.round(ticks));\n  const slots = plotVariants();\n  return createPortal(\n    <div data-slot=\"plot-y-axis\" aria-hidden>\n      {Array.from({ length: intervals + 1 }, (_unused, index) => {\n        const value = max - ((max - min) * index) / intervals;\n        return (\n          <span\n            key={index}\n            className={slots.axisLabel({ className })}\n            style={{\n              left: 0,\n              width: Math.max(plot.left - Y_AXIS_GUTTER, 0),\n              textAlign: 'right',\n              top: plot.top + (plot.height / intervals) * index,\n              transform: 'translateY(-50%)',\n            }}\n          >\n            {format ? format(value) : compactNumber(value)}\n          </span>\n        );\n      })}\n    </div>,\n    hosts.overlay\n  );\n}\nPlotYAxis.displayName = 'Plot.YAxis';\n\nexport interface PlotCursorProps {\n  /** A theme role by name, or any CSS colour. The foreground colour by default. */\n  color?: PlotColor;\n  /** Hide the vertical line and keep only the reading. */\n  showLine?: boolean;\n}\n\n/**\n * Reading the rows, and the line that follows: hovering a row, pressing or dragging\n * across them, or the arrow keys. A click or a tap pins the reading until the next\n * press elsewhere or Escape. The hit area is the whole plot.\n */\nfunction PlotCursor({\n  color,\n  showLine = true,\n}: PlotCursorProps) {\n  const { setSlot } = useChart('Plot.Cursor');\n  useEffect(() => {\n    setSlot('cursor', { color, showLine });\n    return () => setSlot('cursor', undefined);\n  }, [color, showLine, setSlot]);\n  return null;\n}\nPlotCursor.displayName = 'Plot.Cursor';\n\nexport interface PlotTooltipProps {\n  /** Format one series' value. Defaults to a compact number. */\n  formatValue?: (value: number, key: string) => string;\n  /** Format the heading from the row. Defaults to the value at `xDataKey`. */\n  formatX?: (datum: PlotDatum) => string;\n  /** Draw the readout yourself, given the row being read. */\n  children?: (datum: PlotDatum, index: number) => ReactNode;\n  /** Added to the readout's surface. */\n  className?: string;\n}\n\n/** The readout that rides the cursor. It needs a `Plot.Cursor` beside it, which owns the reading. */\nfunction PlotTooltip({ formatValue, formatX, children, className }: PlotTooltipProps) {\n  const { setSlot } = useChart('Plot.Tooltip');\n  const latest = useLatest({ formatValue, formatX, children });\n  useEffect(() => {\n    setSlot('tooltip', { className, latest });\n    return () => setSlot('tooltip', undefined);\n  }, [className, latest, setSlot]);\n  return null;\n}\nPlotTooltip.displayName = 'Plot.Tooltip';\n\n/* -------------------------------------------------------------------------- */\n/* Above the drawing                                                          */\n/* -------------------------------------------------------------------------- */\n\nexport interface PlotHeaderProps extends Omit<ComponentProps<'div'>, 'title'> {\n  /** A word for what the plot is of. */\n  title?: ReactNode;\n  /** The figure, large. Usually the total, or the row being read. */\n  value?: ReactNode;\n  /** A line under the value. */\n  caption?: ReactNode;\n  /** Replaces the whole header, keeping only its place above the drawing. */\n  children?: ReactNode;\n}\n\n/** The row above the drawing: what it is, and the one number worth reading. */\nfunction PlotHeader({ title, value, caption, className, children, render, ...props }: PlotHeaderProps & RenderProp<'div'>) {\n  const slots = plotVariants();\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'plot-header',\n        className: slots.header({ className }),\n        children: children ?? (\n          <>\n            {title ? <div className={slots.headerTitle()}>{title}</div> : null}\n            {value ? <div className={slots.headerValue()}>{value}</div> : null}\n            {caption ? <div className={slots.headerCaption()}>{caption}</div> : null}\n          </>\n        ),\n      },\n      props\n    ),\n  });\n}\nPlotHeader.displayName = 'Plot.Header';\n\nexport interface PlotLegendProps extends ComponentProps<'div'> {\n  /** Names for the columns, keyed by `dataKey`. Falls back to the key itself. */\n  labels?: Record<string, string>;\n}\n\n/** A swatch and a name per mark, taken from the marks that registered. */\nfunction PlotLegend({ labels, className, render, ...props }: PlotLegendProps & RenderProp<'div'>) {\n  const { series } = useChart('Plot.Legend');\n  const slots = plotVariants();\n  const element = useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'plot-legend',\n        className: slots.legend({ className }),\n        children: series.map(([key, color]) => (\n          <span key={key} className={slots.legendItem()}>\n            <span className={slots.legendSwatch()} style={{ background: color }} />\n            {labels?.[key] ?? key}\n          </span>\n        )),\n      },\n      props\n    ),\n  });\n  return series.length > 0 ? element : null;\n}\nPlotLegend.displayName = 'Plot.Legend';\n\nexport const Plot = Object.assign(PlotRoot, {\n  Header: PlotHeader,\n  Legend: PlotLegend,\n  Grid: PlotGrid,\n  Area: PlotArea,\n  Bars: PlotBars,\n  Line: PlotLine,\n  Dots: PlotDots,\n  Rule: PlotRule,\n  Layer: PlotLayer,\n  Overlay: PlotOverlay,\n  XAxis: PlotXAxis,\n  YAxis: PlotYAxis,\n  Cursor: PlotCursor,\n  Tooltip: PlotTooltip,\n});\n\n/* By name as well, for a Server Component, which cannot dot into a client\n   module. */\nexport {\n  PlotHeader,\n  PlotLegend,\n  PlotGrid,\n  PlotArea,\n  PlotBars,\n  PlotLine,\n  PlotDots,\n  PlotRule,\n  PlotLayer,\n  PlotOverlay,\n  PlotXAxis,\n  PlotYAxis,\n  PlotCursor,\n  PlotTooltip,\n};\n"
    },
    {
      "path": "components/panelui/plot/plot-scale.ts",
      "type": "registry:component",
      "target": "@components/panelui/plot/plot-scale.ts",
      "content": "/**\n * The arithmetic behind Plot, apart from the drawing so it can be tested on its\n * own: the one y-domain every mark shares, and how a row or a value becomes a\n * position. The same functions are exported for a mark written outside the chart.\n */\n\n/** The drawable box inside a chart, after its padding is taken off. */\nexport interface PlotBox {\n  width: number;\n  height: number;\n  left: number;\n  top: number;\n}\n\nexport interface PlotPoint {\n  x: number;\n  y: number;\n}\n\n/**\n * How a series is joined between its points. `step` holds each value until the next\n * point and then moves straight to it, for a balance that only changes on the day it does.\n */\nexport type PlotCurve = 'monotone' | 'linear' | 'step';\n\n/** One end of the y-domain: a number to pin it at, or `auto` to derive it. */\nexport type PlotBound = number | 'auto';\n\nconst MAGNITUDES = ['k', 'M', 'B', 'T'];\n\nconst trimZeros = (text: string) => (text.includes('.') ? text.replace(/\\.?0+$/, '') : text);\n\nconst mantissa = (value: number) => trimZeros(value.toFixed(1));\n\n/** `12k` rather than `12400`, because a readout has one line to say it in. */\nexport function compactNumber(value: number): string {\n  if (!Number.isFinite(value)) return String(value);\n\n  const abs = Math.abs(value);\n  if (abs < 1_000) {\n    if (Number.isInteger(value)) return String(value);\n    // Below one, a single decimal rounds most values to `0`.\n    return trimZeros(value.toFixed(abs < 1 ? 2 : 1));\n  }\n\n  let index = 0;\n  let scaled = value / 1_000;\n  while (Math.abs(scaled) >= 1_000 && index < MAGNITUDES.length - 1) {\n    scaled /= 1_000;\n    index += 1;\n  }\n\n  // Rounding can carry a mantissa to a thousand: 999,999 has to read `1M`.\n  let text = mantissa(scaled);\n  if (Math.abs(Number(text)) >= 1_000 && index < MAGNITUDES.length - 1) {\n    index += 1;\n    text = mantissa(scaled / 1_000);\n  }\n\n  return `${text}${MAGNITUDES[index]}`;\n}\n\n/** A point's x, spreading `total` of them evenly from one edge of the plot to the other. */\nexport function xOf(index: number, total: number, plot: PlotBox): number {\n  if (total <= 1) return plot.left + plot.width / 2;\n  return plot.left + (plot.width * index) / (total - 1);\n}\n\n/** The centre of the `index`th of `total` equal slices of the plot. */\nexport function bandOf(index: number, total: number, plot: PlotBox): number {\n  if (total <= 0) return plot.left + plot.width / 2;\n  return plot.left + (plot.width / total) * (index + 0.5);\n}\n\n/** A value's y, with `min` at the bottom of the plot and `max` at the top. */\nexport function yOf(value: number, plot: PlotBox, min: number, max: number): number {\n  const span = max - min || 1;\n  return plot.top + plot.height - ((value - min) / span) * plot.height;\n}\n\n/** A value's x on a measured axis, with `min` at the left edge and `max` at the right. */\nexport function xAt(value: number, plot: PlotBox, min: number, max: number): number {\n  const span = max - min || 1;\n  return plot.left + ((value - min) / span) * plot.width;\n}\n\n/**\n * Widens a domain out to a step of 1, 2 or 5 times a power of ten. It can only\n * add room, and zero survives, because any step divides it.\n */\nexport function niceDomain(min: number, max: number, ticks = 5): [number, number] {\n  if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) return [min, max];\n\n  const rough = (max - min) / Math.max(1, ticks);\n  const magnitude = 10 ** Math.floor(Math.log10(rough));\n  const error = rough / magnitude;\n  // The thresholds are geometric means, so each step wins the span it is closest to.\n  const step =\n    (error >= Math.sqrt(50) ? 10 : error >= Math.sqrt(10) ? 5 : error >= Math.sqrt(2) ? 2 : 1) * magnitude;\n\n  return [Math.floor(min / step) * step, Math.ceil(max / step) * step];\n}\n\nconst finite = (value: unknown): number | undefined =>\n  typeof value === 'number' && Number.isFinite(value) ? value : undefined;\n\n/** The numbers of one column, with anything unusable left as a gap. */\nexport function columnValues(data: readonly Record<string, unknown>[], key: string): (number | null)[] {\n  return data.map((row) => finite(row[key]) ?? null);\n}\n\nexport interface PlotExtentOptions {\n  yDomain?: readonly [PlotBound, PlotBound];\n  /** A bar is a length from zero, so a plot with bars keeps zero on its axis. */\n  hasBars?: boolean;\n  nice?: boolean;\n}\n\n/**\n * The y-domain, across every column a mark draws.\n *\n * Headroom of a tenth of the span goes on the derived ends only: a pinned end\n * stays where it was pinned, and so does a zero under bars, because room under a\n * baseline lifts the bars off what they are measured from. `nice` replaces the\n * headroom, since rounding out to a whole step is already room.\n */\nexport function plotExtent(\n  data: readonly Record<string, unknown>[],\n  keys: readonly string[],\n  { yDomain, hasBars = false, nice = false }: PlotExtentOptions = {}\n): [number, number] {\n  const lowPin = finite(yDomain?.[0]);\n  const highPin = finite(yDomain?.[1]);\n  if (lowPin !== undefined && highPin !== undefined) return [lowPin, highPin];\n\n  let min = Infinity;\n  let max = -Infinity;\n  for (const row of data) {\n    for (const key of keys) {\n      const value = finite(row[key]);\n      if (value === undefined) continue;\n      if (value < min) min = value;\n      if (value > max) max = value;\n    }\n  }\n\n  if (min === Infinity) {\n    // Nothing to measure, and a span of zero would draw marks at infinity.\n    const low = lowPin ?? (highPin !== undefined ? highPin - 1 : 0);\n    return [low, highPin ?? low + 1];\n  }\n\n  let lowRoom = 1;\n  let highRoom = 1;\n  if (hasBars) {\n    if (min > 0) min = 0;\n    if (max < 0) max = 0;\n    if (min === 0) lowRoom = 0;\n    if (max === 0) highRoom = 0;\n  }\n\n  // A flat series has no span of its own, so it lands on the middle of the plot.\n  if (min === max) {\n    min -= 1;\n    max += 1;\n  }\n\n  const headroom = (max - min) * 0.1;\n  const [niceLow, niceHigh] = nice ? niceDomain(min, max) : [min, max];\n  const low = lowPin ?? (nice ? niceLow : min - headroom * lowRoom);\n  const high = highPin ?? (nice ? niceHigh : max + headroom * highRoom);\n  if (low < high) return [low, high];\n  // A pin past all the data would flip the axis, so the derived end keeps to its side.\n  const span = max - min;\n  return lowPin !== undefined ? [low, low + span] : [high - span, high];\n}\n\n/**\n * The domain partway through a change, `t` from `0` to `1`.\n *\n * The chart moves a mark's pixels in a straight line from where the old domain put\n * them to where the new one does. Moving the two ends of the domain would not keep\n * up, because a position divides by the span. Blending the two scales is still a\n * straight line in the value, so it is exactly one domain: this one, and anything\n * drawn against it stays on the marks all the way across.\n */\nexport function domainBetween(\n  [fromMin, fromMax]: readonly [number, number],\n  [toMin, toMax]: readonly [number, number],\n  t: number\n): [number, number] {\n  if (t >= 1) return [toMin, toMax];\n  const fromSpan = fromMax - fromMin || 1;\n  const toSpan = toMax - toMin || 1;\n  const slope = (1 - t) / fromSpan + t / toSpan;\n  const offset = -((1 - t) * fromMin) / fromSpan - (t * toMin) / toSpan;\n  return [-offset / slope, (1 - offset) / slope];\n}\n\n/**\n * Monotone cubic tangents. The tangent at each point is clamped against the\n * slopes either side of it, so a curve cannot overshoot between two points and\n * draw a dip that is not in the data.\n */\nfunction tangents(xs: readonly number[], ys: readonly number[]): number[] {\n  const n = xs.length;\n  const slopes: number[] = [];\n  for (let i = 0; i < n - 1; i += 1) {\n    slopes.push((ys[i + 1]! - ys[i]!) / (xs[i + 1]! - xs[i]! || 1));\n  }\n\n  const result: number[] = new Array(n).fill(0);\n  result[0] = slopes[0] ?? 0;\n  result[n - 1] = slopes[n - 2] ?? 0;\n  for (let i = 1; i < n - 1; i += 1) {\n    const previous = slopes[i - 1]!;\n    const next = slopes[i]!;\n    result[i] = previous * next <= 0 ? 0 : (previous + next) / 2;\n  }\n\n  // Pull any tangent back inside the circle of radius 3, which keeps the segment monotone.\n  for (let i = 0; i < n - 1; i += 1) {\n    const slope = slopes[i]!;\n    if (slope === 0) {\n      result[i] = 0;\n      result[i + 1] = 0;\n      continue;\n    }\n    const a = result[i]! / slope;\n    const b = result[i + 1]! / slope;\n    const magnitude = Math.sqrt(a * a + b * b);\n    if (magnitude > 3) {\n      result[i] = (3 / magnitude) * a * slope;\n      result[i + 1] = (3 / magnitude) * b * slope;\n    }\n  }\n  return result;\n}\n\n/** One unbroken run of points as a path, curved or straight. */\nexport function segment(points: readonly PlotPoint[], curve: PlotCurve): string {\n  if (!points.length) return '';\n  if (curve === 'step') {\n    return points\n      .map(({ x, y }, index) => (index === 0 ? `M${x},${y}` : `L${x},${points[index - 1]!.y} L${x},${y}`))\n      .join(' ');\n  }\n  if (points.length < 3 || curve === 'linear') {\n    return points.map(({ x, y }, index) => `${index === 0 ? 'M' : 'L'}${x},${y}`).join(' ');\n  }\n\n  const xs = points.map(({ x }) => x);\n  const ys = points.map(({ y }) => y);\n  const ms = tangents(xs, ys);\n  let d = `M${xs[0]},${ys[0]}`;\n  for (let i = 0; i < points.length - 1; i += 1) {\n    const dx = (xs[i + 1]! - xs[i]!) / 3;\n    d += ` C${xs[i]! + dx},${ys[i]! + ms[i]! * dx} ${xs[i + 1]! - dx},${ys[i + 1]! - ms[i + 1]! * dx} ${xs[i + 1]},${ys[i + 1]}`;\n  }\n  return d;\n}\n\n/** Points, split at the gaps. A null breaks the series rather than crossing it. */\nfunction runsOf(\n  values: readonly (number | null)[],\n  plot: PlotBox,\n  min: number,\n  max: number,\n  banded: boolean\n): PlotPoint[][] {\n  const runs: PlotPoint[][] = [];\n  let run: PlotPoint[] = [];\n  values.forEach((value, index) => {\n    if (value === null) {\n      if (run.length) runs.push(run);\n      run = [];\n      return;\n    }\n    run.push({\n      x: banded ? bandOf(index, values.length, plot) : xOf(index, values.length, plot),\n      y: yOf(value, plot, min, max),\n    });\n  });\n  if (run.length) runs.push(run);\n  return runs;\n}\n\n/** A column as a stroked line. `banded` puts its points at slice centres, to sit over bars. */\nexport function linePath(\n  values: readonly (number | null)[],\n  plot: PlotBox,\n  min: number,\n  max: number,\n  curve: PlotCurve = 'monotone',\n  banded = false\n): string {\n  return runsOf(values, plot, min, max, banded)\n    .map((run) => segment(run, curve))\n    .join(' ');\n}\n\n/** A column as a fill down to the bottom of the plot. */\nexport function areaPath(\n  values: readonly (number | null)[],\n  plot: PlotBox,\n  min: number,\n  max: number,\n  curve: PlotCurve = 'monotone',\n  banded = false\n): string {\n  const bottom = plot.top + plot.height;\n  return runsOf(values, plot, min, max, banded)\n    .map((run) => `${segment(run, curve)} L${run[run.length - 1]!.x},${bottom} L${run[0]!.x},${bottom} Z`)\n    .join(' ');\n}\n\n/** One bar, rounded on the end it grows towards and square on the end it grows from. */\nexport function barPath(\n  x: number,\n  y: number,\n  width: number,\n  height: number,\n  radius: number,\n  towards: 'up' | 'down'\n): string {\n  if (width <= 0 || height <= 0) return '';\n  const r = Math.max(0, Math.min(radius, width / 2, height / 2));\n  const right = x + width;\n  const bottom = y + height;\n  return towards === 'up'\n    ? `M${x},${bottom}L${x},${y + r}Q${x},${y} ${x + r},${y}L${right - r},${y}Q${right},${y} ${right},${y + r}L${right},${bottom}Z`\n    : `M${x},${y}L${x},${bottom - r}Q${x},${bottom} ${x + r},${bottom}L${right - r},${bottom}Q${right},${bottom} ${right},${bottom - r}L${right},${y}Z`;\n}\n\n/**\n * Where a bar ends once it is at least `minHeight` pixels long. A value on the\n * baseline stays there, because no bar is the honest drawing of nothing.\n */\nexport function barEnd(\n  value: number,\n  baseline: number,\n  minHeight: number,\n  plotHeight: number,\n  [min, max]: readonly [number, number]\n): number {\n  if (value === baseline || plotHeight <= 0) return value;\n  const least = (minHeight / plotHeight) * (max - min);\n  return Math.abs(value - baseline) >= least ? value : baseline + Math.sign(value - baseline) * least;\n}\n\n/** Which rows get an x label: `ticks` of them, as evenly spread as whole rows allow. */\nexport function labelledRows(total: number, ticks: number): number[] {\n  const count = Math.min(Math.max(1, Math.round(ticks)), total);\n  if (count <= 0) return [];\n  const step = count > 1 ? (total - 1) / (count - 1) : 0;\n  return Array.from({ length: count }, (_unused, index) => Math.round(index * step));\n}\n\n/** A path builder for the chart's marks, in the shape of one of the two curves. */\nexport function plotCurve(curve: PlotCurve) {\n  const join = (points: readonly (readonly [number, number])[]) => points.map(([x, y]) => ({ x, y }));\n  return {\n    line: (points: readonly (readonly [number, number])[]) => segment(join(points), curve),\n    area: (top: readonly (readonly [number, number])[], bottom: readonly (readonly [number, number])[]) => {\n      const under = segment(join([...bottom].reverse()), curve);\n      return `${segment(join(top), curve)} ${under.replace('M', 'L')} Z`;\n    },\n  };\n}\n"
    }
  ]
}
