{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "time-series",
  "type": "registry:ui",
  "title": "Time Series",
  "description": "One metric over time, with the actions that moved it drawn ON the curve.",
  "author": "ofri-peretz <https://github.com/ofri-peretz>",
  "categories": [
    "data",
    "chart"
  ],
  "dependencies": [],
  "registryDependencies": [
    "https://ds.interlace.tools/r/theme.json",
    "https://ds.interlace.tools/r/cn.json",
    "https://ds.interlace.tools/r/data-state.json",
    "https://ds.interlace.tools/r/series-table.json",
    "https://ds.interlace.tools/r/skeleton.json"
  ],
  "files": [
    {
      "path": "registry/interlace-ui/charts/time-series.tsx",
      "target": "components/ui/charts/time-series.tsx",
      "type": "registry:ui",
      "content": "'use client';\n\nimport * as React from 'react';\n\n// @interlace/time-series v1.2.0 — Interlace design system.\n// Docs, props and live preview: https://ds.interlace.tools/c/time-series\n// What changed since: https://ds.interlace.tools/c/time-series#history\n// Generated banner — keep it, the upgrade diff reads this version.\n\n/**\n * @interlace/ui — TimeSeries\n *\n * One metric over time, with the actions that moved it drawn ON the curve.\n *\n * ## The annotation is the point\n *\n * A line going up is a fact. A line going up with a publish marker at the\n * inflection is an *argument*. Everything else in this component — grid, axis,\n * crosshair — is chrome that exists so the annotation can be read against a\n * scale. If you are adding a feature here and it does not help someone connect\n * a thing they did to a number that moved, it probably belongs elsewhere.\n *\n * ## The crosshair works from the keyboard\n *\n * This is the part chart libraries almost universally get wrong: hover-only\n * inspection means the values exist for mouse users and for nobody else. Here\n * the SVG is focusable, ←/→ step the crosshair, Home/End jump to the ends, and\n * the readout below is `aria-live=\"polite\"` so each step is announced. Learned\n * in 1.2 — axe cannot press a key, so it scored the hover-only version green.\n *\n * The pointer path and the keyboard path both resolve through `nearestSlot`,\n * so the two can never disagree about which point is under the crosshair.\n *\n * ## There is no floating tooltip, and that is the design\n *\n * A tooltip that follows the pointer is a *second* inspection surface: it has\n * to be kept in step with the live readout a keyboard user gets, it has to be\n * positioned in viewport pixels while the plot is `viewBox` scaled, and at 320\n * it lands on top of the very points it describes. The readout row IS the\n * tooltip — it names the date and every series' value at the crosshair, it is\n * fed by the same slot the arrow keys move, and there is exactly one of it.\n *\n * ## Legibility at 320 — why the x labels are HTML and the y labels are not\n *\n * SVG text scales with the `viewBox`. At a 320 viewport the plot is 288px wide\n * against a 900-unit box, so `text-xs` inside the SVG paints at **4px** —\n * measured in Chrome, not reasoned about. The x labels are therefore real HTML\n * under the plot, where 12px means 12px, aligned to the plot by a percentage\n * pad that matches `PAD_LEFT`.\n *\n * The y labels stay in the SVG and do shrink with it; the y scale survives\n * because the readout row prints `min` and `max` as HTML at every width.\n * Moving them out needs a per-tick vertical offset, and percentage padding in\n * CSS resolves against *width* — that is a separate change, not a line of this\n * one.\n *\n * ## MIN_VIEWPORT — 320\n *\n * The SVG scales to its container via `viewBox` (no fixed width), the readout\n * and legend wrap, the x axis drops to three labels below `sm`, and\n * `<SeriesTable>` scrolls inside its own box. Nothing forces the page to\n * scroll horizontally.\n *\n * | Rule | Concept                    | Where in this file                                       |\n * | ---- | -------------------------- | -------------------------------------------------------- |\n * | R6   | data-slot on every part    | `\"time-series\" / \"-plot\" / \"-axis\" / \"-legend\" / \"-readout\"` |\n * | R7   | className merged + ...rest | `cn(...)` + `{...props}`                                  |\n * | R8   | No `isXxx`; enums          | `polarity`; annotation `kind`                             |\n * | R13  | Ecosystem first            | no charting dep — `plotScales` + SVG is the engine        |\n * | R14  | Declares min viewport      | `data-min-viewport={String(MIN_VIEWPORT)}`                |\n * | R18  | Tailwind only              | zero inline `style`                                       |\n * | R19  | Tokens only                | `stroke-chart-1..5`, `stroke-viz-*`, `text-muted-foreground` |\n * | R20  | AA contrast                | axis 3.64:1 light / 3.82:1 dark; grid deliberately decorative |\n * | R23  | Loading reserves its box   | `loading` → `<Skeleton variant=\"chart\">`, same height     |\n * | R23  | Absence is a vocabulary    | `loading` / `error` / not-enough-data are three messages  |\n * | R25  | Client component           | pointer + key handlers, `useState`                        |\n * | R26  | A11y                       | `role=\"img\"` + label + focusable + live readout + table   |\n */\n\nimport { cn } from '@/lib/utils';\nimport {\n  announceDataState,\n  resolveDataState,\n  type AnnouncementOptions,\n} from '@/components/ui/data-state';\nimport { Skeleton } from '@/components/ui/skeleton';\nimport { SeriesTable } from '@/components/ui/charts/series-table';\nimport {\n  areaPath,\n  axisSlots,\n  day,\n  describeSeries,\n  keepAtNarrow,\n  linePath,\n  nearestSlot,\n  plotScales,\n  ticks,\n  type Annotation,\n  type AnnotationKind,\n  type Point,\n} from '@/components/ui/charts/scale';\n\nexport const MIN_VIEWPORT = 320 as const;\n\n/** Internal drawing width in user units. The viewBox scales it to any container. */\nconst W = 900;\nconst PAD_LEFT = 44;\nconst PAD_TOP = 14;\n\n/**\n * `PAD_LEFT` as a percentage of `W` (44 / 900), so the HTML x-axis row starts\n * exactly where the plot does at every width. The SVG scales; a px pad cannot.\n */\nconst AXIS_PAD_LEFT = 'pl-[4.889%]';\n\n/**\n * How many series can be drawn at once.\n *\n * `--chart-1..5` is the whole identity palette, and there are five line styles\n * to pair with it. A sixth series would reuse both, which is two lines a reader\n * cannot tell apart — worse than a line that is not drawn. Series past the\n * fifth stay in `<SeriesTable>`, so nothing is lost, and the legend says so.\n */\nconst MAX_PLOTTED_SERIES = 5;\n\n/**\n * Series identity is a DASH PATTERN first and a hue second — the same rule the\n * annotation marks follow, for the same reason. Two lines separated only by\n * `--chart-1` vs `--chart-2` are one line in a greyscale print, in a screenshot\n * pasted into Slack, and to a red-green colour-blind reader.\n */\nconst SERIES_STYLE: readonly { stroke: string; fill: string; dash?: string }[] = [\n  { stroke: 'stroke-chart-1', fill: 'fill-chart-1' },\n  { stroke: 'stroke-chart-2', fill: 'fill-chart-2', dash: '12 6' },\n  { stroke: 'stroke-chart-3', fill: 'fill-chart-3', dash: '2 6' },\n  { stroke: 'stroke-chart-4', fill: 'fill-chart-4', dash: '18 6 2 6' },\n  { stroke: 'stroke-chart-5', fill: 'fill-chart-5', dash: '12 6 2 6 2 6' },\n];\n\n/**\n * The name a reader sees. `<SeriesTable>` already spells an unlabelled series\n * \"Value\"; the legend, the readout and the table must not each invent their own\n * word for the same column.\n */\nconst named = (label?: string): string => label ?? 'Value';\n\n/**\n * Annotation marks differ by SHAPE first and hue second. A photocopy, a\n * greyscale print, and a colour-blind reader all keep the distinction.\n */\nconst MARK: Record<AnnotationKind, { className: string; d: (x: number, y: number) => string }> = {\n  publish: {\n    className: 'fill-viz-annotation-publish',\n    // circle, drawn as a path so all three share one element type\n    d: (x, y) => `M${x - 3.5},${y}a3.5,3.5 0 1,0 7,0a3.5,3.5 0 1,0 -7,0`,\n  },\n  release: {\n    className: 'fill-viz-annotation-release',\n    d: (x, y) => `M${x},${y - 4}L${x + 4},${y}L${x},${y + 4}L${x - 4},${y}Z`, // diamond\n  },\n  action: {\n    className: 'fill-viz-annotation-action',\n    d: (x, y) => `M${x},${y - 4}L${x + 4},${y + 3.5}L${x - 4},${y + 3.5}Z`, // triangle\n  },\n};\n\n/**\n * A series plotted alongside `points`.\n *\n * `label` is required, unlike the primary series' — with one line the\n * figcaption names it, but with two the name is the only thing that says which\n * line is which in the readout and the data table.\n */\nexport interface ComparisonSeries {\n  points: readonly Point[];\n  label: string;\n  /** Noun for this series' values. Series in one chart rarely share a unit. */\n  unit?: string;\n}\n\n/** Stable identity, so the plot memo does not recompute on every render. */\nconst NO_COMPARE: readonly ComparisonSeries[] = [];\n\nexport interface TimeSeriesProps extends Omit<React.ComponentProps<'figure'>, 'children'> {\n  points: readonly Point[];\n  /**\n   * Further series drawn against the same axes.\n   *\n   * `points` stays the required single-series prop it always was — every\n   * existing call site and story is untouched — and this is purely additive.\n   * The alternative (one required `series: Series[]`) would have been a rename\n   * of the only prop this component has.\n   *\n   * All series share ONE y domain. That is the honest choice and it is not\n   * configurable: a second y axis lets an author slide two unrelated metrics\n   * until they appear to cross wherever the argument needs them to. A series\n   * two orders of magnitude smaller will render as flat, which is the true\n   * statement about it — give it its own chart, or a `MetricTable` row.\n   */\n  compare?: readonly ComparisonSeries[];\n  annotations?: readonly Annotation[];\n  /** Drawing height in user units. The rendered height follows the container width. */\n  height?: number;\n  /** Series name — used in the caption, the accessible label and the readout. */\n  label?: string;\n  /** Noun for the values, e.g. \"views\". */\n  unit?: string;\n  /** Render the data table visibly under the chart instead of `sr-only`. */\n  showTable?: boolean;\n  /**\n   * Render a `<Skeleton variant=\"chart\" />` placeholder.\n   *\n   * A chart's data is in flight on first paint essentially always, so this is\n   * the state the component spends its first frames in — and the one most often\n   * left as a spinner, which reserves no space and guarantees a layout shift the\n   * moment the series lands.\n   */\n  loading?: boolean;\n  /**\n   * The fetch failed.\n   *\n   * `unknown` rather than `boolean` for the same reason `DataStateFlags.error`\n   * is: a caught value can be handed straight through, and only its truthiness\n   * is ever read — nothing here renders it, because a stack trace is not a\n   * message for a reader.\n   *\n   * This is a different STATEMENT from an empty series, not a different\n   * severity of one. \"No data yet\" says the metric has no history; a failed\n   * request says the history is unknown, which is the one thing an empty-state\n   * message cannot be allowed to claim. Ranked directly under `loading`, per\n   * `DATA_STATES`.\n   */\n  error?: unknown;\n  /** Context for the absence sentences — noun, coverage, reason. */\n  announce?: AnnouncementOptions;\n}\n\nexport const TimeSeries = React.forwardRef<HTMLElement, TimeSeriesProps>(function TimeSeries(\n  {\n    points,\n    compare = NO_COMPARE,\n    annotations = [],\n    height = 220,\n    label,\n    unit,\n    showTable = false,\n    loading = false,\n    error,\n    announce,\n    className,\n    ...props\n  },\n  ref,\n) {\n  const [cursor, setCursor] = React.useState<number | null>(null);\n  const svgRef = React.useRef<SVGSVGElement>(null);\n\n  // The primary series is series 0. Everything below indexes off this list, so\n  // \"the first line\" and \"the first table column\" cannot come apart.\n  // The primary keeps an OPTIONAL label so `describeSeries` still falls back to\n  // \"Series\" for an unlabelled chart; `named()` supplies the visible fallback.\n  const all: readonly { points: readonly Point[]; label?: string; unit?: string }[] =\n    React.useMemo(() => [{ points, label, unit }, ...compare], [points, label, unit, compare]);\n  const drawn = React.useMemo(() => all.slice(0, MAX_PLOTTED_SERIES), [all]);\n  const undrawn = all.length - drawn.length;\n\n  const plot = React.useMemo(\n    () => plotScales(drawn.map((s) => s.points), W - PAD_LEFT, height - PAD_TOP, PAD_TOP),\n    [drawn, height],\n  );\n  // Shift the plot right of the axis labels without threading an offset through\n  // every scale call.\n  const px = React.useCallback((slot: number) => PAD_LEFT + plot.x(slot), [plot]);\n\n  const line = linePath(plot.series[0]);\n  const axisTicks = React.useMemo(\n    () => ticks({ points: plot.keys, min: plot.min, max: plot.max }, 4),\n    [plot],\n  );\n  const xSlots = React.useMemo(() => axisSlots(plot.keys.length), [plot]);\n  const slotByDay = React.useMemo(\n    () => new Map(plot.keys.map((key, slot) => [key, slot])),\n    [plot],\n  );\n\n  const last = plot.keys.length - 1;\n\n  const move = React.useCallback(\n    (next: number) => setCursor(Math.max(0, Math.min(last, next))),\n    [last],\n  );\n\n  const onKeyDown = (event: React.KeyboardEvent<SVGSVGElement>) => {\n    const current = cursor ?? 0;\n    switch (event.key) {\n      case 'ArrowRight':\n        move(current + 1);\n        break;\n      case 'ArrowLeft':\n        move(current - 1);\n        break;\n      case 'Home':\n        move(0);\n        break;\n      case 'End':\n        move(last);\n        break;\n      case 'Escape':\n        setCursor(null);\n        return; // no preventDefault — Escape may close an enclosing overlay\n      default:\n        return;\n    }\n    // Arrow keys scroll the page by default; a focused chart owns them.\n    event.preventDefault();\n  };\n\n  const onPointerMove = (event: React.PointerEvent<SVGSVGElement>) => {\n    const box = svgRef.current?.getBoundingClientRect();\n    if (!box || box.width === 0) return;\n    const userX = ((event.clientX - box.left) / box.width) * W - PAD_LEFT;\n    move(nearestSlot(plot.keys.length, userX, W - PAD_LEFT));\n  };\n\n  // Loading and error are both checked BEFORE the not-enough-data branch: data\n  // still in flight, and data that failed to arrive, are each a different claim\n  // from \"this metric has no history\" — and telling a reader the last one while\n  // either of the others is true is simply wrong. The order is `DATA_STATES`'\n  // order, resolved by the same function every other surface in the package\n  // uses, so a chart and a stat strip on one page cannot disagree about which\n  // absence wins.\n  //\n  // Every hook above this point runs unconditionally — the guards sit after them\n  // on purpose, so toggling `loading` never changes the hook order.\n  const absence = resolveDataState({ loading, error }, announce);\n\n  if (absence.state === 'loading') {\n    return (\n      <Skeleton\n        variant=\"chart\"\n        data-slot=\"time-series\"\n        data-min-viewport={String(MIN_VIEWPORT)}\n        className={className}\n      />\n    );\n  }\n\n  if (absence.state === 'error') {\n    return (\n      <figure\n        ref={ref}\n        data-slot=\"time-series-error\"\n        data-state=\"error\"\n        data-min-viewport={String(MIN_VIEWPORT)}\n        className={cn(\n          'm-0 w-full rounded-lg border border-destructive/40 p-6',\n          className,\n        )}\n        {...props}\n      >\n        {/* `role=\"alert\"` and not the muted empty-state paragraph. The reader\n            has to be able to tell \"we asked and could not find out\" from \"we\n            asked and the answer was nothing\" — those license different\n            conclusions, and only one of them is about the metric. */}\n        <p role=\"alert\" className=\"text-sm text-destructive\">\n          {announceDataState('error', announce)} The history is unknown, not\n          absent — this is not an empty series.\n        </p>\n      </figure>\n    );\n  }\n\n  // Below two points there is no line to draw. Say why, rather than rendering an\n  // empty box that reads as a bug — a series genuinely cannot be back-filled.\n  //\n  // The test is the PRIMARY series, not the union: a chart whose headline\n  // metric has one reading is not rescued by a comparison series that has\n  // fourteen, and drawing the comparison alone under the primary's caption\n  // would attribute one metric's shape to another.\n  if (!line) {\n    const own = plot.series[0].points.length;\n    return (\n      <figure\n        ref={ref}\n        data-slot=\"time-series-empty\"\n        data-min-viewport={String(MIN_VIEWPORT)}\n        className={cn('m-0 w-full rounded-lg border border-border p-6', className)}\n        {...props}\n      >\n        <p className=\"text-sm text-muted-foreground\">\n          {own === 0 ? 'No data yet.' : `Only ${own} point so far.`}{' '}\n          A trend needs at least two observations, and history cannot be back-filled.\n        </p>\n      </figure>\n    );\n  }\n\n  /**\n   * The crosshair readout, for BOTH the pointer and the keyboard.\n   *\n   * One string, built once, rendered once. The moment a hover tooltip renders\n   * its own copy of this it becomes a thing that can be right while the live\n   * region is wrong, and only the sighted mouse user would ever find out.\n   *\n   * The series name is included only when there is more than one — with a\n   * single line the figcaption already names it, and prefixing every readout\n   * with a name the reader can see two lines up is noise in a live region.\n   */\n  const readout =\n    cursor === null\n      ? ''\n      : [\n          plot.keys[cursor],\n          ...drawn.map((series, index) => {\n            const value = plot.at(index, cursor);\n            const name = drawn.length > 1 ? `${named(series.label)} ` : '';\n            return value === null\n              ? `${name}no data`\n              : `${name}${value.toLocaleString()}${series.unit ? ` ${series.unit}` : ''}`;\n          }),\n        ].join(' · ');\n\n  return (\n    <figure\n      ref={ref}\n      data-slot=\"time-series\"\n      data-min-viewport={String(MIN_VIEWPORT)}\n      data-series-count={String(drawn.length)}\n      // `w-full` is load-bearing, not cosmetic. The plot sizes itself from the\n      // container via `viewBox` + `w-full`, so a figure that collapses to\n      // zero width paints NOTHING — and a bare <figure> is a flex/grid item\n      // that does exactly that inside any centered parent. Caught in the\n      // browser; jsdom reports every box as 0×0 and cannot see it.\n      className={cn('m-0 flex w-full flex-col gap-2', className)}\n      {...props}\n    >\n      {label && (\n        <figcaption className=\"text-xs text-muted-foreground\">\n          {label}\n          <span className=\"sr-only\">, </span>\n          <span aria-hidden> · </span>\n          {plot.keys[0]} → {plot.keys[last]}\n        </figcaption>\n      )}\n\n      {/* The legend is only drawn when there is something to tell apart. With\n          one series it would restate the figcaption directly beneath it. */}\n      {drawn.length > 1 && (\n        <ul\n          data-slot=\"time-series-legend\"\n          className=\"m-0 flex list-none flex-wrap items-center gap-x-4 gap-y-1 p-0 text-xs text-muted-foreground\"\n        >\n          {drawn.map((series, index) => (\n            <li key={named(series.label)} className=\"flex items-center gap-1.5\">\n              {/* The swatch repeats the line's DASH, not only its hue, so the\n                  legend identifies the same way the plot does. A row of five\n                  identical bars in five colours identifies nothing in\n                  greyscale. */}\n              <svg\n                aria-hidden\n                width={24}\n                height={8}\n                viewBox=\"0 0 24 8\"\n                className=\"shrink-0\"\n              >\n                <line\n                  x1={0}\n                  y1={4}\n                  x2={24}\n                  y2={4}\n                  strokeWidth={2}\n                  strokeDasharray={SERIES_STYLE[index].dash}\n                  className={SERIES_STYLE[index].stroke}\n                />\n              </svg>\n              {named(series.label)}\n            </li>\n          ))}\n          {undrawn > 0 && (\n            <li>{`${undrawn} more not plotted — see the data table`}</li>\n          )}\n        </ul>\n      )}\n\n      <svg\n        ref={svgRef}\n        data-slot=\"time-series-plot\"\n        viewBox={`0 0 ${W} ${height}`}\n        className={cn(\n          'block w-full touch-pan-y rounded-md',\n          'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',\n        )}\n        role=\"img\"\n        aria-label={`${drawn\n          .map((series) => describeSeries(series.points, series.label))\n          .join(' ')} Focus this chart and use the left and right arrow keys to read individual values.`}\n        tabIndex={0}\n        onKeyDown={onKeyDown}\n        onPointerMove={onPointerMove}\n        onPointerLeave={() => setCursor(null)}\n        onBlur={() => setCursor(null)}\n      >\n        {/* Grid + axis. Decorative weight for the lines, real contrast for the\n            labels — the label is what tells you where zero is. */}\n        {axisTicks.map((value) => {\n          const y = plot.y(value);\n          return (\n            <g key={value}>\n              <line\n                x1={PAD_LEFT}\n                y1={y}\n                x2={W}\n                y2={y}\n                className=\"stroke-viz-grid\"\n                strokeWidth={1}\n                aria-hidden\n              />\n              <text\n                x={PAD_LEFT - 8}\n                y={y}\n                dominantBaseline=\"middle\"\n                textAnchor=\"end\"\n                className=\"fill-muted-foreground text-xs tabular-nums\"\n                aria-hidden\n              >\n                {Math.round(value).toLocaleString()}\n              </text>\n            </g>\n          );\n        })}\n        <line\n          x1={PAD_LEFT}\n          y1={PAD_TOP}\n          x2={PAD_LEFT}\n          y2={height}\n          className=\"stroke-viz-axis\"\n          strokeWidth={1}\n          aria-hidden\n        />\n\n        {/* The x axis: a baseline and a tick per labelled slot. The tick is what\n            makes the HTML label below it point at a position rather than float\n            in the general area — the labels themselves cannot live in here,\n            because SVG text at this viewBox paints 4px wide at a 320 viewport. */}\n        <line\n          x1={PAD_LEFT}\n          y1={height}\n          x2={W}\n          y2={height}\n          className=\"stroke-viz-axis\"\n          strokeWidth={1}\n          aria-hidden\n        />\n        {xSlots.map((slot) => (\n          <line\n            key={`tick-${plot.keys[slot]}`}\n            data-slot=\"time-series-tick\"\n            x1={px(slot)}\n            y1={height - 6}\n            x2={px(slot)}\n            y2={height}\n            className=\"stroke-viz-axis\"\n            strokeWidth={1}\n            aria-hidden\n          />\n        ))}\n\n        {/* The series paths are built in unshifted scale space, so they live in\n            one translated group. Everything outside this <g> — annotations,\n            crosshair — positions with `px()`, which adds the same offset.\n            Two ways to say \"left edge\" in one file is how coordinate bugs\n            start; this is the seam between them. */}\n        <g transform={`translate(${PAD_LEFT} 0)`}>\n          {/* The area fill is a one-series affordance. Two translucent fills\n              overlap into a third colour that belongs to neither series and\n              reads as a value. */}\n          {drawn.length === 1 && (\n            <path\n              d={areaPath(plot.series[0], height)}\n              className=\"fill-chart-1 opacity-10\"\n              aria-hidden\n            />\n          )}\n          {plot.series.map((series, index) => (\n            <path\n              key={`line-${named(drawn[index].label)}`}\n              d={linePath(series)}\n              fill=\"none\"\n              strokeWidth={2}\n              strokeLinejoin=\"round\"\n              strokeLinecap=\"round\"\n              strokeDasharray={SERIES_STYLE[index].dash}\n              className={SERIES_STYLE[index].stroke}\n            />\n          ))}\n        </g>\n\n        {annotations.map((annotation) => {\n          const index = slotByDay.get(day(annotation.t));\n          if (index == null) return null;\n          const kind: AnnotationKind = annotation.kind ?? 'action';\n          const mark = MARK[kind];\n          const x = px(index);\n          return (\n            <g key={`${annotation.t}-${annotation.label}`} data-annotation-kind={kind}>\n              <line\n                x1={x}\n                y1={PAD_TOP}\n                x2={x}\n                y2={height}\n                className=\"stroke-viz-axis opacity-50\"\n                strokeWidth={1}\n                strokeDasharray=\"3 3\"\n                aria-hidden\n              />\n              <path d={mark.d(x, PAD_TOP)} className={mark.className}>\n                {/* `<title>` is the SVG-native tooltip AND accessible name for\n                    the shape — no Popover needed for a label this short. */}\n                <title>{`${day(annotation.t)} — ${kind}: ${annotation.label}`}</title>\n              </path>\n            </g>\n          );\n        })}\n\n        {cursor !== null && (\n          <g aria-hidden>\n            <line\n              x1={px(cursor)}\n              y1={PAD_TOP}\n              x2={px(cursor)}\n              y2={height}\n              className=\"stroke-viz-crosshair opacity-50\"\n              strokeWidth={1}\n            />\n            {/* A dot per series that HAS a reading here. A series with a gap on\n                this day gets no dot rather than one parked on the line segment\n                that bridges the gap — that dot would be an invented value. */}\n            {plot.series.map((series, index) => {\n              const value = plot.at(index, cursor);\n              return value === null ? null : (\n                <circle\n                  key={`dot-${named(drawn[index].label)}`}\n                  cx={px(cursor)}\n                  cy={plot.y(value)}\n                  r={4}\n                  className={cn('stroke-background', SERIES_STYLE[index].fill)}\n                  strokeWidth={2}\n                />\n              );\n            })}\n          </g>\n        )}\n      </svg>\n\n      {/* The x scale, in HTML. Below `sm` only the ends and the midpoint are\n          rendered — `hidden` removes them from the flex row, so the survivors\n          re-spread rather than leaving gaps where the dropped labels were. */}\n      <div\n        data-slot=\"time-series-axis\"\n        aria-hidden\n        className={cn(\n          'flex justify-between text-xs text-muted-foreground tabular-nums',\n          AXIS_PAD_LEFT,\n        )}\n      >\n        {xSlots.map((slot, index) => (\n          <span\n            key={plot.keys[slot]}\n            className={keepAtNarrow(index, xSlots.length) ? undefined : 'hidden sm:inline'}\n          >\n            {/* MM-DD. The year is in the figcaption and the full ISO date is in\n                the readout and the table; repeating it here is 10 characters\n                per label, which is what makes five of them collide at 320. */}\n            {plot.keys[slot].slice(5)}\n          </span>\n        ))}\n      </div>\n\n      {/* The readout is both the tooltip and the live region. One element, so a\n          keyboard user and a mouse user are never told different things. */}\n      <div\n        data-slot=\"time-series-readout\"\n        className=\"flex flex-wrap items-baseline justify-between gap-x-4 text-xs text-muted-foreground tabular-nums\"\n      >\n        <span aria-hidden>{plot.min.toLocaleString()}</span>\n        <output aria-live=\"polite\" className=\"font-medium text-foreground\">\n          {readout}\n        </output>\n        <span aria-hidden>{plot.max.toLocaleString()}</span>\n      </div>\n\n      {/* Every series, including any past `MAX_PLOTTED_SERIES`. The cap is a\n          drawing limit, never a data limit — the table stays lossless. */}\n      <SeriesTable\n        series={all.map((series) => ({ label: named(series.label), points: series.points }))}\n        caption={\n          all.length === 1\n            ? `${label ?? 'Series'} — full data${unit ? ` (${unit})` : ''}`\n            : `${all.map((series) => named(series.label)).join(', ')} — full data`\n        }\n        hidden={!showTable}\n      />\n    </figure>\n  );\n});\n"
    },
    {
      "path": "registry/interlace-ui/charts/scale.ts",
      "target": "components/ui/charts/scale.ts",
      "type": "registry:ui",
      "content": "export interface Point {\n  /** ISO date, or any string that sorts correctly. */\n  t: string;\n  v: number | null;\n}\n\n/** A mark drawn ON the series — a publish, a release, a manual action. */\nexport interface Annotation {\n  t: string;\n  label: string;\n  kind?: AnnotationKind;\n}\n\nexport const ANNOTATION_KINDS = ['publish', 'release', 'action'] as const;\nexport type AnnotationKind = (typeof ANNOTATION_KINDS)[number];\n\n/** Direction of travel. `flat` exists so callers never have to treat 0 as \"up\". */\nexport type Direction = 'up' | 'down' | 'flat';\n\n/** A point that survived `numeric()` — `v` is narrowed to a number. */\nexport type NumericPoint = { t: string; v: number };\n\n/**\n * Drop the gaps.\n *\n * A `null` is not plottable and must not become a 0 — a day we did not measure\n * is not a day the metric was zero, and averaging over it silently invents\n * data. Callers that want interpolation must ask for it explicitly.\n */\nexport const numeric = (points: readonly Point[]): NumericPoint[] =>\n  points.filter((p): p is NumericPoint => typeof p.v === 'number' && Number.isFinite(p.v));\n\n/** The x/y projectors plus the observed domain, for one series in one box. */\nexport interface Scales {\n  points: NumericPoint[];\n  x: (index: number) => number;\n  y: (value: number) => number;\n  min: number;\n  max: number;\n}\n\n/**\n * Project a series into an SVG box.\n *\n * Two edge cases are handled here rather than in every component:\n *\n *  - **A single point** has no horizontal extent, so it is centred instead of\n *    being pinned to x=0 where it reads as the start of a line that never drew.\n *  - **A flat series** has a zero span. Dividing by it yields NaN, and clamping\n *    the span to 1 would pin the line to the top edge — which looks like a\n *    metric at its maximum rather than one that never moved. It is centred.\n */\nexport function seriesScales(\n  points: readonly Point[],\n  width: number,\n  height: number,\n  pad = 4,\n): Scales {\n  const pts = numeric(points);\n  const values = pts.map((p) => p.v);\n  const min = values.length ? Math.min(...values) : 0;\n  const max = values.length ? Math.max(...values) : 0;\n  const span = max - min;\n  const last = pts.length - 1;\n\n  const x = (index: number): number => (last > 0 ? (index / last) * width : width / 2);\n\n  const y = (value: number): number =>\n    span === 0 ? height / 2 : height - pad - ((value - min) / span) * (height - pad * 2);\n\n  return { points: pts, x, y, min, max };\n}\n\n/**\n * Several series projected into ONE box: one x axis, one y domain.\n *\n * ## Why the axis is the union of days and not \"series 0 wins\"\n *\n * Letting the first series own the axis is a line of code cheaper and drops\n * every reading the others took on a day the first one missed — silently, and\n * only in the picture, so the `<SeriesTable>` beside it would still list them.\n * A chart that disagrees with its own table is worse than no chart. The x axis\n * is therefore the sorted union of `day(t)` across every series, exactly the\n * key set `<SeriesTable>` builds, and a series simply has no vertex at a slot\n * it did not measure.\n *\n * ## Why there is one y domain and never two\n *\n * A second y axis lets an author slide two unrelated series until they appear\n * to cross where the argument needs them to. The domain here is the union of\n * every value, so a series that is genuinely two orders of magnitude smaller\n * *renders* as flat — which is the true statement about it. Plot it as its own\n * chart, or as a `MetricTable` row.\n */\nexport interface PlotScales {\n  /** Sorted union of `day(t)` across every series. Slot i is `keys[i]`. */\n  keys: string[];\n  /** Slot index → user-unit x. */\n  x: (slot: number) => number;\n  /** Value → user-unit y. Shared, so two lines are on one scale. */\n  y: (value: number) => number;\n  min: number;\n  max: number;\n  /** One projector per input series, in input order, sharing the axis above. */\n  series: Scales[];\n  /** A series' value at a slot. `null` = that series has no reading that day. */\n  at: (seriesIndex: number, slot: number) => number | null;\n}\n\n/**\n * Project several series onto one shared axis.\n *\n * Two readings on the same day collapse to the last one, which is the rule\n * `<SeriesTable>` already applies — the alternative is a chart and a table that\n * report a different number for the same date.\n */\nexport function plotScales(\n  series: readonly (readonly Point[])[],\n  width: number,\n  height: number,\n  pad = 4,\n): PlotScales {\n  const byKey = series.map((points) => new Map(numeric(points).map((p) => [day(p.t), p.v])));\n  const keys = [...new Set(byKey.flatMap((m) => [...m.keys()]))].sort();\n  const slotOf = new Map(keys.map((key, slot) => [key, slot]));\n\n  const values = byKey.flatMap((m) => [...m.values()]);\n  const min = values.length ? Math.min(...values) : 0;\n  const max = values.length ? Math.max(...values) : 0;\n  const span = max - min;\n  const lastSlot = keys.length - 1;\n\n  const x = (slot: number): number => (lastSlot > 0 ? (slot / lastSlot) * width : width / 2);\n  const y = (value: number): number =>\n    span === 0 ? height / 2 : height - pad - ((value - min) / span) * (height - pad * 2);\n\n  return {\n    keys,\n    x,\n    y,\n    min,\n    max,\n    // Each entry is an ordinary `Scales`, so `linePath` / `areaPath` need no\n    // multi-series variant: only the meaning of the index changes, and it\n    // stays private to this closure.\n    series: byKey.map((m) => {\n      const points: NumericPoint[] = keys\n        .filter((key) => m.has(key))\n        .map((key) => ({ t: key, v: m.get(key)! }));\n      return { points, x: (index) => x(slotOf.get(points[index].t)!), y, min, max };\n    }),\n    at: (seriesIndex, slot) => byKey[seriesIndex].get(keys[slot]) ?? null,\n  };\n}\n\n/**\n * Which slots get a labelled tick.\n *\n * Evenly spaced and capped, because the x labels are HTML at a fixed 12px while\n * the plot they sit under is `viewBox`-scaled — at 320 the plot is 288px wide\n * and a label per observation would overlap long before the reader ran out of\n * dates. Returns fewer than `max` when the series is shorter, and never repeats\n * a slot.\n */\nexport function axisSlots(count: number, max = 5): number[] {\n  if (count <= 0) return [];\n  if (max < 2 || count === 1) return [0];\n  if (count <= max) return Array.from({ length: count }, (_, i) => i);\n  const last = count - 1;\n  return [...new Set(Array.from({ length: max }, (_, i) => Math.round((i / (max - 1)) * last)))];\n}\n\n/**\n * Which of a set of evenly spaced labels survive below `sm`: the two ends, plus\n * the midpoint when the count is odd.\n *\n * Five five-character labels clear a 288px plot by ~12px and a longer format\n * would not — so the narrow case drops to three rather than depending on the\n * labels staying short. The ENDS are never dropped, because the ends are the\n * range; a chart whose axis has lost its last label has lost its scale.\n *\n * Lives here rather than in a component because two charts now thin the same\n * axis, and two copies of this predicate is how one of them ends up dropping an\n * end label at a width the other survives.\n */\nexport const keepAtNarrow = (index: number, count: number): boolean =>\n  index === 0 || index === count - 1 || index === (count - 1) / 2;\n\n/** An SVG path `d` for the series polyline. Empty string for <2 points. */\nexport const linePath = (scales: Scales): string =>\n  scales.points.length < 2\n    ? ''\n    : scales.points.map((p, i) => `${i ? 'L' : 'M'}${scales.x(i)},${scales.y(p.v)}`).join('');\n\n/** The polyline closed down to the baseline, for an area fill. */\nexport const areaPath = (scales: Scales, height: number): string => {\n  const line = linePath(scales);\n  if (!line) return '';\n  return `${line}L${scales.x(scales.points.length - 1)},${height}L${scales.x(0)},${height}Z`;\n};\n\n// ── Distributions: a quantity spread over bins, not over time ───────────────\n\n/**\n * One slot of a distribution.\n *\n * `label` is the identity as well as the name — a distribution's axis is a list\n * of NAMES (hours of the day, weekdays, cohorts), not a list of instants, so\n * there is no `t` to key it by and nothing sensible to sort it into. The order\n * the caller passes IS the axis.\n *\n * `v: null` carries the same meaning it does on a `Point`: this bin was not\n * measured. It is emphatically not a zero, and here that distinction is sharper\n * than anywhere else in the package — a bar of height zero and a bar that was\n * never drawn are the same picture, so the component has to draw something\n * else entirely.\n */\nexport interface Bin {\n  label: string;\n  v: number | null;\n}\n\n/** A band (categorical) x scale plus a zero-anchored y scale. */\nexport interface BandScales {\n  /** Width of one bin's slot in user units. */\n  band: number;\n  /** Bin index → the LEFT edge of its slot. */\n  x: (index: number) => number;\n  /** Bin index → the CENTRE of its slot, where a per-bin mark belongs. */\n  centre: (index: number) => number;\n  /** Value → user-unit y. */\n  y: (value: number) => number;\n  /** The y of zero — where every bar starts and ends. */\n  zero: number;\n  min: number;\n  max: number;\n}\n\n/**\n * Project one or more bin series into an SVG box, sharing ONE domain.\n *\n * ## Why this is not `seriesScales` with a different x\n *\n * The y domain. `seriesScales` fits the OBSERVED band, deliberately: a metric\n * that ran 3,412 → 3,588 gets ticks inside that band, because the movement is\n * what the reader came for and rounding out to 0–4,000 would flatten it.\n *\n * A bar cannot do that. A bar encodes its value as a LENGTH from a baseline, so\n * the reader reads the ratio between two bars — and on an axis that starts at\n * 3,412 a bar twice as long is a value 2.5% larger. Truncating a bar axis is\n * the oldest chart lie there is. So the domain here always contains zero, and\n * `zero` is published so the component draws from the baseline rather than\n * from the bottom of the box.\n *\n * A negative value therefore widens the domain downward rather than being\n * clamped — a clamp would render −40 and −4,000 as the same empty slot.\n *\n * Several series go in as several arrays for the same reason `plotScales` takes\n * several: they share the domain, so the second one cannot be quietly rescaled\n * until it crosses the first wherever the argument needs it to. The band count\n * comes from the LONGEST series, so a reference that stops short leaves its\n * remaining bins empty rather than stretching the axis.\n */\nexport function bandScales(\n  series: readonly (readonly (number | null)[])[],\n  width: number,\n  height: number,\n  pad = 4,\n): BandScales {\n  const measured = series\n    .flat()\n    .filter((v): v is number => typeof v === 'number' && Number.isFinite(v));\n  const min = Math.min(0, ...measured);\n  const max = Math.max(0, ...measured);\n  const span = max - min;\n  const count = Math.max(0, ...series.map((one) => one.length));\n  // An empty distribution still has to hand back a usable band rather than\n  // divide by zero — the component draws nothing, but it does so at full width.\n  const band = count > 0 ? width / count : width;\n\n  // Everything measured is zero: every bar is a zero-length bar sitting on the\n  // baseline, which is the honest picture. Centring it (the `seriesScales` rule\n  // for a flat line) would float the baseline in mid-air.\n  const y = (value: number): number =>\n    span === 0\n      ? height - pad\n      : height - pad - ((value - min) / span) * (height - pad * 2);\n\n  return {\n    band,\n    x: (index) => index * band,\n    centre: (index) => index * band + band / 2,\n    y,\n    zero: y(0),\n    min,\n    max,\n  };\n}\n\n/**\n * Index of the largest MEASURED bin, or `null` when nothing was measured.\n *\n * \"Where is the peak\" is the first question anyone asks a distribution, and\n * every hand-rolled version of this chart computed it with\n * `Math.max(1, ...values)` — which invents a denominator of 1 out of an empty\n * series and reports bin 0 as the peak of a distribution that has no peak.\n * Ties go to the earliest bin, so the answer is stable across re-renders.\n */\nexport function peakBin(values: readonly (number | null)[]): number | null {\n  let best = -1;\n  for (let index = 0; index < values.length; index += 1) {\n    const value = values[index];\n    if (typeof value !== 'number' || !Number.isFinite(value)) continue;\n    if (best === -1 || value > (values[best] as number)) best = index;\n  }\n  return best === -1 ? null : best;\n}\n\n/**\n * A STEP path across bins — flat over each band, never sloped between them.\n *\n * A polyline through bin centres draws a diagonal between two bins and that\n * diagonal is a claim: that the quantity passed through every value in between,\n * somewhere in between. For a per-bin aggregate (\"readers awake at 14:00\")\n * nothing exists between the bins to pass through. The step says the same\n * numbers without the invented interpolation.\n *\n * An unmeasured bin BREAKS the path rather than bridging it, for the reason\n * `numeric()` drops nulls: a bridge over a gap is a drawn value nobody measured.\n */\nexport function stepPath(\n  values: readonly (number | null)[],\n  scales: BandScales,\n): string {\n  let d = '';\n  let open = false;\n  for (let index = 0; index < values.length; index += 1) {\n    const value = values[index];\n    if (typeof value !== 'number' || !Number.isFinite(value)) {\n      open = false;\n      continue;\n    }\n    const y = scales.y(value);\n    const left = scales.x(index);\n    d += `${open ? 'L' : 'M'}${left},${y}L${left + scales.band},${y}`;\n    open = true;\n  }\n  return d;\n}\n\n/**\n * The accessible name for a distribution.\n *\n * `describeSeries` answers \"where did it go\"; a distribution has nowhere to go,\n * so the sentence answers the questions it can actually be asked: how much in\n * total, where the peak is, and how much of the axis was never measured. That\n * last clause is not decoration — a distribution with six unmeasured bins looks\n * exactly like one with six empty bins, and only the sentence can tell them\n * apart for a reader who is not looking at it.\n */\nexport function describeDistribution(\n  bins: readonly Bin[],\n  label?: string,\n  unit?: string,\n): string {\n  const name = label ?? 'Distribution';\n  const measured = bins.filter(\n    (bin): bin is Bin & { v: number } =>\n      typeof bin.v === 'number' && Number.isFinite(bin.v),\n  );\n  if (measured.length === 0) return `${name}: no data`;\n\n  const peak = peakBin(bins.map((bin) => bin.v))!;\n  const total = measured.reduce((sum, bin) => sum + bin.v, 0);\n  const noun = unit ? ` ${unit}` : '';\n  const gaps = bins.length - measured.length;\n\n  return (\n    `${name}: ${bins.length} bins, ${total.toLocaleString()}${noun} in total, ` +\n    `highest in ${bins[peak].label} at ${(bins[peak].v as number).toLocaleString()}${noun}` +\n    `${gaps === 0 ? '' : `, ${gaps} bin${gaps === 1 ? '' : 's'} not measured`}.`\n  );\n}\n\n/** First → last change, in absolute and percentage terms. */\nexport interface DeltaResult {\n  from: number;\n  to: number;\n  abs: number;\n  /** `null` when the baseline is 0 — a percentage change from nothing is undefined, not infinite. */\n  pct: number | null;\n  direction: Direction;\n}\n\nexport function delta(points: readonly Point[]): DeltaResult | null {\n  const pts = numeric(points);\n  if (pts.length < 2) return null;\n  const from = pts[0].v;\n  const to = pts[pts.length - 1].v;\n  const abs = to - from;\n  return {\n    from,\n    to,\n    abs,\n    pct: from === 0 ? null : (abs / Math.abs(from)) * 100,\n    direction: abs > 0 ? 'up' : abs < 0 ? 'down' : 'flat',\n  };\n}\n\n/** ISO timestamp → `YYYY-MM-DD`. Charts key annotations by day, not by instant. */\nexport const day = (t: string): string => t.slice(0, 10);\n\n/**\n * The accessible name for a series.\n *\n * Axe cannot read an SVG, and a screen reader handed `role=\"img\"` with no label\n * announces \"image\". This is the sentence that replaces the picture — every\n * chart in this package owes one, alongside its `<SeriesTable>`.\n */\nexport function describeSeries(points: readonly Point[], label?: string): string {\n  const pts = numeric(points);\n  const name = label ?? 'Series';\n  if (pts.length === 0) return `${name}: no data`;\n  if (pts.length === 1) return `${name}: a single value, ${pts[0].v}, on ${day(pts[0].t)}`;\n  const d = delta(pts)!;\n  const move =\n    d.direction === 'flat'\n      ? 'unchanged'\n      : `${d.direction} ${Math.abs(d.abs).toLocaleString()}${\n          d.pct === null ? '' : ` (${Math.abs(d.pct).toFixed(1)}%)`\n        }`;\n  return (\n    `${name}: ${pts.length} points from ${day(pts[0].t)} to ${day(pts[pts.length - 1].t)}, ` +\n    `${d.from.toLocaleString()} to ${d.to.toLocaleString()}, ${move}. ` +\n    `Range ${Math.min(...pts.map((p) => p.v)).toLocaleString()} to ` +\n    `${Math.max(...pts.map((p) => p.v)).toLocaleString()}.`\n  );\n}\n\n/**\n * Anything carrying an observed domain plus something to count.\n *\n * `Scales` satisfies it directly; a multi-series plot passes its slot keys.\n * Widening the parameter rather than adding a `multiTicks` is deliberate — two\n * tick functions is how one chart ends up with two disagreeing y axes.\n */\nexport interface TickSource {\n  readonly points: readonly unknown[];\n  readonly min: number;\n  readonly max: number;\n}\n\n/**\n * Evenly spaced axis values across the observed domain.\n *\n * Deliberately NOT \"nice\" rounded ticks. A metric that ran 3,412 → 3,588 gets\n * ticks inside that band; rounding out to 0–4,000 would flatten the only thing\n * the reader came for. The axis labels the data, not a textbook scale.\n */\nexport function ticks(scales: TickSource, count = 3): number[] {\n  if (count < 2 || scales.points.length === 0) return [];\n  if (scales.min === scales.max) return [scales.min];\n  const step = (scales.max - scales.min) / (count - 1);\n  return Array.from({ length: count }, (_, i) => scales.min + step * i);\n}\n\n/**\n * Slot nearest an x position, in SVG user units.\n *\n * The arithmetic behind every crosshair in this package. It takes a count\n * rather than a series because with two series plotted there is no single\n * series whose indices *are* the axis — the axis is the shared slot list. The\n * pointer path and the arrow-key path both land here, which is the property\n * that stops a mouse user and a keyboard user being told different things.\n */\nexport function nearestSlot(count: number, xPosition: number, width: number): number {\n  const last = count - 1;\n  if (last <= 0) return 0;\n  const ratio = width === 0 ? 0 : xPosition / width;\n  return Math.max(0, Math.min(last, Math.round(ratio * last)));\n}\n\n/**\n * The band an x position falls INSIDE, in SVG user units.\n *\n * `nearestSlot` rounds to the closest vertex, which is right for a line: the\n * value lives AT the vertex and the space between two of them belongs to\n * whichever is nearer. A bar owns its whole band, so the right answer is\n * containment, not proximity — rounding would hand the right-hand third of\n * every bar to its neighbour, and the reader would watch the highlight jump to\n * a bin their pointer is visibly not over.\n */\nexport function slotAt(count: number, xPosition: number, width: number): number {\n  const last = count - 1;\n  if (last <= 0) return 0;\n  const ratio = width === 0 ? 0 : xPosition / width;\n  return Math.max(0, Math.min(last, Math.floor(ratio * count)));\n}\n\n/**\n * Index of the point nearest an x position, for a single series.\n *\n * The one-series spelling of `nearestSlot`, kept because it is the published\n * shape of this module and delegating is what guarantees the two cannot drift\n * into rounding a boundary differently.\n */\nexport function nearestIndex(scales: Scales, xPosition: number, width: number): number {\n  return nearestSlot(scales.points.length, xPosition, width);\n}\n\n/** Compact number formatting for dense rows — 12.4k, 3.1M. */\nexport function compact(value: number): string {\n  const abs = Math.abs(value);\n  if (abs >= 1e9) return `${(value / 1e9).toFixed(1)}B`;\n  if (abs >= 1e6) return `${(value / 1e6).toFixed(1)}M`;\n  if (abs >= 1e3) return `${(value / 1e3).toFixed(1)}k`;\n  return value.toLocaleString();\n}\n\n/**\n * @interlace/ui — chart scales and series math\n *\n * Every number a chart draws comes from here. Nothing in this file imports\n * React, touches the DOM, or renders anything — which is the point: it is the\n * part of a visualisation that can be *proved* correct, so it carries the\n * 100/100/100/100 coverage gate while the SVG above it is checked by stories\n * and axe.\n *\n * There is no charting dependency, and that is a decision rather than an\n * omission. A shadcn-registry item must install from a bare `npx shadcn add`\n * with every import resolvable, and d3/recharts/visx each want to own layout.\n * The named exit: if one surface ever needs >5k points with a live crosshair,\n * *that component* goes to canvas — it does not drag a library into the other\n * twenty. See VISUALIZATION_PHILOSOPHY.md.\n */\n\n/** One observation. `v: null` is a real gap in the data, not a zero. */\n"
    }
  ],
  "meta": {
    "tier": "chart",
    "client": true,
    "minViewport": 320,
    "loading": true,
    "version": "1.2.0",
    "since": "1.0.0"
  },
  "docs": "## @interlace/time-series\n\nInstalled to `components/ui/charts/time-series.tsx`.\n\n```tsx\nimport { /* … */ } from '@/components/ui/charts/time-series';\n```\n\nProps, a11y contract, live preview and source: https://ds.interlace.tools/c/time-series\n\nRequires the `@interlace/theme` CSS baseline (installed automatically as a registry dependency)."
}
