{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "radial-weave",
  "type": "registry:ui",
  "title": "Radial Weave",
  "description": "The same series `TimeSeries` plots, wrapped around a dial — the POSTER form: identity and shape, composed to be looked at and shared.",
  "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",
    "https://ds.interlace.tools/r/time-series.json"
  ],
  "files": [
    {
      "path": "registry/interlace-ui/charts/radial-weave.tsx",
      "target": "components/ui/charts/radial-weave.tsx",
      "type": "registry:ui",
      "content": "import * as React from 'react';\n\n// @interlace/radial-weave v1.2.0 — Interlace design system.\n// Docs, props and live preview: https://ds.interlace.tools/c/radial-weave\n// What changed since: https://ds.interlace.tools/c/radial-weave#history\n// Generated banner — keep it, the upgrade diff reads this version.\n\n/**\n * @interlace/ui — RadialWeave\n *\n * The same series `TimeSeries` plots, wrapped around a dial — the POSTER\n * form: identity and shape, composed to be looked at and shared.\n *\n * ## What this form is for — and what it is not\n *\n * A radial plot trades inspection for composition: angles are harder to\n * read against a scale than horizontal distance, and that trade is only\n * honest if the inspection surfaces still exist somewhere else. They do,\n * and this component carries them: the `aria-label` sentence states the\n * range and movement in words, the `<SeriesTable>` behind the picture is\n * lossless, and the readout row prints `min`/`max` in real HTML. There is\n * deliberately NO crosshair here — a value a reader needs to inspect is\n * `TimeSeries`' job, and building a second, angular inspection surface\n * would mean maintaining two of them in disagreement.\n *\n * ## The gap in the circle is a statement\n *\n * The sweep is 300°, not 360 (`DialGeometry`'s doc has the full argument):\n * a closed circle claims the newest observation meets the oldest. The gap\n * sits at the bottom — the speedometer convention — so time starts\n * bottom-left and runs clockwise over the top.\n *\n * ## Identity survives the form change\n *\n * Series are drawn with `SERIES_STYLE` — the exact dash+hue table\n * `TimeSeries` uses — so \"the dashed thread\" names the same series in both\n * forms, in greyscale, and in a screenshot. The reveal is the same\n * `weave-reveal` clip `TimeSeries` draws with, keyed by the same\n * value-based geometry string, so the two forms enter the same way.\n *\n * | Rule | Concept                    | Where in this file                              |\n * | ---- | -------------------------- | ----------------------------------------------- |\n * | R6   | data-slot on every part    | `\"radial-weave\" / \"-plot\" / \"-centre\" / \"-legend\" / \"-readout\"` |\n * | R7   | className merged + ...rest | `cn(...)` + `{...props}`                        |\n * | R13  | Ecosystem first            | no charting dep — `dial*` from `scale.ts`       |\n * | R19  | Tokens only                | `stroke-chart-1..5`, `stroke-viz-grid`          |\n * | R23  | Loading reserves its box   | `loading` → `<Skeleton variant=\"chart\">`        |\n * | R25  | Server component           | no state, no handlers — the reveal is CSS       |\n * | R26  | A11y                       | `role=\"img\"` + sentence + lossless 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  compact,\n  describeSeries,\n  dialPath,\n  dialRadius,\n  dialRing,\n  numeric,\n  plotScales,\n  ticks,\n  type DialGeometry,\n  type Point,\n} from '@/components/ui/charts/scale';\nimport { MIN_VIEWPORT, SERIES_STYLE, type ComparisonSeries } from '@/components/ui/charts/time-series';\n\n/** Drawing box, square. The viewBox scales it to any container width. */\nconst S = 460;\n\nconst GEOMETRY: DialGeometry = {\n  cx: S / 2,\n  cy: S / 2,\n  inner: 64,\n  outer: 206,\n  startAngle: 120,\n  sweep: 300,\n};\n\n/** Same drawing cap, same reason, as `TimeSeries` (five dash+hue pairs). */\nconst MAX_PLOTTED_SERIES = 5;\n\nconst named = (label?: string): string => label ?? 'Value';\n\nconst NO_COMPARE: readonly ComparisonSeries[] = [];\n\nexport interface RadialWeaveProps extends Omit<React.ComponentProps<'figure'>, 'children'> {\n  points: readonly Point[];\n  /** Further series wrapped around the same dial. One radial domain, shared. */\n  compare?: readonly ComparisonSeries[];\n  /** Series name — caption, accessible label, table caption. */\n  label?: string;\n  /** Noun for the values, e.g. \"downloads\". Printed under the centre value. */\n  unit?: string;\n  /** Render the data table visibly under the dial instead of `sr-only`. */\n  showTable?: boolean;\n  /** Render a `<Skeleton variant=\"chart\" />` placeholder. */\n  loading?: boolean;\n  /** The fetch failed — a different statement from an empty series. */\n  error?: unknown;\n  /** Context for the absence sentences — noun, coverage, reason. */\n  announce?: AnnouncementOptions;\n}\n\nexport const RadialWeave = React.forwardRef<HTMLElement, RadialWeaveProps>(function RadialWeave(\n  {\n    points,\n    compare = NO_COMPARE,\n    label,\n    unit,\n    showTable = false,\n    loading = false,\n    error,\n    announce,\n    className,\n    ...props\n  },\n  ref,\n) {\n  const clipId = `rw-reveal-${React.useId().replace(/[^a-zA-Z0-9-]/g, '')}`;\n\n  const all: readonly { points: readonly Point[]; label?: string; unit?: string }[] = [\n    { points, label, unit },\n    ...compare,\n  ];\n  const drawn = all.slice(0, MAX_PLOTTED_SERIES);\n  const undrawn = all.length - drawn.length;\n\n  // The inner/outer radii play the role height plays for `plotScales` —\n  // only min/max/keys/at are read here; x/y projectors go unused.\n  const plot = plotScales(drawn.map((s) => s.points), S, S);\n  const last = plot.keys.length - 1;\n\n  const absence = resolveDataState({ loading, error }, announce);\n\n  if (absence.state === 'loading') {\n    return (\n      <Skeleton\n        variant=\"chart\"\n        data-slot=\"radial-weave\"\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=\"radial-weave-error\"\n        data-state=\"error\"\n        data-min-viewport={String(MIN_VIEWPORT)}\n        className={cn('m-0 w-full rounded-lg border border-destructive/40 p-6', className)}\n        {...props}\n      >\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  // The primary series carries the dial, exactly as it carries the line\n  // (`TimeSeries`' rule and reasoning — a comparison series cannot rescue\n  // a headline metric with one reading).\n  const own = numeric(points);\n  if (own.length < 2) {\n    return (\n      <figure\n        ref={ref}\n        data-slot=\"radial-weave-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.length === 0 ? 'No data yet.' : `Only ${own.length} 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  // Same value-named geometry, same replay contract, as `TimeSeries`.\n  const geometry = [\n    drawn.map((s) => named(s.label)).join(','),\n    plot.keys[0],\n    plot.keys[last],\n    plot.keys.length,\n    plot.min,\n    plot.max,\n  ].join('|');\n\n  const latest = own[own.length - 1].v;\n\n  return (\n    <figure\n      ref={ref}\n      data-slot=\"radial-weave\"\n      data-min-viewport={String(MIN_VIEWPORT)}\n      data-series-count={String(drawn.length)}\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      {drawn.length > 1 && (\n        <ul\n          data-slot=\"radial-weave-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              <svg aria-hidden width={24} height={8} viewBox=\"0 0 24 8\" className=\"shrink-0\">\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 && <li>{`${undrawn} more not plotted — see the data table`}</li>}\n        </ul>\n      )}\n\n      <div className=\"relative\">\n        <svg\n          data-slot=\"radial-weave-plot\"\n          viewBox={`0 0 ${S} ${S}`}\n          className=\"block w-full\"\n          role=\"img\"\n          aria-label={drawn\n            .map((series) => describeSeries(series.points, series.label))\n            .join(' ')}\n        >\n          <defs>\n            <clipPath id={clipId}>\n              <rect\n                key={geometry}\n                x={0}\n                y={0}\n                width={S}\n                height={S}\n                className=\"animate-weave-reveal origin-left [transform-box:fill-box]\"\n              />\n            </clipPath>\n          </defs>\n\n          {/* Grid rings — the dial's stage, outside the reveal clip for the\n              same reason TimeSeries' grid is. Decorative weight; the values\n              the rings stand at are printed in HTML below. */}\n          {ticks({ points: plot.keys, min: plot.min, max: plot.max }, 3).map((value) => (\n            <path\n              key={value}\n              d={dialRing(dialRadius(value, plot.min, plot.max, GEOMETRY), GEOMETRY)}\n              className=\"fill-none stroke-viz-grid\"\n              strokeWidth={1}\n              aria-hidden\n            />\n          ))}\n\n          <g clipPath={`url(#${clipId})`}>\n            {drawn.map((series, index) => (\n              <path\n                key={`arc-${named(series.label)}`}\n                d={dialPath(\n                  plot.keys.map((_, slot) => plot.at(index, slot)),\n                  plot.min,\n                  plot.max,\n                  GEOMETRY,\n                )}\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        </svg>\n\n        {/* The centre is HTML, not SVG text: at any container width these\n            pixels are real pixels (TimeSeries' 4px-labels lesson). */}\n        <div\n          data-slot=\"radial-weave-centre\"\n          aria-hidden\n          className=\"pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center\"\n        >\n          <span className=\"text-2xl font-semibold tabular-nums text-foreground\">\n            {compact(latest)}\n          </span>\n          {unit && <span className=\"text-xs text-muted-foreground\">{unit}</span>}\n        </div>\n      </div>\n\n      <div\n        data-slot=\"radial-weave-readout\"\n        className=\"flex flex-wrap items-baseline justify-between gap-x-4 text-xs text-muted-foreground tabular-nums\"\n      >\n        <span>{plot.min.toLocaleString()}</span>\n        <span>\n          {plot.keys[0]} → {plot.keys[last]}\n        </span>\n        <span>{plot.max.toLocaleString()}</span>\n      </div>\n\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// ── The dial: time wrapped around an arc, for the radial (poster) form ──────\n\n/**\n * Geometry of a dial plot. The sweep is deliberately LESS than 360°: a\n * closed circle claims the last observation meets the first — that August\n * touches last December — and the gap is the honest statement that time\n * does not wrap. The gap sits at the bottom (the speedometer convention,\n * the most-practised radial read there is): time starts bottom-left and\n * proceeds clockwise over the top.\n */\nexport interface DialGeometry {\n  cx: number;\n  cy: number;\n  /** Radius where `min` sits — the centre hole keeps the middle legible. */\n  inner: number;\n  /** Radius where `max` sits. */\n  outer: number;\n  /** Degrees, SVG convention (0° = +x, clockwise positive). */\n  startAngle: number;\n  /** Degrees of arc the time axis occupies. */\n  sweep: number;\n}\n\n/** Slot → degrees. A single-slot axis sits mid-sweep, not at the start. */\nexport function dialAngle(slot: number, count: number, geometry: DialGeometry): number {\n  return (\n    geometry.startAngle +\n    (count > 1 ? (slot / (count - 1)) * geometry.sweep : geometry.sweep / 2)\n  );\n}\n\n/** Value → radius. A zero span centres between the rings (`seriesScales`' rule). */\nexport function dialRadius(\n  value: number,\n  min: number,\n  max: number,\n  geometry: DialGeometry,\n): number {\n  const span = max - min;\n  return span === 0\n    ? (geometry.inner + geometry.outer) / 2\n    : geometry.inner + ((value - min) / span) * (geometry.outer - geometry.inner);\n}\n\n/** Degrees + radius → cartesian, around the dial's centre. */\nexport function dialPoint(\n  angleDeg: number,\n  radius: number,\n  geometry: DialGeometry,\n): { x: number; y: number } {\n  const rad = (angleDeg * Math.PI) / 180;\n  return { x: geometry.cx + radius * Math.cos(rad), y: geometry.cy + radius * Math.sin(rad) };\n}\n\n/**\n * A series wrapped around the dial. Straight segments between observed\n * slots — a curve through polar space would smooth in values nobody\n * measured — and a `null` BREAKS the path (`numeric()`'s rule): an arc\n * bridging a gap is a drawn value with no observation under it.\n */\nexport function dialPath(\n  values: readonly (number | null)[],\n  min: number,\n  max: number,\n  geometry: DialGeometry,\n): string {\n  let d = '';\n  let open = false;\n  for (let slot = 0; slot < values.length; slot += 1) {\n    const value = values[slot];\n    if (typeof value !== 'number' || !Number.isFinite(value)) {\n      open = false;\n      continue;\n    }\n    const point = dialPoint(\n      dialAngle(slot, values.length, geometry),\n      dialRadius(value, min, max, geometry),\n      geometry,\n    );\n    d += `${open ? 'L' : 'M'}${point.x.toFixed(2)},${point.y.toFixed(2)}`;\n    open = true;\n  }\n  return d;\n}\n\n/**\n * A grid ring: one constant-radius arc across the whole sweep. Two `A`\n * segments rather than one: a sweep beyond 180° needs `large-arc-flag=1`\n * (and exactly 180° is ambiguous between the two semi-circles), so\n * splitting at the midpoint keeps every segment unambiguously minor —\n * correct for any sweep up to 360° without a case split.\n */\nexport function dialRing(radius: number, geometry: DialGeometry): string {\n  const start = dialPoint(geometry.startAngle, radius, geometry);\n  const mid = dialPoint(geometry.startAngle + geometry.sweep / 2, radius, geometry);\n  const end = dialPoint(geometry.startAngle + geometry.sweep, radius, geometry);\n  const arc = (to: { x: number; y: number }): string =>\n    `A${radius},${radius} 0 0 1 ${to.x.toFixed(2)},${to.y.toFixed(2)}`;\n  return `M${start.x.toFixed(2)},${start.y.toFixed(2)}${arc(mid)}${arc(end)}`;\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": null,
    "loading": true,
    "version": "1.2.0",
    "since": null
  },
  "docs": "## @interlace/radial-weave\n\nInstalled to `components/ui/charts/radial-weave.tsx`.\n\n```tsx\nimport { /* … */ } from '@/components/ui/charts/radial-weave';\n```\n\nProps, a11y contract, live preview and source: https://ds.interlace.tools/c/radial-weave\n\nRequires the `@interlace/theme` CSS baseline (installed automatically as a registry dependency)."
}
