{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "metric-table",
  "type": "registry:ui",
  "title": "Metric Table",
  "description": "The roic.ai row: metric name, values across time, sparkline, delta. Click a row to promote it into whatever chart the caller renders above.",
  "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/delta.json",
    "https://ds.interlace.tools/r/skeleton.json",
    "https://ds.interlace.tools/r/sparkline.json"
  ],
  "files": [
    {
      "path": "registry/interlace-ui/charts/metric-table.tsx",
      "target": "components/ui/charts/metric-table.tsx",
      "type": "registry:ui",
      "content": "'use client';\n\nimport * as React from 'react';\n\n// @interlace/metric-table v1.2.0 — Interlace design system.\n// Docs, props and live preview: https://ds.interlace.tools/c/metric-table\n// What changed since: https://ds.interlace.tools/c/metric-table#history\n// Generated banner — keep it, the upgrade diff reads this version.\n\n/**\n * @interlace/ui — MetricTable\n *\n * The roic.ai row: metric name, values across time, sparkline, delta. Click a\n * row to promote it into whatever chart the caller renders above.\n *\n * ## Why this is the centrepiece and not the chart\n *\n * roic.ai did not build 186 visualisations. They built ONE row and repeated it.\n * Density is the product — the eye scans a column of rows and reads a decade,\n * which no amount of chart-per-metric ever achieves. Anything that looks like a\n * new chart type should first be attempted as a new ROW.\n *\n * Selection is owned by the caller (`selected` / `onSelect`), so the same table\n * can drive one plot, several, or none. It is deliberately not internal state:\n * the selected metric belongs in the URL (see `URL_PHILOSOPHY` /\n * `DEEP_LINKING_PHILOSOPHY`) so a view can be linked to a colleague.\n *\n * ## It is a real table\n *\n * Not a grid of divs. `<th scope=\"row\">` per metric, `<th scope=\"col\">` per\n * date, so a screen reader announces \"Views, 2026-08-01, 1,240\" instead of a\n * bare number. Rows are `<button>`-behaviour without being buttons: the row is\n * `tabIndex=0` with `role=\"row\"` semantics preserved and Enter/Space selecting,\n * because wrapping every cell in a button destroys the table semantics that\n * make the data readable in the first place.\n *\n * ## MIN_VIEWPORT — 320\n *\n * Scrolls horizontally inside its own container — the page never does. The date\n * columns are the thing that overflows, and they are the thing you scroll.\n *\n * | Rule | Concept                    | Where in this file                                     |\n * | ---- | -------------------------- | ------------------------------------------------------ |\n * | R6   | data-slot on every part    | `data-slot=\"metric-table\" / \"-row\"`                    |\n * | R7   | className merged + ...rest | `cn(...)` + `{...props}`                               |\n * | R8   | No `isXxx`                 | `selected`, `polarity`                                 |\n * | R11  | One variable per part      | the row owns selection; the table owns the columns     |\n * | R14  | Declares min viewport      | `data-min-viewport={String(MIN_VIEWPORT)}`             |\n * | R18  | Tailwind only              | zero inline `style`                                    |\n * | R19  | Tokens only                | `border-border`, `bg-accent`, `text-muted-foreground`  |\n * | R20  | AA contrast                | selected row uses `bg-accent`/`text-accent-foreground` (9.31:1 light, 9.85:1 dark) |\n * | R23  | Absence is a vocabulary    | `loading` / `error` resolve through `resolveDataState` |\n * | R25  | Client component           | row key/click handlers                                 |\n * | R26  | A11y                       | native table semantics + `aria-selected` + focus ring  |\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 { Delta, type Polarity } from '@/components/ui/charts/delta';\nimport { Sparkline } from '@/components/ui/charts/sparkline';\nimport { compact, day, type Point } from '@/components/ui/charts/scale';\n\nexport const MIN_VIEWPORT = 320 as const;\n\nexport interface MetricRow {\n  key: string;\n  label: string;\n  points: readonly Point[];\n  /** `inverse` for metrics where down is good — latency, cost, bounce rate. */\n  polarity?: Polarity;\n  unit?: string;\n}\n\nexport interface MetricTableProps extends Omit<React.ComponentProps<'div'>, 'onSelect'> {\n  rows: readonly MetricRow[];\n  caption: string;\n  selected?: string | null;\n  onSelect?: (key: string) => void;\n  /**\n   * How many date columns to show. The rest of the history stays in the\n   * sparkline and in the `sr-only` data table, so nothing is lost.\n   *\n   * Defaults to 6, not 8. With 8 the date columns consumed the whole width at\n   * a typical content measure and pushed **trend and change off the right\n   * edge** — the two columns the reader actually came for, scrollable but\n   * invisible. Individual dates are the least valuable thing in the row; they\n   * are what should give up space first.\n   */\n  maxColumns?: number;\n  /** Render a `<Skeleton variant=\"metric-table\" />` placeholder. */\n  loading?: boolean;\n  /**\n   * The fetch failed.\n   *\n   * A table of metrics that failed to load must not render as a table with no\n   * rows: an empty `<tbody>` under a real `<caption>` reads as \"we looked, and\n   * you track nothing\" — a claim about the reader rather than about the\n   * request. Ranked directly under `loading`, per `DATA_STATES`.\n   */\n  error?: unknown;\n  /** Context for the absence sentences — noun, coverage, reason. */\n  announce?: AnnouncementOptions;\n}\n\nexport const MetricTable = React.forwardRef<HTMLDivElement, MetricTableProps>(\n  function MetricTable(\n    {\n      rows,\n      caption,\n      selected = null,\n      onSelect,\n      maxColumns = 6,\n      loading = false,\n      error,\n      announce,\n      className,\n      ...props\n    },\n    ref,\n  ) {\n    // The most recent N days present in ANY row, so a metric that started late\n    // still aligns with one that has full history.\n    const columns = React.useMemo(() => {\n      const all = new Set<string>();\n      for (const row of rows) for (const point of row.points) all.add(day(point.t));\n      return [...all].sort().slice(-maxColumns);\n    }, [rows, maxColumns]);\n\n    const selectable = Boolean(onSelect);\n\n    // After the memo above, so hook order never depends on the prop.\n    const absence = resolveDataState({ loading, error }, announce);\n\n    if (absence.state === 'loading') {\n      return (\n        <Skeleton\n          variant=\"metric-table\"\n          data-slot=\"metric-table\"\n          data-min-viewport={String(MIN_VIEWPORT)}\n          className={className}\n        />\n      );\n    }\n\n    if (absence.state === 'error') {\n      return (\n        <div\n          ref={ref}\n          data-slot=\"metric-table-error\"\n          data-state=\"error\"\n          data-min-viewport={String(MIN_VIEWPORT)}\n          className={cn(\n            'w-full rounded-lg border border-destructive/40 p-6',\n            className,\n          )}\n          {...props}\n        >\n          <p role=\"alert\" className=\"text-sm text-destructive\">\n            {announceDataState('error', announce)} The rows are unknown, not\n            absent — this is not a table with nothing in it.\n          </p>\n        </div>\n      );\n    }\n\n    return (\n      <div\n        ref={ref}\n        data-slot=\"metric-table\"\n        data-min-viewport={String(MIN_VIEWPORT)}\n        className={cn('w-full overflow-x-auto', className)}\n        {...props}\n      >\n        <table className=\"w-full border-collapse text-sm\">\n          <caption className=\"mb-2 text-left text-xs text-muted-foreground\">\n            {caption}\n            {selectable && (\n              <span className=\"sr-only\">\n                . Each row can be selected to plot it; press Enter or Space on a focused row.\n              </span>\n            )}\n          </caption>\n          <thead>\n            <tr>\n              <th\n                scope=\"col\"\n                className=\"border-b border-border px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground\"\n              >\n                Metric\n              </th>\n              {/* Trend and Change sit BEFORE the dates, immediately after the\n                  row's identity. A dense table overflows and scrolls — that is\n                  inherent, not a bug — so the only real decision is which\n                  columns are allowed to scroll away. Trailing them after the\n                  dates meant the two columns the reader came for were the first\n                  off-screen, showing a bare direction glyph and no numbers.\n                  Individual dates are the least valuable cells in the row. */}\n              <th scope=\"col\" className=\"border-b border-border px-3 py-2\">\n                <span className=\"sr-only\">Trend</span>\n              </th>\n              <th\n                scope=\"col\"\n                className=\"whitespace-nowrap border-b border-border px-3 py-2 text-right text-xs font-medium text-muted-foreground\"\n              >\n                Change\n              </th>\n              {columns.map((column) => (\n                <th\n                  key={column}\n                  scope=\"col\"\n                  className=\"whitespace-nowrap border-b border-border px-3 py-2 text-right text-xs font-medium text-muted-foreground tabular-nums\"\n                >\n                  {column.slice(5)}\n                </th>\n              ))}\n            </tr>\n          </thead>\n          <tbody>\n            {rows.map((row) => {\n              const values = new Map(row.points.map((point) => [day(point.t), point.v]));\n              const isSelected = selected === row.key;\n              return (\n                <tr\n                  key={row.key}\n                  data-slot=\"metric-table-row\"\n                  data-selected={isSelected || undefined}\n                  aria-selected={selectable ? isSelected : undefined}\n                  tabIndex={selectable ? 0 : undefined}\n                  onClick={selectable ? () => onSelect!(row.key) : undefined}\n                  onKeyDown={\n                    selectable\n                      ? (event) => {\n                          if (event.key !== 'Enter' && event.key !== ' ') return;\n                          // Space scrolls the page by default; a focused row owns it.\n                          event.preventDefault();\n                          onSelect!(row.key);\n                        }\n                      : undefined\n                  }\n                  className={cn(\n                    'border-t border-border',\n                    selectable &&\n                      'cursor-pointer focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring hover:bg-muted',\n                    isSelected && 'bg-accent text-accent-foreground',\n                  )}\n                >\n                  <th\n                    scope=\"row\"\n                    className=\"whitespace-nowrap px-3 py-1.5 text-left font-normal\"\n                  >\n                    {row.label}\n                  </th>\n                  <td className=\"px-3 py-1\">\n                    {/* Decorative: the row already announces every value and the\n                        change cell announces the direction. */}\n                    <Sparkline points={row.points} polarity={row.polarity} decorative />\n                  </td>\n                  {/* `whitespace-nowrap`: auto table layout hands the date\n                      columns the space first and squeezes this one to a zero\n                      content box, which clips the digits and leaves only the\n                      direction glyph visible. The numbers ARE the column. */}\n                  <td className=\"whitespace-nowrap px-3 py-1.5 text-right\">\n                    <Delta points={row.points} polarity={row.polarity} unit={row.unit} />\n                  </td>\n                  {columns.map((column) => {\n                    const value = values.get(column);\n                    return (\n                      <td\n                        key={column}\n                        className={cn(\n                          'px-3 py-1.5 text-right tabular-nums',\n                          value == null && 'text-muted-foreground',\n                        )}\n                      >\n                        {value == null ? (\n                          <>\n                            <span aria-hidden>—</span>\n                            <span className=\"sr-only\">No data</span>\n                          </>\n                        ) : (\n                          compact(value)\n                        )}\n                      </td>\n                    );\n                  })}\n                </tr>\n              );\n            })}\n          </tbody>\n        </table>\n      </div>\n    );\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/metric-table\n\nInstalled to `components/ui/charts/metric-table.tsx`.\n\n```tsx\nimport { /* … */ } from '@/components/ui/charts/metric-table';\n```\n\nProps, a11y contract, live preview and source: https://ds.interlace.tools/c/metric-table\n\nRequires the `@interlace/theme` CSS baseline (installed automatically as a registry dependency)."
}
