{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sparkline",
  "type": "registry:ui",
  "title": "Sparkline",
  "description": "A trend in the width of a table cell. The densest thing in the package: it exists so a `MetricTable` row can show *shape* without spending a chart's worth of pixels, which is the whole roic.ai lesson — one row that reads a decade beats one chart that reads…",
  "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"
  ],
  "files": [
    {
      "path": "registry/interlace-ui/charts/sparkline.tsx",
      "target": "components/ui/charts/sparkline.tsx",
      "type": "registry:ui",
      "content": "import * as React from 'react';\n\n// @interlace/sparkline v1.2.0 — Interlace design system.\n// Docs, props and live preview: https://ds.interlace.tools/c/sparkline\n// What changed since: https://ds.interlace.tools/c/sparkline#history\n// Generated banner — keep it, the upgrade diff reads this version.\n\n/**\n * @interlace/ui — Sparkline\n *\n * A trend in the width of a table cell. The densest thing in the package: it\n * exists so a `MetricTable` row can show *shape* without spending a chart's\n * worth of pixels, which is the whole roic.ai lesson — one row that reads a\n * decade beats one chart that reads a quarter.\n *\n * ## Colour is never the only signal\n *\n * Rising draws in `--viz-positive`, falling in `--viz-negative`. Roughly 8% of\n * men cannot tell those apart, so the direction is ALSO in the accessible name\n * (\"up 12.4%\") and in the end-cap position. If you find yourself removing the\n * label because the colour \"already says it\", it does not.\n *\n * ## MIN_VIEWPORT — 320\n *\n * Fixed intrinsic size (default 90×22) and `aria-hidden` when decorative, so it\n * never forces a container wider than its parent column.\n *\n * | Rule | Concept                    | Where in this file                                       |\n * | ---- | -------------------------- | -------------------------------------------------------- |\n * | R4   | Extends the element props  | `ComponentProps<'svg'>` minus what we own                |\n * | R6   | data-slot                  | `data-slot=\"sparkline\"`                                  |\n * | R7   | className merged + ...rest | `cn(...)` + `{...props}`                                 |\n * | R8   | No `isXxx`                 | `decorative`, not `isDecorative`                         |\n * | R13  | Ecosystem first            | no charting dep — SVG + `seriesScales` is the engine     |\n * | R14  | Declares min viewport      | `data-min-viewport={String(MIN_VIEWPORT)}`               |\n * | R18  | Tailwind only              | zero inline `style`; sizing via width/height attributes  |\n * | R19  | Tokens only                | `stroke-viz-positive` / `stroke-viz-negative`            |\n * | R20  | AA contrast                | both tokens alias `--success` / `--destructive` (≥7:1)   |\n * | R23  | Absence is a vocabulary    | `loading` / `error` / no-trend are three different cells |\n * | R26  | A11y                       | `role=\"img\"` + computed label, or `aria-hidden` when decorative |\n */\n\nimport { cn } from '@/lib/utils';\nimport {\n  DataStateBadge,\n  resolveDataState,\n  type AnnouncementOptions,\n} from '@/components/ui/data-state';\nimport { Skeleton } from '@/components/ui/skeleton';\nimport { toneFor, type Polarity } from '@/components/ui/charts/delta';\nimport { delta, describeSeries, linePath, seriesScales, type Point } from '@/components/ui/charts/scale';\n\nexport const MIN_VIEWPORT = 320 as const;\n\nexport interface SparklineProps\n  // `points` is also a real SVG presentation attribute (on `<polyline>`/`<polygon>`,\n  // where it is a space-separated string). Ours wins — omit theirs, or TS reports\n  // the interface as an invalid extension rather than a prop collision.\n  extends Omit<React.ComponentProps<'svg'>, 'width' | 'height' | 'children' | 'points'> {\n  points: readonly Point[];\n  width?: number;\n  height?: number;\n  /**\n   * Set when the same numbers are already announced adjacently — in a\n   * `MetricTable` row the value and delta cells say it, so a second\n   * announcement is noise. Renders `aria-hidden` instead of `role=\"img\"`.\n   */\n  decorative?: boolean;\n  /** Name used in the accessible label. Ignored when `decorative`. */\n  label?: string;\n  /**\n   * Which direction counts as good. `inverse` for latency, cost, error rate,\n   * open issues.\n   *\n   * Without this the sparkline coloured purely by DIRECTION, so an\n   * inverse-polarity row rendered a red line beside a green delta — the same\n   * row asserting \"bad\" and \"good\" simultaneously. Only visible by looking at\n   * it; every unit test passed.\n   */\n  polarity?: Polarity;\n  /**\n   * Render a `<Skeleton variant=\"sparkline\" />` placeholder at the exact inline\n   * cell size, so a metric arriving mid-window does not reflow its column.\n   */\n  loading?: boolean;\n  /**\n   * The fetch failed.\n   *\n   * The empty form of this component is `aria-hidden` — correct, because in a\n   * `MetricTable` row the numbers beside it already say there is no trend. A\n   * FAILED row is the opposite case: nothing else in the row knows, so this is\n   * the only element that can say so, and it says it out loud.\n   */\n  error?: unknown;\n  /** Context for the absence sentence — noun, coverage, reason. */\n  announce?: AnnouncementOptions;\n}\n\nexport const Sparkline = React.forwardRef<SVGSVGElement, SparklineProps>(function Sparkline(\n  {\n    points,\n    width = 90,\n    height = 22,\n    decorative = false,\n    label,\n    polarity = 'normal',\n    loading = false,\n    error,\n    announce,\n    className,\n    ...props\n  },\n  ref,\n) {\n  const scales = React.useMemo(\n    () => seriesScales(points, width, height),\n    [points, width, height],\n  );\n  const path = linePath(scales);\n  const change = delta(points);\n\n  const absence = resolveDataState({ loading, error }, announce);\n\n  if (absence.state === 'loading') {\n    return (\n      <Skeleton variant=\"sparkline\" data-slot=\"sparkline\" className={className} />\n    );\n  }\n\n  // A failed request is not a flat trend and it is not a missing one. It holds\n  // the same box as the placeholder below — the column must not reflow just\n  // because one row failed — and unlike that placeholder it is announced,\n  // because no other cell in the row knows the fetch failed.\n  if (absence.state === 'error') {\n    return (\n      <span\n        data-slot=\"sparkline-error\"\n        data-state=\"error\"\n        className={cn('inline-flex items-center align-middle', className)}\n        // Same intrinsic sizing argument as the placeholder below.\n        style={{ width, height }}\n      >\n        <DataStateBadge state=\"error\" glyphOnly announce={announce} />\n      </span>\n    );\n  }\n\n  // One point cannot show a trend, and an empty box that still occupies the\n  // column keeps the table from reflowing when a metric starts mid-window.\n  if (!path) {\n    return (\n      <span\n        aria-hidden\n        data-slot=\"sparkline-empty\"\n        className={cn('inline-block align-middle', className)}\n        // ponytail: intrinsic sizing on a placeholder is the one thing Tailwind\n        // cannot express for arbitrary caller-supplied numbers.\n        style={{ width, height }}\n      />\n    );\n  }\n\n  // Past the guard above, `path` is non-empty, which means ≥2 numeric points,\n  // which is exactly the condition under which `delta()` returns a value. The\n  // assertion documents that invariant instead of adding a `?? 'flat'` fallback\n  // that no input can ever reach — an unreachable default is a lie the coverage\n  // report has to be argued with.\n  const direction = change!.direction;\n  const TONE = {\n    good: 'text-viz-positive',\n    bad: 'text-viz-negative',\n    flat: 'text-viz-neutral',\n  } as const;\n  const tone = TONE[toneFor(direction, polarity)];\n  const last = scales.points.length - 1;\n\n  return (\n    <svg\n      ref={ref}\n      data-slot=\"sparkline\"\n      data-min-viewport={String(MIN_VIEWPORT)}\n      data-direction={direction}\n      width={width}\n      height={height}\n      viewBox={`0 0 ${width} ${height}`}\n      className={cn('inline-block align-middle overflow-visible', tone, className)}\n      {...(decorative\n        ? { 'aria-hidden': true }\n        : { role: 'img', 'aria-label': describeSeries(points, label) })}\n      {...props}\n    >\n      <path d={path} fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} />\n      {/* The end cap is the \"you are here\" — without it a sparkline reads\n          equally in both directions at a glance. */}\n      <circle cx={scales.x(last)} cy={scales.y(scales.points[last].v)} r={1.8} fill=\"currentColor\" />\n    </svg>\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": false,
    "minViewport": 320,
    "loading": true,
    "version": "1.2.0",
    "since": "1.0.0"
  },
  "docs": "## @interlace/sparkline\n\nInstalled to `components/ui/charts/sparkline.tsx`.\n\n```tsx\nimport { /* … */ } from '@/components/ui/charts/sparkline';\n```\n\nProps, a11y contract, live preview and source: https://ds.interlace.tools/c/sparkline\n\nRequires the `@interlace/theme` CSS baseline (installed automatically as a registry dependency)."
}
