{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "meter-scale",
  "type": "registry:ui",
  "title": "Meter Scale",
  "description": "The maths behind `Meter` and `RankedBarList`. No React, no DOM — the same split `charts/scale.ts` makes, and for the same reason: a bar that draws a beautiful wrong length is worse than one that fails to render, and the length is the part that can be…",
  "author": "ofri-peretz <https://github.com/ofri-peretz>",
  "categories": [
    "data",
    "primitive"
  ],
  "dependencies": [],
  "registryDependencies": [
    "https://ds.interlace.tools/r/theme.json"
  ],
  "files": [
    {
      "path": "registry/interlace-ui/meter-scale.ts",
      "target": "components/ui/meter-scale.ts",
      "type": "registry:ui",
      "content": "export type MeterScaleKind = 'linear' | 'log';\n\n/** Clamp to the unit interval. Non-finite input is treated as the floor. */\nexport const clamp01 = (value: number): number =>\n  Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0;\n\n/**\n * The log domain floor.\n *\n * `log(0)` is `-Infinity`, so a log axis needs a positive lower bound. 1 is the\n * honest choice for counts: it is the smallest thing that can be observed at\n * all, and it maps to fraction 0 — \"the least this axis can show\", not \"none\".\n */\nexport const LOG_FLOOR = 1;\n\n/**\n * Value → fill fraction in `[0, 1]`, or `null` when the value is unmeasured.\n *\n * Rules, each of which exists because the naive version is wrong:\n *\n *  - `value === null` → `null`. Unmeasured is not zero. Callers render the\n *    hatch, not an empty bar.\n *  - a non-finite `value` or `max` → `null`. `NaN / 0` silently paints a\n *    full-width bar in some browsers and an empty one in others.\n *  - `max <= 0` → `null`. There is no denominator, so there is no fraction;\n *    inventing `1` would report every row as complete.\n *  - the result is clamped, so a value above the stated maximum draws full\n *    rather than overflowing its track. The NUMBER still shows the overage —\n *    which is why the number is never optional.\n *\n * On the log branch, `floor` is the bottom of the domain (default `LOG_FLOOR`).\n * Values at or below it map to 0. When the domain collapses (`max <= floor`)\n * a measured value is at its ceiling, so it maps to 1 — unlike a flat time\n * series, a meter's denominator is a *stated* maximum rather than an observed\n * span, so there is nothing to centre.\n */\nexport function meterFraction(\n  value: number | null | undefined,\n  max: number | null | undefined,\n  kind: MeterScaleKind = 'linear',\n  floor: number = LOG_FLOOR,\n): number | null {\n  if (value === null || value === undefined) return null;\n  if (max === null || max === undefined) return null;\n  if (!Number.isFinite(value) || !Number.isFinite(max)) return null;\n  if (max <= 0) return null;\n\n  if (kind === 'linear') return clamp01(value / max);\n\n  const lo = Math.max(floor, Number.MIN_VALUE);\n  if (max <= lo) return value >= max ? 1 : 0;\n  if (value <= lo) return 0;\n  return clamp01(\n    (Math.log10(value) - Math.log10(lo)) / (Math.log10(max) - Math.log10(lo)),\n  );\n}\n\n/**\n * The largest measured value in a set of rows, or `null` when none were.\n *\n * `null`s are skipped rather than counted as `0`, so an all-unmeasured list\n * yields `null` and the caller renders hatch rows instead of a row of empty\n * bars implying every value was zero.\n */\nexport function meterDomainMax(\n  values: readonly (number | null | undefined)[],\n): number | null {\n  let max: number | null = null;\n  for (const value of values) {\n    if (value === null || value === undefined) continue;\n    if (!Number.isFinite(value)) continue;\n    if (max === null || value > max) max = value;\n  }\n  return max;\n}\n\n/**\n * Sort rows by magnitude, unmeasured rows last.\n *\n * Two properties that a bare `.sort((a, b) => b.value - a.value)` does not\n * have, and both matter:\n *\n *  - An unmeasured row (`null`) sinks to the bottom **without becoming zero**.\n *    Coercing it would put it below every measured row *and* claim it was the\n *    smallest, which is a measurement nobody took.\n *  - The sort is stable within each group, so two rows at the same value — and\n *    the whole block of unmeasured rows — keep the caller's order. An\n *    alphabetical input stays alphabetical where the data does not separate it.\n */\nexport function rankByValue<T extends { value: number | null | undefined }>(\n  rows: readonly T[],\n): T[] {\n  const measurable = (row: T): boolean =>\n    row.value !== null && row.value !== undefined && Number.isFinite(row.value);\n\n  return rows\n    .map((row, index) => ({ row, index }))\n    .sort((a, b) => {\n      const aHas = measurable(a.row);\n      const bHas = measurable(b.row);\n      if (aHas !== bHas) return aHas ? -1 : 1;\n      if (!aHas) return a.index - b.index;\n      const diff = (b.row.value as number) - (a.row.value as number);\n      return diff === 0 ? a.index - b.index : diff;\n    })\n    .map((entry) => entry.row);\n}\n\n/**\n * Fill widths as literal Tailwind classes, indexed by whole percent.\n *\n * A table and not `style={{ width }}`, because Tailwind v4 scans source as raw\n * TEXT: a template-built `w-[${n}%]` is never emitted, and an inline style is\n * the thing this design system does not do (R18). 101 entries is the honest\n * cost of a data-driven width that stays in the class layer.\n *\n * One percent is ~3px on a 300px track, which is below the width of the\n * hairline that separates two rows — and the exact value is printed beside the\n * bar regardless, because length is never the only carrier.\n */\nexport const FILL_WIDTH_CLASSES = [\n  'w-[0%]', 'w-[1%]', 'w-[2%]', 'w-[3%]', 'w-[4%]', 'w-[5%]', 'w-[6%]', 'w-[7%]', 'w-[8%]', 'w-[9%]',\n  'w-[10%]', 'w-[11%]', 'w-[12%]', 'w-[13%]', 'w-[14%]', 'w-[15%]', 'w-[16%]', 'w-[17%]', 'w-[18%]', 'w-[19%]',\n  'w-[20%]', 'w-[21%]', 'w-[22%]', 'w-[23%]', 'w-[24%]', 'w-[25%]', 'w-[26%]', 'w-[27%]', 'w-[28%]', 'w-[29%]',\n  'w-[30%]', 'w-[31%]', 'w-[32%]', 'w-[33%]', 'w-[34%]', 'w-[35%]', 'w-[36%]', 'w-[37%]', 'w-[38%]', 'w-[39%]',\n  'w-[40%]', 'w-[41%]', 'w-[42%]', 'w-[43%]', 'w-[44%]', 'w-[45%]', 'w-[46%]', 'w-[47%]', 'w-[48%]', 'w-[49%]',\n  'w-[50%]', 'w-[51%]', 'w-[52%]', 'w-[53%]', 'w-[54%]', 'w-[55%]', 'w-[56%]', 'w-[57%]', 'w-[58%]', 'w-[59%]',\n  'w-[60%]', 'w-[61%]', 'w-[62%]', 'w-[63%]', 'w-[64%]', 'w-[65%]', 'w-[66%]', 'w-[67%]', 'w-[68%]', 'w-[69%]',\n  'w-[70%]', 'w-[71%]', 'w-[72%]', 'w-[73%]', 'w-[74%]', 'w-[75%]', 'w-[76%]', 'w-[77%]', 'w-[78%]', 'w-[79%]',\n  'w-[80%]', 'w-[81%]', 'w-[82%]', 'w-[83%]', 'w-[84%]', 'w-[85%]', 'w-[86%]', 'w-[87%]', 'w-[88%]', 'w-[89%]',\n  'w-[90%]', 'w-[91%]', 'w-[92%]', 'w-[93%]', 'w-[94%]', 'w-[95%]', 'w-[96%]', 'w-[97%]', 'w-[98%]', 'w-[99%]',\n  'w-[100%]',\n] as const;\n\n/**\n * Fraction → the class that paints it.\n *\n * Rounds rather than floors: at 0.999 a floor would paint 99% and leave a\n * hairline of track visible on a row that IS the maximum, which reads as\n * \"almost\" on the one row where the answer is \"yes\".\n */\nexport function fillWidthClass(fraction: number): string {\n  if (!Number.isFinite(fraction)) return FILL_WIDTH_CLASSES[0];\n  const percent = Math.round(clamp01(fraction) * 100);\n  return FILL_WIDTH_CLASSES[percent];\n}\n\n/**\n * Compact magnitude — `12.4k`, `3.1M`.\n *\n * A deliberate twin of `compact()` in `charts/scale.ts` rather than an import\n * of it. `charts/scale.ts` is a `.ts` companion, not a registry item, so a\n * cross-tier `../charts/scale.js` import from a primitive emits a\n * `registryDependencies` entry that resolves to nothing and makes the whole\n * item silently uninstallable via `npx shadcn add`. The two are pinned to\n * identical output by a test rather than by a shared module.\n */\nexport function compactMagnitude(value: number): string {\n  const abs = Math.abs(value);\n  if (!Number.isFinite(value)) return '—';\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 * The spoken sentence for one measured bar.\n *\n * A bar drawn with `aria-hidden` geometry and no text is a picture of a number\n * that a screen reader cannot read. This is the text equivalent WCAG 1.1.1\n * asks for, and it carries the ACTUAL value — not \"62 percent\", which is the\n * projection rather than the measurement.\n */\nexport function describeMeter(\n  label: string,\n  value: number | null,\n  max: number | null,\n  unit?: string,\n): string {\n  if (value === null) return `${label}: not measured.`;\n  const suffix = unit ? ` ${unit}` : '';\n  const measured = `${value.toLocaleString()}${suffix}`;\n  return max === null || max <= 0\n    ? `${label}: ${measured}.`\n    : `${label}: ${measured} of ${max.toLocaleString()}${suffix}.`;\n}\n\n/**\n * @interlace/ui — meter arithmetic (pure)\n *\n * The maths behind `Meter` and `RankedBarList`. No React, no DOM — the same\n * split `charts/scale.ts` makes, and for the same reason: a bar that draws a\n * beautiful wrong length is worse than one that fails to render, and the\n * length is the part that can be proved rather than reviewed.\n *\n * ## `null` is unmeasured, and it stays that way\n *\n * `meterFraction` returns `null` for a `null` value rather than `0`. A bar of\n * length zero and a bar that was never run look identical once the number is\n * gone, and the whole point of the hatch variant is that they must not.\n *\n * ## Length and number, never hue\n *\n * Nothing here returns a colour. Magnitude is carried by the fraction (length)\n * and by the formatted value (number) so the bar survives greyscale, a\n * screenshot at 40% width, and the ~8% of men with red-green colour vision\n * deficiency. That is the same rule `Delta` follows with its glyph/sign/colour\n * triple — see VISUALIZATION_PHILOSOPHY.md §5.\n */\n\n/**\n * Linear or logarithmic.\n *\n * `log` exists because reach spans orders of magnitude: a row at 10k and a row\n * at 10M cannot share a linear axis without the first becoming a hairline that\n * reads as zero. On a log axis both are legible and the *ordering* — the thing\n * a ranked list is for — survives.\n *\n * It is opt-in, and it must stay opt-in. A log axis flatters small numbers, so\n * silently defaulting to it would make every list look healthier than it is.\n */\n\n// @interlace/meter-scale v1.0.0 — Interlace design system.\n// Docs, props and live preview: https://ds.interlace.tools/c/meter-scale\n// What changed since: https://ds.interlace.tools/c/meter-scale#history\n// Generated banner — keep it, the upgrade diff reads this version.\n"
    }
  ],
  "meta": {
    "tier": "primitive",
    "client": false,
    "minViewport": null,
    "loading": false,
    "version": "1.0.0",
    "since": "1.1.0"
  },
  "docs": "## @interlace/meter-scale\n\nInstalled to `components/ui/meter-scale.ts`.\n\n```tsx\nimport { /* … */ } from '@/components/ui/meter-scale';\n```\n\nProps, a11y contract, live preview and source: https://ds.interlace.tools/c/meter-scale\n\nRequires the `@interlace/theme` CSS baseline (installed automatically as a registry dependency)."
}
