{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "distribution",
  "type": "registry:ui",
  "title": "Distribution",
  "description": "One quantity spread across a fixed set of bins — hours of the day, days of the week, cohorts, buckets — with an optional REFERENCE distribution drawn over it.",
  "author": "ofri-peretz <https://github.com/ofri-peretz>",
  "categories": [
    "data",
    "chart"
  ],
  "dependencies": [],
  "registryDependencies": [
    "https://ds.interlace.tools/r/theme.json",
    "https://ds.interlace.tools/r/cn.json",
    "https://ds.interlace.tools/r/data-state.json",
    "https://ds.interlace.tools/r/series-table.json",
    "https://ds.interlace.tools/r/skeleton.json"
  ],
  "files": [
    {
      "path": "registry/interlace-ui/charts/distribution.tsx",
      "target": "components/ui/charts/distribution.tsx",
      "type": "registry:ui",
      "content": "'use client';\n\nimport * as React from 'react';\n\n// @interlace/distribution v1.1.0 — Interlace design system.\n// Docs, props and live preview: https://ds.interlace.tools/c/distribution\n// What changed since: https://ds.interlace.tools/c/distribution#history\n// Generated banner — keep it, the upgrade diff reads this version.\n\n/**\n * @interlace/ui — Distribution\n *\n * One quantity spread across a fixed set of bins — hours of the day, days of\n * the week, cohorts, buckets — with an optional REFERENCE distribution drawn\n * over it.\n *\n * ## Why this is not `TimeSeries` with three more props\n *\n * It was attempted that way first. Every part of `TimeSeries` is chronological\n * in a way that cannot be parameterised out:\n *\n *   - its axis keys are `day(t)`, sorted — a calendar. Bins are NAMES, and\n *     sorting `['Thu','Fri','Sat']` gives `['Fri','Sat','Thu']`, which is a\n *     week that does not exist;\n *   - a LINE asserts that the metric passed through every value between two\n *     samples. Between \"readers awake at 14:00\" and \"readers awake at 15:00\"\n *     there is nothing to pass through — the quantity is an aggregate OF the\n *     bin, not a sample at an instant;\n *   - `delta()` — first → last — is the sentence `TimeSeries` speaks, and on a\n *     cyclical axis the first and last bins are neighbours. \"Down 40% from\n *     00:00 to 23:00\" is arithmetic performed on a circle.\n *\n * So the marks are BARS from a zero baseline (see `bandScales` for why the\n * axis cannot be truncated), the axis order is the caller's, and the summary\n * sentence answers where the peak is rather than which way it went.\n *\n * ## The reference is the whole point, and it shares one domain\n *\n * A distribution on its own answers \"when did this happen\". The question worth\n * asking is almost always \"when did this happen *against what was available*\"\n * — reading against readers awake, incidents against traffic, deploys against\n * working hours. The gap between the bars and the reference IS the finding.\n *\n * Both series therefore share ONE y domain, exactly as in `TimeSeries`, and for\n * the same reason: two axes let an author slide one against the other until\n * they cross where the argument needs them to. That means the reference must\n * be in the SAME unit as the bars — usually both as shares of their own\n * denominator (\"% of the week's reading\" vs \"% of readers awake\"). A reference\n * in raw counts against bars in percent is not a chart this component will\n * draw honestly, and the fix is arithmetic in the caller, not a second axis.\n *\n * ## An unmeasured bin is not an empty bin\n *\n * This is where a bar chart is at its most dangerous: a bar of height zero and\n * a bar that was never drawn are the SAME PICTURE. Everywhere else in the\n * package a `null` can simply be skipped; here skipping it silently asserts a\n * zero. So an unmeasured bin gets a diagonal hatch across the full height of\n * its slot — the same mark `not-counted` carries in `DataStateBadge` and\n * `Meter` — and the accessible sentence counts them out loud.\n *\n * ## MIN_VIEWPORT — 320\n *\n * The plot is `viewBox`-scaled with no fixed width, the x labels are real HTML\n * (SVG text in this box paints at 4px at a 320 viewport — measured, not\n * reasoned about), the label row thins to its ends below `sm`, and the data\n * table scrolls inside its own box.\n *\n * ## Anatomy\n *\n *   <figure data-slot=\"distribution\" data-min-viewport=\"320\" data-peak-bin=\"14\">\n *     <figcaption>{label}</figcaption>\n *     <ul data-slot=\"distribution-legend\" />        // only with a reference\n *     <svg data-slot=\"distribution-plot\" role=\"img\" tabIndex={0}>\n *       <rect data-slot=\"distribution-gap\" />       // hatched: not measured\n *       <rect data-slot=\"distribution-bar\" />       // observed\n *       <path data-slot=\"distribution-reference\" /> // step, never sloped\n *     </svg>\n *     <div data-slot=\"distribution-axis\" />         // HTML labels + notes\n *     <div data-slot=\"distribution-readout\"><output aria-live=\"polite\" /></div>\n *     <SeriesTable axis=\"category\" />\n *   </figure>\n *\n * | Rule | Concept                    | Where in this file                                        |\n * | ---- | -------------------------- | --------------------------------------------------------- |\n * | R6   | data-slot on every part    | `\"distribution\" / \"-plot\" / \"-bar\" / \"-gap\" / \"-axis\"`     |\n * | R7   | className merged + ...rest | `cn(...)` + `{...props}`                                   |\n * | R8   | No `isXxx`; enums          | `axis=\"category\"` downstream; no boolean modes             |\n * | R10  | Composition seams          | `<SeriesTable>` renders the data; the caller owns the bins |\n * | R13  | Ecosystem first            | no charting dep — `bandScales` + SVG is the engine         |\n * | R14  | Declares min viewport      | `data-min-viewport={String(MIN_VIEWPORT)}`                 |\n * | R18  | Tailwind only              | zero inline `style`                                        |\n * | R19  | Tokens only                | `fill-chart-1`, `stroke-chart-2`, `stroke-viz-axis`        |\n * | R20  | AA contrast                | axis + hatch on `--viz-axis` (3.49:1 light / 3.83:1 dark)  |\n * | R23  | Absence is a vocabulary    | `loading` / `error` / no-bins / unmeasured-bin are four    |\n * | R25  | Client component           | pointer + key handlers, `useState`                         |\n * | R26  | A11y                       | `role=\"img\"` + label + focusable + live readout + table    |\n */\n\nimport { cn } from '@/lib/utils';\nimport {\n  announceDataState,\n  resolveDataState,\n  type AnnouncementOptions,\n} from '@/components/ui/data-state';\nimport { Skeleton } from '@/components/ui/skeleton';\nimport { SeriesTable } from '@/components/ui/charts/series-table';\nimport {\n  axisSlots,\n  bandScales,\n  describeDistribution,\n  keepAtNarrow,\n  peakBin,\n  slotAt,\n  stepPath,\n  ticks,\n  type Bin,\n} from '@/components/ui/charts/scale';\n\nexport const MIN_VIEWPORT = 320 as const;\n\n/** Internal drawing width in user units. The viewBox scales it to any container. */\nconst W = 900;\nconst PAD_LEFT = 44;\nconst PAD_TOP = 14;\n\n/** `PAD_LEFT` as a percentage of `W`, so the HTML label row starts where the plot does. */\nconst AXIS_PAD_LEFT = 'pl-[4.889%]';\n\n/**\n * At most eight x labels.\n *\n * `TimeSeries` caps at five because its labels are dates (`08-14`, five\n * characters, and the middles drop below `sm`). Bin labels are shorter by\n * nature — an hour, a weekday abbreviation — and eight is what lets a 24-bin\n * clock label every third bin, which is the granularity at which a reader can\n * still find \"about 3pm\" without counting.\n */\nconst MAX_LABELS = 8;\n\n/** Share of its band a bar occupies. The rest is the gutter that separates them. */\nconst BAR_WIDTH = 0.7;\n\n/** The name a reader sees. One fallback, used by the legend, readout and table alike. */\nconst named = (label: string | undefined, fallback: string): string => label ?? fallback;\n\n/** One bin, plus the two things a bin can carry that a `Point` cannot. */\nexport interface DistributionBin extends Bin {\n  /**\n   * A second reading of the same slot, on ANOTHER axis family — \"09:00\" under\n   * \"14:00 UTC\", \"Q3\" under \"Jul\".\n   *\n   * This is the honest replacement for the UTC/local toggle every clock chart\n   * hand-rolls. A toggle shows one axis and hides the other, so a reader\n   * comparing their own morning against a UTC peak has to hold one of the two\n   * in their head; and the hidden one is missing from any screenshot of the\n   * chart. Both readings are printed, and both travel into the readout and the\n   * data table.\n   */\n  note?: string;\n  /**\n   * This bin's REFERENCE value, in the same unit as `v`.\n   *\n   * On the bin rather than in a parallel array on purpose: a\n   * `reference: number[]` beside `bins` is an off-by-one waiting to happen, and\n   * the failure is silent — every bar simply lines up against its neighbour's\n   * reference.\n   */\n  reference?: number | null;\n}\n\nexport interface DistributionProps\n  extends Omit<React.ComponentProps<'figure'>, 'children'> {\n  /**\n   * The bins, in the order they belong on the axis. That order is the axis —\n   * nothing here sorts them.\n   */\n  bins: readonly DistributionBin[];\n  /** Name of the plotted quantity — used in the caption, label, readout and table. */\n  label?: string;\n  /** Noun for the values, e.g. \"views\", \"%\". */\n  unit?: string;\n  /** Name of the reference overlay. Drawn whenever any bin has a `reference`. */\n  referenceLabel?: string;\n  /** Column header for the bin column of the data table — \"Hour\", \"Weekday\". */\n  binLabel?: string;\n  /** Drawing height in user units. The rendered height follows the container width. */\n  height?: number;\n  /** Render the data table visibly under the chart instead of `sr-only`. */\n  showTable?: boolean;\n  /** Render a `<Skeleton variant=\"chart\" />` placeholder at the chart's own height. */\n  loading?: boolean;\n  /**\n   * The fetch failed. A different statement from \"every bin is empty\", which is\n   * a real and interesting finding about the subject; this is a fact 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 Distribution = React.forwardRef<HTMLElement, DistributionProps>(\n  function Distribution(\n    {\n      bins,\n      label,\n      unit,\n      referenceLabel,\n      binLabel = 'Bin',\n      height = 220,\n      showTable = false,\n      loading = false,\n      error,\n      announce,\n      className,\n      ...props\n    },\n    ref,\n  ) {\n    const [cursor, setCursor] = React.useState<number | null>(null);\n    const svgRef = React.useRef<SVGSVGElement>(null);\n\n    // React's generated ids contain `:`, which is legal in an id and awkward\n    // everywhere else. The pattern is referenced by `url(#…)`, and one hatch\n    // definition per instance is what keeps two charts on one page from\n    // sharing — and then fighting over — a single `<pattern id=\"hatch\">`.\n    const hatchId = `distribution-hatch-${React.useId().replace(/[^\\w-]/g, '')}`;\n\n    const values = React.useMemo(() => bins.map((bin) => bin.v), [bins]);\n    const references = React.useMemo(\n      () => bins.map((bin) => bin.reference ?? null),\n      [bins],\n    );\n\n    // \"Is there a reference to draw\" is exactly \"does any bin have a measured\n    // reference\", which `peakBin` already answers — rather than a second\n    // hand-rolled scan that could disagree with the one the axis uses.\n    const hasReference = peakBin(references) !== null;\n\n    const plot = React.useMemo(\n      () => bandScales([values, references], W - PAD_LEFT, height, PAD_TOP),\n      [values, references, height],\n    );\n    const px = React.useCallback((slot: number) => PAD_LEFT + plot.x(slot), [plot]);\n\n    const axisTicks = React.useMemo(\n      () => ticks({ points: bins, min: plot.min, max: plot.max }, 4),\n      [bins, plot],\n    );\n    const labelSlots = React.useMemo(\n      () => axisSlots(bins.length, MAX_LABELS),\n      [bins.length],\n    );\n    const labelPosition = React.useMemo(\n      () => new Map(labelSlots.map((slot, index) => [slot, index])),\n      [labelSlots],\n    );\n\n    const last = bins.length - 1;\n    const move = React.useCallback(\n      (next: number) => setCursor(Math.max(0, Math.min(last, next))),\n      [last],\n    );\n\n    const onKeyDown = (event: React.KeyboardEvent<SVGSVGElement>) => {\n      const current = cursor ?? 0;\n      switch (event.key) {\n        case 'ArrowRight':\n          move(current + 1);\n          break;\n        case 'ArrowLeft':\n          move(current - 1);\n          break;\n        case 'Home':\n          move(0);\n          break;\n        case 'End':\n          move(last);\n          break;\n        case 'Escape':\n          setCursor(null);\n          return; // no preventDefault — Escape may close an enclosing overlay\n        default:\n          return;\n      }\n      // Arrow keys scroll the page by default; a focused chart owns them.\n      event.preventDefault();\n    };\n\n    const onPointerMove = (event: React.PointerEvent<SVGSVGElement>) => {\n      const box = svgRef.current?.getBoundingClientRect();\n      if (!box || box.width === 0) return;\n      const userX = ((event.clientX - box.left) / box.width) * W - PAD_LEFT;\n      // The pointer lands in a BAND, not near a vertex — `slotAt`, not\n      // `nearestSlot`. A bar owns its whole band, and rounding to the closest\n      // centre would highlight a bin the pointer is visibly not over.\n      move(slotAt(bins.length, userX, W - PAD_LEFT));\n    };\n\n    // Loading and error are resolved before every \"there is nothing to draw\"\n    // branch, through the same function `StatStrip` and `Meter` use — data in\n    // flight and data that failed to arrive are two claims, and neither is the\n    // claim \"this subject has no distribution\".\n    //\n    // Every hook above runs unconditionally, so no prop can change hook order.\n    const absence = resolveDataState({ loading, error }, announce);\n\n    if (absence.state === 'loading') {\n      return (\n        <Skeleton\n          variant=\"chart\"\n          data-slot=\"distribution\"\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=\"distribution-error\"\n          data-state=\"error\"\n          data-min-viewport={String(MIN_VIEWPORT)}\n          className={cn(\n            'm-0 w-full rounded-lg border border-destructive/40 p-6',\n            className,\n          )}\n          {...props}\n        >\n          <p role=\"alert\" className=\"text-sm text-destructive\">\n            {announceDataState('error', announce)} The shape of this\n            distribution is unknown, not flat.\n          </p>\n        </figure>\n      );\n    }\n\n    // No BINS at all — there is no axis to draw, which is a different thing\n    // from an axis whose bins were never measured. That second case renders\n    // below as a plot full of hatch, because \"we looked at all 24 hours and\n    // measured none of them\" is a finding and an empty box is not.\n    if (bins.length === 0) {\n      return (\n        <figure\n          ref={ref}\n          data-slot=\"distribution-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            No bins to plot. A distribution needs the set of slots it is spread\n            across, even when every one of them is empty.\n          </p>\n        </figure>\n      );\n    }\n\n    const peak = peakBin(values);\n    const referenceName = named(referenceLabel, 'Reference');\n    const primaryName = named(label, 'Value');\n\n    /** `label` and its second axis reading, as one string. */\n    const binKey = (bin: DistributionBin): string =>\n      bin.note ? `${bin.label} (${bin.note})` : bin.label;\n\n    const spoken = (value: number | null): string =>\n      value === null\n        ? 'not measured'\n        : `${value.toLocaleString()}${unit ? ` ${unit}` : ''}`;\n\n    /**\n     * The crosshair readout, for BOTH the pointer and the keyboard. One string,\n     * built once, rendered once — the same rule `TimeSeries` follows, for the\n     * same reason: a second hover-only surface can be right while the live\n     * region is wrong, and only a sighted mouse user would ever find out.\n     */\n    const readout =\n      cursor === null\n        ? ''\n        : [\n            binKey(bins[cursor]),\n            hasReference\n              ? `${primaryName} ${spoken(values[cursor])}`\n              : spoken(values[cursor]),\n            ...(hasReference\n              ? [`${referenceName} ${spoken(references[cursor])}`]\n              : []),\n          ].join(' · ');\n\n    const referencePath = stepPath(references, plot);\n    const tableSeries = [\n      { label: primaryName, points: bins.map((bin) => ({ t: binKey(bin), v: bin.v })) },\n      ...(hasReference\n        ? [\n            {\n              label: referenceName,\n              points: bins.map((bin) => ({ t: binKey(bin), v: bin.reference ?? null })),\n            },\n          ]\n        : []),\n    ];\n\n    return (\n      <figure\n        ref={ref}\n        data-slot=\"distribution\"\n        data-min-viewport={String(MIN_VIEWPORT)}\n        data-bin-count={String(bins.length)}\n        // The peak, published rather than only drawn: \"where does this peak\"\n        // is the first question a distribution is asked, and a caller should\n        // not have to re-derive it from the same array to write a sentence\n        // under the chart.\n        data-peak-bin={peak === null ? undefined : String(peak)}\n        // `w-full` is load-bearing: the plot sizes itself from the container\n        // via `viewBox`, so a figure that collapses to zero width paints\n        // nothing. See the same note in time-series.tsx.\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            {bins.length} bins\n          </figcaption>\n        )}\n\n        {hasReference && (\n          <ul\n            data-slot=\"distribution-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            <li className=\"flex items-center gap-1.5\">\n              {/* The swatch repeats the MARK, not only the hue: a filled block\n                  for the bars, a dashed rule for the step. A legend of two\n                  identical bars in two colours identifies nothing in\n                  greyscale. */}\n              <svg aria-hidden width={24} height={8} viewBox=\"0 0 24 8\" className=\"shrink-0\">\n                <rect x={2} y={0} width={20} height={8} className=\"fill-chart-1\" />\n              </svg>\n              {primaryName}\n            </li>\n            <li 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=\"6 4\"\n                  className=\"stroke-chart-2\"\n                />\n              </svg>\n              {referenceName}\n            </li>\n          </ul>\n        )}\n\n        <svg\n          ref={svgRef}\n          data-slot=\"distribution-plot\"\n          viewBox={`0 0 ${W} ${height}`}\n          className={cn(\n            'block w-full touch-pan-y rounded-md',\n            'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',\n          )}\n          role=\"img\"\n          aria-label={`${describeDistribution(bins, label, unit)}${\n            hasReference\n              ? ` ${describeDistribution(\n                  bins.map((bin) => ({ label: bin.label, v: bin.reference ?? null })),\n                  referenceName,\n                  unit,\n                )}`\n              : ''\n          } Focus this chart and use the left and right arrow keys to read individual bins.`}\n          tabIndex={0}\n          onKeyDown={onKeyDown}\n          onPointerMove={onPointerMove}\n          onPointerLeave={() => setCursor(null)}\n          onBlur={() => setCursor(null)}\n        >\n          <defs>\n            {/* The hatch, as SVG rather than the `HATCH_CLASS` background used\n                by `DataStateBadge`: a CSS background-image does not paint on an\n                SVG shape, so the vocabulary's mark has to be re-expressed in\n                the medium. Same angle, same token, same meaning. */}\n            <pattern\n              id={hatchId}\n              width={6}\n              height={6}\n              patternUnits=\"userSpaceOnUse\"\n              patternTransform=\"rotate(45)\"\n            >\n              <line\n                x1={0}\n                y1={0}\n                x2={0}\n                y2={6}\n                className=\"stroke-viz-axis\"\n                strokeWidth={1}\n              />\n            </pattern>\n          </defs>\n\n          {axisTicks.map((value) => {\n            const y = plot.y(value);\n            return (\n              <g key={value}>\n                <line\n                  x1={PAD_LEFT}\n                  y1={y}\n                  x2={W}\n                  y2={y}\n                  className=\"stroke-viz-grid\"\n                  strokeWidth={1}\n                  aria-hidden\n                />\n                <text\n                  x={PAD_LEFT - 8}\n                  y={y}\n                  dominantBaseline=\"middle\"\n                  textAnchor=\"end\"\n                  className=\"fill-muted-foreground text-xs tabular-nums\"\n                  aria-hidden\n                >\n                  {Math.round(value).toLocaleString()}\n                </text>\n              </g>\n            );\n          })}\n\n          <line\n            x1={PAD_LEFT}\n            y1={PAD_TOP}\n            x2={PAD_LEFT}\n            y2={plot.y(plot.min)}\n            className=\"stroke-viz-axis\"\n            strokeWidth={1}\n            aria-hidden\n          />\n          {/* The baseline is ZERO, not the bottom of the box. On a bar chart\n              they are the same line only when the domain has no negatives, and\n              drawing the box edge instead is how a negative bar ends up\n              hanging off an axis it never crosses. */}\n          <line\n            data-slot=\"distribution-baseline\"\n            x1={PAD_LEFT}\n            y1={plot.zero}\n            x2={W}\n            y2={plot.zero}\n            className=\"stroke-viz-axis\"\n            strokeWidth={1}\n            aria-hidden\n          />\n\n          {bins.map((bin, index) => {\n            const x = px(index) + (plot.band * (1 - BAR_WIDTH)) / 2;\n            const width = plot.band * BAR_WIDTH;\n\n            // Not measured. A skipped bar and a zero bar are the same picture,\n            // so the slot says which one it is instead of staying blank.\n            if (bin.v === null) {\n              return (\n                <rect\n                  key={bin.label}\n                  data-slot=\"distribution-gap\"\n                  data-state=\"not-counted\"\n                  x={px(index)}\n                  y={PAD_TOP}\n                  width={plot.band}\n                  height={Math.max(0, plot.y(plot.min) - PAD_TOP)}\n                  fill={`url(#${hatchId})`}\n                  aria-hidden\n                />\n              );\n            }\n\n            const y = plot.y(bin.v);\n            return (\n              <rect\n                key={bin.label}\n                data-slot=\"distribution-bar\"\n                x={x}\n                y={Math.min(y, plot.zero)}\n                width={width}\n                height={Math.abs(plot.zero - y)}\n                rx={2}\n                className=\"fill-chart-1\"\n                aria-hidden\n              />\n            );\n          })}\n\n          {referencePath && (\n            <path\n              data-slot=\"distribution-reference\"\n              // The step lives in unshifted scale space, like the series paths\n              // in time-series.tsx, so one translate covers it and everything\n              // positioned with `px()` stays on the other side of that seam.\n              transform={`translate(${PAD_LEFT} 0)`}\n              d={referencePath}\n              fill=\"none\"\n              strokeWidth={2}\n              strokeLinejoin=\"round\"\n              strokeDasharray=\"6 4\"\n              className=\"stroke-chart-2\"\n              aria-hidden\n            />\n          )}\n\n          {cursor !== null && (\n            <rect\n              data-slot=\"distribution-cursor\"\n              x={px(cursor)}\n              y={PAD_TOP}\n              width={plot.band}\n              height={Math.max(0, plot.y(plot.min) - PAD_TOP)}\n              className=\"fill-viz-crosshair opacity-20\"\n              aria-hidden\n            />\n          )}\n        </svg>\n\n        {/* The x scale, in HTML at a real 12px. One cell per bin — including\n            the unlabelled ones — because a flex row of equal cells is the only\n            way to keep a label centred under its band at every width without\n            an inline style. */}\n        <div\n          data-slot=\"distribution-axis\"\n          aria-hidden\n          className={cn(\n            'flex text-xs text-muted-foreground tabular-nums',\n            AXIS_PAD_LEFT,\n          )}\n        >\n          {bins.map((bin, index) => {\n            const position = labelPosition.get(index);\n            return (\n              <span\n                key={bin.label}\n                className={cn(\n                  'min-w-0 flex-1',\n                  // A label is wider than its cell on any dense axis — 24 bins\n                  // at a 320 viewport gives each cell ~11px and \"00:00\" wants\n                  // 30. Spilling over an EMPTY neighbouring cell is harmless;\n                  // spilling outside the row is not, because it is what makes\n                  // the page scroll sideways. So the two labels that can only\n                  // spill outward are pinned to the edges instead — the same\n                  // thing `TimeSeries` gets from `justify-between`.\n                  position === 0\n                    ? 'text-start'\n                    : position === labelSlots.length - 1\n                      ? 'text-end'\n                      : 'text-center',\n                )}\n              >\n                {position === undefined ? null : (\n                  <span\n                    className={\n                      keepAtNarrow(position, labelSlots.length)\n                        ? undefined\n                        : 'hidden sm:inline'\n                    }\n                  >\n                    {bin.label}\n                    {bin.note ? (\n                      // Hierarchy by WEIGHT, not by opacity.\n                      //\n                      // This was `opacity-70`, and opacity is the one way to\n                      // de-emphasise text that no token can protect you from:\n                      // `--muted-foreground` is 8.06:1 on white, and the same\n                      // colour at 70% composites to #827d77 — 4.07:1, under the\n                      // AA floor for 12px text. The token measured fine; the\n                      // rendered pixels did not, and only axe folding the\n                      // ancestor opacity into the computed value caught it.\n                      //\n                      // Sharper still: `bin.note` exists BECAUSE a timezone\n                      // toggle hides half the truth. Printing the second\n                      // reading and then dimming it below AA is the same\n                      // mistake wearing a different hat.\n                      <span className=\"block font-normal\">{bin.note}</span>\n                    ) : null}\n                  </span>\n                )}\n              </span>\n            );\n          })}\n        </div>\n\n        <div\n          data-slot=\"distribution-readout\"\n          className=\"flex flex-wrap items-baseline justify-between gap-x-4 text-xs text-muted-foreground tabular-nums\"\n        >\n          <span aria-hidden>{plot.min.toLocaleString()}</span>\n          <output aria-live=\"polite\" className=\"font-medium text-foreground\">\n            {readout}\n          </output>\n          <span aria-hidden>{plot.max.toLocaleString()}</span>\n        </div>\n\n        {/* `axis=\"category\"` — the bins keep the caller's order. Sorting them\n            is not merely unnecessary here, it is wrong: it would make the table\n            disagree with the picture directly above it about what order the\n            week happens in. */}\n        <SeriesTable\n          axis=\"category\"\n          keyLabel={binLabel}\n          series={tableSeries}\n          caption={\n            hasReference\n              ? `${primaryName}, ${referenceName} — full data`\n              : `${primaryName} — full data${unit ? ` (${unit})` : ''}`\n          }\n          hidden={!showTable}\n        />\n      </figure>\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.1.0",
    "since": "1.1.0"
  },
  "docs": "## @interlace/distribution\n\nInstalled to `components/ui/charts/distribution.tsx`.\n\n```tsx\nimport { /* … */ } from '@/components/ui/charts/distribution';\n```\n\nProps, a11y contract, live preview and source: https://ds.interlace.tools/c/distribution\n\nRequires the `@interlace/theme` CSS baseline (installed automatically as a registry dependency)."
}
