{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "range-chart",
  "type": "registry:ui",
  "title": "Range chart",
  "description": "One quantity over time as a barcode of thin bars, with labelled segments and their shares in a tray above, and a range you brush by hand: drag across the bars, drag either edge, or drag the whole span. The edges follow the pointer one to one and settle on a bar or a segment start; the segment that matches the range is raised. The total of what is brushed rolls as you go, its last group of digits in the quiet ink. In fun mode the span can be thrown and the bars it sweeps rise and settle.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/button.json",
    "https://littmd.vercel.app/r/motion.json",
    "https://littmd.vercel.app/r/rolling-number.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/range-chart.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/range-chart 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Range chart (components.md, \"Range chart\"): one quantity over time as a barcode of thin bars, its segments in a tray\n * above them, and a range brushed by hand, with the total of what is brushed read as one figure that rolls. Think\n * visitors per day over eight months: drag across the launch and the figure says what the launch brought. A Time\n * scrubber finds a moment; this finds a span.\n *\n * - Anatomy, top to bottom: the reading, the segment tray, the bars, the rail, the axis.\n *   Reading: the label (small, title weight, --litt-fg) with a ghost \"Whole range\" button at the row's end, which lies flat\n *   (disabled) while nothing is narrowed, so the row never moves; the brushed total at the display size (18px, the one\n *   headline size, R8) in tabular figures with the unit after it in --litt-fg-subtle; and a line of words under it: \"13 Mar\n *   to 30 Jun, 18% of the whole\", or \"All of 1 Jan to 31 Aug\" (R5). The total is two inks on purpose: every group of\n *   digits but the last is --litt-fg and the last group is --litt-fg-subtle, because the last group is the one that churns while\n *   you brush, and the quiet ink keeps the eye on the magnitude. Under 1,000 there is one group and it is --litt-fg. The\n *   group separator comes from the locale, never a literal comma.\n *   Segment tray: a tray (R3: --litt-tray, --litt-tray-shade) the plot's width at sm height (44px on a finger), one button per\n *   segment as wide as its span, its label (caption, title weight) and its share of the whole (caption, tabular,\n *   --litt-fg-subtle: \"52%\", \"<1%\"). The segment whose span equals the range is the raised block (--litt-block, --litt-block-lift,\n *   aria-pressed): selected is raised. The block glides between segments by layoutId on the responsive tier, as the\n *   Segmented control's does, and is gone when no segment matches. A label too narrow for its span drops to its share.\n *   Pressing a segment settles the range onto it.\n *   Bars: `height` px tall. One 2px rect per drawn bar on a pitch of at least 4px; more bars than the width has room\n *   for are drawn in equal groups by their mean, and only drawn: the hand, the keys, the figure and the words work in\n *   whole bars. A drawn bar is lit (--litt-fg) once an edge has passed its centre, the rest are --litt-fg at 55%, the chart\n *   family's two steps; the fill fades on the instant tier. Never the accent (R6), never a hue (R7: one quantity, the\n *   quiet foreground).\n *   Rail: a slot (R3: --litt-slot, --litt-slot-shade) cut under the bars, as wide as they are, holding the Range slider's\n *   anatomy: the fill between two raised caps (--litt-slot-fill; --litt-cap, --litt-cap-lift, a grip line), 8 by 24px, standing\n *   taller than the slot. Notches under the slot mark each segment start: they are where a release is pulled to.\n * - Hand (R4, motion/hand.ts): press on the bars or the rail. Within 8px of an edge (22px on a finger) it takes that\n *   edge, held where it was taken, following 1:1; inside the range it takes the whole span, which keeps its width;\n *   anywhere else a drag brushes a new range from the bar edge nearest the press. A press without a drag outside the\n *   range sends the nearer edge there. Edges never cross. While held a cap sinks --litt-press-depth and its cast tightens\n *   (--litt-cap-press), at once. Each bar lights as an edge passes it and the figure rolls. On release the edges settle on\n *   the responsive spring, from rest, onto the nearest bar or a segment start within 8px, never past either end; then\n *   onValueCommit fires once.\n * - Fun mode (motion/fun.tsx, a trial): the span can be thrown. It coasts on the speed it was let go at, lands on the\n *   segment start nearest where it would stop (within 24px) on fun mode's throw spring, and gives like a rubber band\n *   past either end. Each bar an edge sweeps rises from its base once (scaleY 1.12 to 1 on the pop spring), a held cap\n *   lifts in the hand (useFunGrab on its face; the sink stays on its box), and the figure rolls through every value on\n *   the way. It rests exactly where Calm does.\n * - Keys and assistive tech: three sliders in a group named by the label, \"<label>, from\", \"<label>, to\" and\n *   \"<label>, move range\", each with aria-valuetext in words (\"13 Mar\", \"30 Jun\", \"13 Mar to 30 Jun\"). ← and → move\n *   one bar (Shift: ten), Page Up and Page Down go to the next and previous segment start (ten bars without segments),\n *   Home and End to the ends, Escape to the whole range; on \"move range\" the keys move the whole span. The tray's\n *   segments are buttons. The svg is an image with a summary, a visually hidden table lists every bar, and a polite\n *   status says the range and its total once it has settled, never while a hand still moves it.\n * - Motion: caps, fill and the drawn bars move by transform only; bar fills fade on the instant tier; the figure\n *   rolls through RollingNumber and the tray's block glides on the responsive tier. Reduced motion: the edges jump to\n *   where they settle, the figure and the fills just change.\n * - Touch: the plot owns sideways drags (data-no-drag, touch-action pan-y), so the page still scrolls; the rail and\n *   the tray grow to 44px and the edges take 22px of reach.\n * - Disabled: everything lies flat (the slot --litt-surface-3, the caps --litt-surface-2 with a hairline, the fill fg 12%, the\n *   label --litt-fg-disabled, \"Whole range\" and the segments disabled); no pointer, no tab stop. The figure and the words\n *   step down to --litt-fg-subtle, not --litt-fg-disabled: they are still information, and they must stay readable.\n * - Forced colors: lit bars take CanvasText and the rest GrayText, the slot and the tray a border, the caps a border\n *   and the system highlight, the raised segment a 2px border.\n */\nimport { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type RefObject } from \"react\";\nimport { LayoutGroup, animate, motion, useMotionValue, useMotionValueEvent, useTransform, type AnimationPlaybackControls, type MotionValue } from \"motion/react\";\nimport { Button } from \"./button\";\nimport { RollingNumber } from \"./rolling-number\";\nimport { useDevice, useFun, useFunGrab, useTransition } from \"../../lib/litt/motion\";\nimport { snap, useHand, useSettle } from \"../../lib/litt/motion/hand\";\n\nexport type RangeChartBar = {\n  /** What the bar is, short: a date or a name (\"12 Mar\"). It names the bar in the reading, the table and the keys. */\n  label: string;\n  /** How much, zero or more. The figure is a sum, so the values must add up. */\n  value: number;\n};\n\nexport type RangeChartSegment = {\n  /** What the stretch is: \"Launch\". It is said in the tray with its share of the whole, and names its button. */\n  label: string;\n  /** Index of the first bar it holds. It runs to the next segment's start, or to the last bar. Each start is where a release is pulled to. */\n  start: number;\n};\n\nexport type RangeChartTick = {\n  /** Index of the bar the words sit under. */\n  at: number;\n  /** The words under it, such as \"Jan\". */\n  label: string;\n};\n\nexport type RangeChartProps = {\n  /** What is measured, said once: \"Visitors\". It names the chart, its sliders and its table for assistive tech. */\n  label: string;\n  /** One quantity, one bar per entry, oldest first. */\n  bars: RangeChartBar[];\n  /** Labelled stretches of the bars with their shares of the whole, such as Before, Launch and Since. */\n  segments?: RangeChartSegment[];\n  /** The words under the bars at chosen bars, such as each month's first day. Without them the first and last labels sit at the ends. */\n  axis?: RangeChartTick[];\n  /** The brushed range as [first, last] bar indexes, both inclusive, when you control it. Pair it with onValueChange. */\n  value?: [first: number, last: number];\n  /** The range it starts with when you do not control it. Without one, the whole range. */\n  defaultValue?: [first: number, last: number];\n  /** Called with the new range each time the range moves onto other bars under a hand, and at once for a key, a press or a throw. */\n  onValueChange?: (value: [first: number, last: number]) => void;\n  /** Called once the edges have settled after a drag, a press or a key. Use it to load what is in the range. */\n  onValueCommit?: (value: [first: number, last: number]) => void;\n  /** What the values count, said after the total: \"visitors\". Pass [\"deploy\", \"deploys\"] to say the first for exactly 1. */\n  unit?: string | [one: string, other: string];\n  /** The locale for the figure and its group separator. */\n  locale?: string;\n  /** Height of the bars in px. The width is the container's. */\n  height?: number;\n  /** Makes the chart unavailable: flat, skipped by Tab, and deaf to the pointer. */\n  disabled?: boolean;\n  /** Extra classes for the chart, such as a width. */\n  className?: string;\n};\n\n/* -------------------------------------------------------------------------------------------------------------- */\n\nconst MIN_PITCH = 4; // px: the least a drawn bar's slot is wide, so a 2px bar keeps air\nconst BAR_MIN = 2;\nconst BAR_MAX = 6;\nconst GRAB = 8; // px each side of an edge that takes it with a mouse\nconst GRAB_TOUCH = 22; // px each side on a finger: a 44px target\nconst PULL = 8; // px: a release this near a segment start takes it\nconst PULL_THROWN = 24; // px: fun mode, a throw is pulled this far\nconst PAGE = 10; // bars a Page key moves when there are no segments\n\n/** What a finger holding a segment shows: the fg 6% fill that rows and tabs share, and the hover text color. */\nconst PRESS = \"pointer-coarse:active:bg-[color-mix(in_oklab,var(--litt-fg)_6%,transparent)] pointer-coarse:active:text-litt-fg\";\nconst RING = \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-litt-focus\";\n/** The rail draws an edge slider's focus ring on its cap's face, so the ring is the cap's shape and not the slider's box. */\nconst CAP_RING =\n  \"[&:has([data-edge=from]:focus-visible)_[data-cap=from]]:outline-2 [&:has([data-edge=from]:focus-visible)_[data-cap=from]]:outline-offset-2 [&:has([data-edge=from]:focus-visible)_[data-cap=from]]:outline-litt-focus \" +\n  \"[&:has([data-edge=to]:focus-visible)_[data-cap=to]]:outline-2 [&:has([data-edge=to]:focus-visible)_[data-cap=to]]:outline-offset-2 [&:has([data-edge=to]:focus-visible)_[data-cap=to]]:outline-litt-focus\";\nconst EDGE = \"pointer-events-none absolute top-0 left-0 h-full w-6 -translate-x-1/2 outline-none pointer-coarse:w-11\";\n/** A drawn bar grows from its base when fun mode lifts it. Static, so the rest frame is the same with fun mode off. */\nconst FROM_BASE = { transformBox: \"fill-box\", transformOrigin: \"50% 100%\" } as const;\n\ntype Zone = \"start\" | \"end\" | \"body\" | \"new\";\ntype Range = [first: number, last: number];\n\ntype Geometry = {\n  plotW: number;\n  /** Bars merged into one drawn bar: 1 when they fit. */\n  group: number;\n  /** px per bar. */\n  perBar: number;\n  barW: number;\n  /** One per drawn bar: its height as a share of the tallest, 0 to 1 (the mean of its group). */\n  heights: number[];\n};\n\ntype Segment = { label: string; start: number; end: number; share: number };\n\n/** The container's width, measured before paint and kept current (the Chart's). 0 until the first measure. */\nfunction useWidth(): readonly [RefObject<HTMLDivElement | null>, number] {\n  const ref = useRef<HTMLDivElement>(null);\n  const [width, setWidth] = useState(0);\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const read = () => setWidth(Math.floor(el.getBoundingClientRect().width));\n    read();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const ro = new ResizeObserver(read);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n  return [ref, width] as const;\n}\n\n/** Bars into pixels: the grouping, the bar width and the heights, from the width and the values. Pure. */\nfunction plan(plotW: number, values: readonly number[]): Geometry {\n  const n = values.length;\n  if (!plotW || !n) return { plotW, group: 1, perBar: 0, barW: BAR_MIN, heights: [] };\n  const fit = Math.max(1, Math.floor(plotW / MIN_PITCH));\n  const group = Math.max(1, Math.ceil(n / fit));\n  const perBar = plotW / n;\n  const barW = Math.min(BAR_MAX, Math.max(BAR_MIN, Math.floor((perBar * group) / 2)));\n  const means: number[] = [];\n  for (let k = 0; k * group < n; k++) {\n    let sum = 0;\n    let count = 0;\n    for (let i = k * group; i < Math.min(n, (k + 1) * group); i++) {\n      sum += Math.max(0, values[i] ?? 0);\n      count++;\n    }\n    means.push(sum / count);\n  }\n  let top = 0;\n  for (const m of means) top = Math.max(top, m);\n  return { plotW, group, perBar, barW, heights: means.map((m) => (top > 0 ? m / top : 0)) };\n}\n\n/** Running totals, so the total of any range is one subtraction: prefix[i] is the sum of values[0 .. i). */\nfunction cumulative(values: readonly number[]): Float64Array {\n  const out = new Float64Array(values.length + 1);\n  for (let i = 0; i < values.length; i++) out[i + 1] = out[i] + Math.max(0, values[i] ?? 0);\n  return out;\n}\n\n/** The segments in order, clipped to the bars, each running to the next start, with its share of the whole. Pure. */\nfunction segmentsOf(segments: readonly RangeChartSegment[], prefix: Float64Array, n: number): Segment[] {\n  const whole = prefix[n] ?? 0;\n  const sorted = [...segments]\n    .map((s) => ({ label: s.label, start: Math.max(0, Math.min(n - 1, Math.round(s.start))) }))\n    .sort((p, q) => p.start - q.start)\n    .filter((s, i, all) => i === 0 || s.start !== all[i - 1].start);\n  return sorted.map((s, i) => {\n    const end = i + 1 < sorted.length ? sorted[i + 1].start : n;\n    return { label: s.label, start: s.start, end, share: whole > 0 ? (prefix[end] - prefix[s.start]) / whole : 0 };\n  });\n}\n\n/** The bar boundary nearest `x` px, from 0 to n. */\nfunction boundaryAt(x: number, geo: Geometry, n: number): number {\n  return Math.max(0, Math.min(n, Math.round(x / geo.perBar)));\n}\n\n/** The edges as bar indexes, [first, last] both inclusive, at least one bar wide. The inverse of `edgesOf`. */\nfunction rangeOf(a: number, b: number, geo: Geometry, n: number): Range {\n  const first = Math.min(n - 1, boundaryAt(a, geo, n));\n  const to = Math.max(first + 1, boundaryAt(b, geo, n));\n  return [first, to - 1];\n}\n\n/** A range as px edges in the plot: the first bar's left edge and the last bar's right edge. */\nfunction edgesOf(range: Range, geo: Geometry): [number, number] {\n  return [range[0] * geo.perBar, (range[1] + 1) * geo.perBar];\n}\n\n/** Sorted, inside [0, n - 1] and whole. */\nfunction clampRange(range: Range, n: number): Range {\n  if (n <= 0) return [0, 0];\n  const lo = Math.max(0, Math.min(n - 1, Math.round(Math.min(range[0], range[1]))));\n  const hi = Math.max(lo, Math.min(n - 1, Math.round(Math.max(range[0], range[1]))));\n  return [lo, hi];\n}\n\n/**\n * What a press at `x` (px in the plot) takes, given the edges `a` and `b`: an edge within `grab` (the nearer one when\n * both reach), else the body between them, else a new range. Pure.\n */\nfunction zoneAt(x: number, a: number, b: number, grab: number): Zone {\n  const da = Math.abs(x - a);\n  const db = Math.abs(x - b);\n  if (da <= grab || db <= grab) return da <= db ? \"start\" : \"end\";\n  return x > a && x < b ? \"body\" : \"new\";\n}\n\n/**\n * Where released edges come to rest, in px. `moved` says which edge the hand carried: one edge snaps to the grid or\n * to a segment start within the pull, kept a bar from the `still` one; both move together keeping their width, and\n * whichever edge a snap moves less decides. Pure.\n */\nfunction landEdges(moved: \"a\" | \"b\" | \"both\", points: readonly [number, number], still: readonly [number, number], geo: Geometry, anchors: readonly number[], thrown: boolean): [number, number] {\n  const step = geo.perBar;\n  const max = geo.plotW;\n  const pull = thrown ? PULL_THROWN : PULL;\n  const [pa, pb] = points;\n  const [A, B] = still;\n  if (moved === \"a\") return [snap(pa, { step, min: 0, max: B - step, anchors, pull }), B];\n  if (moved === \"b\") return [A, snap(pb, { step, min: A + step, max, anchors, pull })];\n  const w = B - A;\n  const a1 = snap(pa, { step, min: 0, max: max - w, anchors, pull });\n  const b1 = snap(pb, { step, min: w, max, anchors, pull });\n  return Math.abs(a1 - pa) <= Math.abs(b1 - pb) ? [a1, a1 + w] : [b1 - w, b1];\n}\n\n/** \"52%\", and \"<1%\" for a share that is there but under one percent (the Breakdown's). */\nconst percent = (share: number): string => (share <= 0 ? \"0%\" : share < 0.01 ? \"<1%\" : `${Math.round(share * 100)}%`);\n\n/** The locale's group separator, probed from a million (es and pl do not group four digits). Null when it has none. */\nfunction groupSeparator(locale: string): string | null {\n  try {\n    return new Intl.NumberFormat(locale).formatToParts(1_000_000).find((p) => p.type === \"group\")?.value ?? null;\n  } catch {\n    return null;\n  }\n}\n\n/**\n * The brushed total in two inks: the head in --litt-fg, the last group of three digits in --litt-fg-subtle, each its own\n * RollingNumber so each rolls on its own. One group (under 1,000, or a locale with no separator) is one RollingNumber.\n */\nfunction Figure({ total, locale, separator, disabled }: { total: number; locale: string; separator: string | null; disabled: boolean }) {\n  const whole = Math.max(0, Math.round(total));\n  const head = Math.floor(whole / 1000);\n  const ink = disabled ? \"text-litt-fg-subtle\" : \"text-litt-fg\";\n  if (!separator || head === 0) return <RollingNumber value={whole} locale={locale} className={ink} />;\n  return (\n    <>\n      <RollingNumber value={head} locale={locale} className={ink} />\n      <span className={ink}>{separator}</span>\n      <RollingNumber value={whole % 1000} locale={locale} format={{ minimumIntegerDigits: 3, useGrouping: false }} className=\"text-litt-fg-subtle\" />\n    </>\n  );\n}\n\n/** One cap on the rail: its box rides on `x`, its sink while held is on the box, and the face (lifted by fun mode) is inside. */\nfunction Cap({ x, edge, held, disabled, faceRef }: { x: MotionValue<number>; edge: \"from\" | \"to\"; held: boolean; disabled: boolean; faceRef: RefObject<HTMLSpanElement | null> }) {\n  return (\n    <motion.span aria-hidden=\"true\" className=\"absolute top-1/2 left-0 h-6 w-2 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize\" style={{ x }}>\n      <span className={\"absolute inset-0 transition-[translate] duration-(--litt-dur-instant) ease-litt \" + (held ? \"translate-y-(--litt-press-depth)\" : \"\")}>\n        <span\n          ref={faceRef}\n          data-cap={edge}\n          className={\n            \"absolute inset-0 rounded-[calc(var(--litt-radius-control)-2px)] forced-colors:border forced-colors:bg-[Highlight] \" +\n            (disabled ? \"bg-litt-surface-2 shadow-[inset_0_0_0_1px_var(--litt-line)]\" : held ? \"bg-litt-cap-held shadow-(--litt-cap-press)\" : \"bg-litt-cap shadow-(--litt-cap-lift)\")\n          }\n        >\n          <span className={\"absolute inset-y-2 left-1/2 w-px -translate-x-1/2 \" + (disabled ? \"bg-litt-line\" : \"bg-litt-cap-grip\")} />\n        </span>\n      </span>\n    </motion.span>\n  );\n}\n\nconst same = (p: Range, q: Range) => p[0] === q[0] && p[1] === q[1];\n\n/** A range of bars you brush by hand, read as one rolling figure. The edges follow the pointer 1:1 and settle on a bar or a segment start. */\nexport function RangeChart({\n  label,\n  bars,\n  segments = [],\n  axis,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  onValueCommit,\n  unit,\n  locale = \"en-GB\",\n  height = 96,\n  disabled = false,\n  className = \"\",\n}: RangeChartProps) {\n  const id = useId();\n  const labelId = `${id}-label`;\n  const n = bars.length;\n  const values = useMemo(() => bars.map((b) => b.value), [bars]);\n  const prefix = useMemo(() => cumulative(values), [values]);\n  const whole = prefix[n] ?? 0;\n  const [plotRef, plotW] = useWidth();\n  const geo = useMemo(() => plan(plotW, values), [plotW, values]);\n  const segs = useMemo(() => segmentsOf(segments, prefix, n), [segments, prefix, n]);\n  const starts = useMemo(() => segs.map((s) => s.start), [segs]);\n  const anchorsPx = useMemo(() => starts.map((s) => s * geo.perBar), [starts, geo.perBar]);\n  const { coarse } = useDevice();\n  const fun = useFun();\n  const responsive = useTransition(\"responsive\");\n  const glide = fun ? fun.glide : responsive;\n  const grabA = useFunGrab();\n  const grabB = useFunGrab();\n  const fmt = useMemo(() => new Intl.NumberFormat(locale), [locale]);\n  const separator = useMemo(() => groupSeparator(locale), [locale]);\n  const unitFor = (count: number) => (typeof unit === \"string\" ? unit : unit ? (count === 1 ? unit[0] : unit[1]) : \"\");\n\n  /* One source per fact: `range` is the value (the sliders, onValueChange), the two MotionValues are where the edges\n     are, and `shown` is the range the picture draws, read from them. The hand writes the MotionValues; React reads. */\n  const [inner, setInner] = useState<Range>(() => clampRange(defaultValue ?? [0, n - 1], n));\n  const range = clampRange(valueProp ?? inner, n);\n  const said = useRef(range);\n  const [shown, setShown] = useState(range);\n  const shownRef = useRef(shown);\n  const a = useMotionValue(0);\n  const b = useMotionValue(0);\n  const settle = useSettle([a, b]);\n  const hand = useHand();\n  const w = useMotionValue(1);\n  const fillScale = useTransform([a, b, w], ([x1, x2, pw]: number[]) => Math.max(0, x2 - x1) / pw);\n  /* True while a settle we asked for runs: the value was told at the start, and only the picture follows the edges. */\n  const quiet = useRef(false);\n  /* True while a new width or new data seats the edges. */\n  const seating = useRef(false);\n  const rects = useRef<(SVGRectElement | null)[]>([]);\n  const pops = useRef(new Map<number, AnimationPlaybackControls>());\n  const faceA = useRef<HTMLSpanElement>(null);\n  const faceB = useRef<HTMLSpanElement>(null);\n  const sliders = useRef<Record<\"from\" | \"to\" | \"move\", HTMLSpanElement | null>>({ from: null, to: null, move: null });\n  const [held, setHeld] = useState<\"start\" | \"end\" | \"both\" | null>(null);\n  const [status, setStatus] = useState(\"\");\n\n  const report = (next: Range) => {\n    if (same(next, said.current)) return;\n    said.current = next;\n    if (valueProp === undefined) setInner(next);\n    onValueChange?.(next);\n  };\n\n  /* A drawn bar is lit once an edge has passed its centre: for one bar per drawn bar, when it is in the range. */\n  const lit = (k: number, r: Range) => {\n    const centre = (k * geo.group + Math.min(n, (k + 1) * geo.group)) / 2;\n    return centre >= r[0] && centre <= r[1] + 1;\n  };\n\n  /* Fun mode: every drawn bar an edge swept rises from its base once. The inline transform goes when it lands, so the\n     rest frame is the one Calm draws. */\n  const pop = (prev: Range, next: Range) => {\n    if (!fun) return;\n    const g = geo.group;\n    const from = Math.floor(Math.min(prev[0], next[0]) / g);\n    const to = Math.floor(Math.max(prev[1], next[1]) / g);\n    for (let k = from; k <= to; k++) {\n      if (lit(k, prev) === lit(k, next)) continue;\n      const el = rects.current[k];\n      if (!el) continue;\n      const run = animate(el, { scaleY: [1.12, 1], originX: 0.5, originY: 1 }, fun.pop); // the origin goes through Motion too, or it resets it to the centre\n      pops.current.set(k, run);\n      void run.then(() => {\n        if (pops.current.get(k) !== run) return;\n        pops.current.delete(k);\n        el.style.transform = \"\";\n      });\n    }\n  };\n\n  const sync = () => {\n    if (seating.current || !geo.perBar || !n) return;\n    const next = rangeOf(a.get(), b.get(), geo, n);\n    const prev = shownRef.current;\n    if (same(next, prev)) return;\n    pop(prev, next);\n    shownRef.current = next;\n    setShown(next);\n    if (!quiet.current) report(next);\n  };\n  useMotionValueEvent(a, \"change\", sync);\n  useMotionValueEvent(b, \"change\", sync);\n\n  /* A new width or new data seats the edges where the picture is, with no animation. The range does not change, so\n     nothing is read from the edges until both are seated: one alone would read as a range, pop bars and report it. */\n  useLayoutEffect(() => {\n    w.set(Math.max(1, geo.plotW));\n    const [pa, pb] = edgesOf(shownRef.current, geo);\n    seating.current = true;\n    a.jump(pa);\n    b.jump(pb);\n    seating.current = false;\n  }, [geo, n]); // eslint-disable-line react-hooks/exhaustive-deps -- the MotionValues are stable\n\n  const wordsOf = (r: Range) => (!n ? \"\" : r[0] === r[1] ? bars[r[0]].label : `${bars[r[0]].label} to ${bars[r[1]].label}`);\n  const totalOf = (r: Range) => (n ? prefix[r[1] + 1] - prefix[r[0]] : 0);\n  const lineOf = (r: Range) => {\n    if (!n) return \"\";\n    const narrow = !(r[0] === 0 && r[1] === n - 1);\n    return narrow ? `${wordsOf(r)}, ${percent(whole > 0 ? totalOf(r) / whole : 0)} of the whole` : `All of ${wordsOf(r)}`;\n  };\n  const readingOf = (r: Range) => {\n    const t = Math.round(totalOf(r));\n    const u = unitFor(t);\n    return `${fmt.format(t)}${u ? ` ${u}` : \"\"}, ${lineOf(r)}`;\n  };\n\n  const landed = (r: Range) => {\n    onValueCommit?.(r);\n    setStatus(readingOf(r));\n  };\n\n  /** Keys, buttons, presses and a controlled value: tell the value now, then take the edges there and commit on arrival. */\n  const settleTo = (target: Range, commit: boolean) => {\n    report(target);\n    quiet.current = true;\n    settle.to(edgesOf(target, geo), {\n      commit: () => {\n        quiet.current = false;\n        if (commit) landed(target);\n      },\n    });\n  };\n\n  /* A controlled value we did not make moves the edges there. */\n  useEffect(() => {\n    if (same(range, said.current)) return;\n    said.current = range;\n    settleTo(range, false);\n  }, [range[0], range[1]]); // eslint-disable-line react-hooks/exhaustive-deps -- follows the value only\n\n  useEffect(() => () => pops.current.forEach((run) => run.stop()), []);\n\n  /* ------------------------------------------------------------------ the pointer */\n\n  const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {\n    if (disabled || !n || !geo.perBar) return;\n    e.preventDefault(); // keeps focus on the slider we pick, and no text selection\n    const box = e.currentTarget.getBoundingClientRect();\n    const x0 = e.clientX - box.left;\n    settle.hold(); // caught mid-settle: the edges are in the hand again, where they are\n    quiet.current = false;\n    const A = a.get();\n    const B = b.get();\n    const W = geo.plotW;\n    const step = geo.perBar;\n    const zone = zoneAt(x0, A, B, coarse ? GRAB_TOUCH : GRAB);\n    const grip = zone === \"start\" ? A - x0 : zone === \"end\" ? B - x0 : 0; // held where it was taken: no jump under the finger\n    const anchor = boundaryAt(x0, geo, n) * geo.perBar; // a new range starts at the bar edge nearest the press\n    let free: \"a\" | \"b\" = \"b\";\n    sliders.current[zone === \"end\" ? \"to\" : zone === \"body\" ? \"move\" : \"from\"]?.focus({ preventScroll: true });\n    setHeld(zone === \"start\" ? \"start\" : zone === \"end\" ? \"end\" : \"both\");\n\n    hand.begin(e, {\n      axis: \"x\",\n      onClaim: () => {\n        if (zone !== \"end\") grabA?.grab(faceA.current);\n        if (zone !== \"start\") grabB?.grab(faceB.current);\n      },\n      onMove: ({ dx }) => {\n        const x = x0 + dx;\n        if (zone === \"start\") a.set(settle.limit(x + grip, 0, B - step).shown);\n        else if (zone === \"end\") b.set(settle.limit(x + grip, A + step, W).shown);\n        else if (zone === \"body\") {\n          const s = settle.limit(A + dx, 0, W - (B - A)).shown;\n          a.set(s);\n          b.set(s + (B - A));\n        } else {\n          free = anchor <= 0 ? \"b\" : anchor >= W ? \"a\" : x >= anchor ? \"b\" : \"a\";\n          if (free === \"b\") {\n            a.set(anchor);\n            b.set(Math.max(anchor + step, settle.limit(x, 0, W).shown));\n          } else {\n            b.set(anchor);\n            a.set(Math.min(anchor - step, settle.limit(x, 0, W).shown));\n          }\n        }\n        grabA?.move(a.get());\n        grabB?.move(b.get());\n      },\n      onRelease: (r) => {\n        setHeld(null);\n        if (r.cancelled) {\n          settle.to([A, B]); // Escape or the page's scroll: back where it was, and nothing committed\n          return;\n        }\n        if (!r.claimed) {\n          press(zone, x0);\n          return;\n        }\n        const moved = zone === \"start\" ? \"a\" : zone === \"end\" ? \"b\" : zone === \"body\" ? \"both\" : free;\n        const speeds = moved === \"a\" ? [r.speed, 0] : moved === \"b\" ? [0, r.speed] : [r.speed, r.speed];\n        const still: [number, number] = zone === \"new\" ? [anchor, anchor] : [A, B];\n        let target = shownRef.current;\n        settle.release({\n          at: [a.get(), b.get()], // the shown edges, past an end if fun mode's rubber holds them there\n          speeds,\n          land: ({ points, thrown }) => {\n            const px = landEdges(moved, [points[0], points[1]], still, geo, anchorsPx, thrown);\n            target = rangeOf(px[0], px[1], geo, n);\n            report(target);\n            quiet.current = true;\n            return edgesOf(target, geo);\n          },\n          commit: () => {\n            quiet.current = false;\n            landed(target);\n          },\n        });\n      },\n    });\n  };\n\n  /** A press that never became a drag: outside the range the nearer edge goes to the bar edge under it. */\n  const press = (zone: Zone, x0: number) => {\n    const current = shownRef.current;\n    if (zone !== \"new\") {\n      settleTo(current, false); // a cap or the span, caught and let go: back to rest where it is\n      return;\n    }\n    const at = boundaryAt(x0, geo, n);\n    const next: Range = at <= current[0] ? [at, current[1]] : [current[0], Math.max(current[0], at - 1)];\n    if (same(next, current)) return;\n    settleTo(next, true);\n  };\n\n  /* ------------------------------------------------------------------ the keys */\n\n  const onKeyDown = (which: \"from\" | \"to\" | \"move\") => (e: KeyboardEvent<HTMLSpanElement>) => {\n    if (disabled || !n || !geo.perBar) return;\n    const [first, last] = said.current;\n    const to = last + 1; // the end as a boundary\n    const k = e.shiftKey ? 10 : 1;\n    const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));\n    const nextStart = (after: number) => starts.find((s) => s > after);\n    const prevStart = (before: number) => [...starts].reverse().find((s) => s < before);\n    const page = (from: number, dir: 1 | -1) => (starts.length ? (dir > 0 ? nextStart(from) : prevStart(from)) : from + dir * PAGE);\n    let next: Range | null = null;\n    if (e.key === \"Escape\") next = [0, n - 1];\n    else if (which === \"from\") {\n      let f: number | undefined;\n      if (e.key === \"ArrowRight\" || e.key === \"ArrowUp\") f = first + k;\n      else if (e.key === \"ArrowLeft\" || e.key === \"ArrowDown\") f = first - k;\n      else if (e.key === \"PageUp\") f = page(first, 1);\n      else if (e.key === \"PageDown\") f = page(first, -1);\n      else if (e.key === \"Home\") f = 0;\n      else if (e.key === \"End\") f = last;\n      if (f !== undefined) next = [clamp(f, 0, last), last];\n    } else if (which === \"to\") {\n      let t: number | undefined;\n      if (e.key === \"ArrowRight\" || e.key === \"ArrowUp\") t = to + k;\n      else if (e.key === \"ArrowLeft\" || e.key === \"ArrowDown\") t = to - k;\n      else if (e.key === \"PageUp\") t = page(to, 1);\n      else if (e.key === \"PageDown\") t = page(to, -1);\n      else if (e.key === \"Home\") t = first + 1;\n      else if (e.key === \"End\") t = n;\n      if (t !== undefined) next = [first, clamp(t, first + 1, n) - 1];\n    } else {\n      const width = last - first;\n      let f: number | undefined;\n      if (e.key === \"ArrowRight\" || e.key === \"ArrowUp\") f = first + k;\n      else if (e.key === \"ArrowLeft\" || e.key === \"ArrowDown\") f = first - k;\n      else if (e.key === \"PageUp\") f = page(first, 1);\n      else if (e.key === \"PageDown\") f = page(first, -1);\n      else if (e.key === \"Home\") f = 0;\n      else if (e.key === \"End\") f = n - 1 - width;\n      if (f !== undefined) {\n        f = clamp(f, 0, n - 1 - width);\n        next = [f, f + width];\n      }\n    }\n    if (next === null) return;\n    e.preventDefault();\n    if (same(next, said.current)) return;\n    settleTo(clampRange(next, n), true);\n  };\n\n  /* ------------------------------------------------------------------ the reading */\n\n  const total = totalOf(shown);\n  const narrowed = n > 0 && !(shown[0] === 0 && shown[1] === n - 1);\n  const unitWord = unitFor(Math.round(total));\n  const matched = segs.find((s) => s.start === shown[0] && s.end - 1 === shown[1]);\n  const summary = !n\n    ? label\n    : `${label}: ${bars[0].label} to ${bars[n - 1].label}, ${fmt.format(Math.round(whole))}${unitWord ? ` ${unitFor(Math.round(whole))}` : \"\"} in all` +\n      (segs.length ? `. ${segs.map((s) => `${s.label} ${percent(s.share)}`).join(\", \")}` : \"\");\n  const lead = segs.length ? segs[0].start : 0;\n  const columns = [lead > 0 ? `${lead}fr` : \"\", ...segs.map((s) => `${s.end - s.start}fr`)].filter(Boolean).join(\" \");\n  const words = disabled ? \"text-litt-fg-subtle\" : \"text-litt-fg-muted\";\n  const hint = starts.length ? \"Arrow keys move a bar. Page Up and Page Down go to the next and previous segment. Escape brushes the whole range.\" : \"Arrow keys move a bar. Escape brushes the whole range.\";\n\n  return (\n    <div role=\"group\" aria-labelledby={labelId} aria-disabled={disabled || undefined} className={\"grid gap-4 \" + className}>\n      <div className=\"flex items-start justify-between gap-4\">\n        <div className=\"grid min-w-0 gap-1\">\n          <span id={labelId} className={\"text-litt-small font-litt-title \" + (disabled ? \"text-litt-fg-disabled\" : \"text-litt-fg\")}>\n            {label}\n          </span>\n          <p aria-hidden=\"true\" className=\"flex items-baseline gap-2 text-litt-display tabular-nums\">\n            <span className=\"inline-flex\">\n              <Figure total={total} locale={locale} separator={separator} disabled={disabled} />\n            </span>\n            {unitWord && <span className=\"text-litt-small font-normal text-litt-fg-subtle\">{unitWord}</span>}\n          </p>\n          <p aria-hidden=\"true\" className={\"text-litt-small tabular-nums \" + words}>\n            {lineOf(shown)}\n          </p>\n          <span className=\"sr-only\">{readingOf(shown)}</span>\n        </div>\n        <Button variant=\"ghost\" size={coarse ? \"md\" : \"sm\"} disabled={disabled || !narrowed} onClick={() => settleTo([0, n - 1], true)} className=\"shrink-0\">\n          Whole range\n        </Button>\n      </div>\n\n      <div className=\"grid gap-2\">\n        {segs.length > 0 && (\n          <LayoutGroup id={id}>\n            <div\n              className=\"grid h-litt-control-sm rounded-litt-control bg-litt-tray p-0.5 shadow-(--litt-tray-shade) pointer-coarse:h-litt-control-md forced-colors:border\"\n              style={{ gridTemplateColumns: columns }}\n            >\n              {lead > 0 && <span aria-hidden=\"true\" />}\n              {segs.map((s) => {\n                const pressed = matched === s;\n                return (\n                  <button\n                    key={s.start}\n                    type=\"button\"\n                    disabled={disabled}\n                    aria-pressed={pressed}\n                    /* The words on the segment, in their order, so a voice command that says what it sees presses it\n                       (WCAG 2.5.3); it keeps the label when a narrow segment hides it. */\n                    aria-label={`${s.label} ${percent(s.share)}`}\n                    onClick={() => settleTo([s.start, s.end - 1], true)}\n                    className={\n                      \"@container litt-target relative flex h-full min-w-0 items-center justify-center rounded-[calc(var(--litt-radius-control)-2px)] text-litt-caption font-litt-title select-none \" +\n                      \"transition-colors duration-(--litt-dur-instant) ease-litt [-webkit-tap-highlight-color:transparent] \" +\n                      \"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-litt-focus \" +\n                      (disabled ? \"cursor-not-allowed text-litt-fg-disabled\" : pressed ? \"cursor-pointer text-litt-fg\" : \"cursor-pointer text-litt-fg-subtle hover:text-litt-fg \" + PRESS)\n                    }\n                  >\n                    {pressed && (\n                      <motion.span\n                        layoutId=\"segment\"\n                        transition={glide}\n                        aria-hidden=\"true\"\n                        className=\"absolute inset-0 rounded-[calc(var(--litt-radius-control)-2px)] bg-litt-block shadow-(--litt-block-lift) forced-colors:border-2\"\n                      />\n                    )}\n                    {/* The padding is inside the container, so the queries measure the whole segment and a narrow one\n                        gives up its padding before its share: a phone's selected block still says \"23%\". */}\n                    <span className=\"relative z-1 flex min-w-0 items-center gap-1.5 px-2 @max-[3rem]:px-0.5\">\n                      <span className=\"truncate @max-[7rem]:hidden\">{s.label}</span>{\" \"}\n                      <span className={\"shrink-0 font-normal tabular-nums @max-[2rem]:hidden \" + (disabled ? \"\" : \"text-litt-fg-subtle\")}>{percent(s.share)}</span>\n                    </span>\n                  </button>\n                );\n              })}\n            </div>\n          </LayoutGroup>\n        )}\n\n        <div\n          ref={plotRef}\n          data-no-drag=\"\"\n          onPointerDown={onPointerDown}\n          className={\"relative touch-pan-y select-none \" + (disabled ? \"cursor-not-allowed\" : \"cursor-crosshair\")}\n        >\n          <svg role=\"img\" aria-label={summary} height={height} className=\"block w-full\" shapeRendering=\"crispEdges\">\n            {geo.heights.map((h, k) => {\n              const hp = h > 0 ? Math.max(2, Math.round(h * height)) : 0;\n              if (!hp) return null;\n              const slot = geo.group * geo.perBar;\n              const x = Math.round(k * slot + (slot - geo.barW) / 2);\n              const on = lit(k, shown);\n              return (\n                <rect\n                  key={k}\n                  ref={(el) => {\n                    rects.current[k] = el;\n                  }}\n                  x={x}\n                  y={height - hp}\n                  width={geo.barW}\n                  height={hp}\n                  style={FROM_BASE}\n                  className={\n                    \"transition-[fill] duration-(--litt-dur-instant) ease-litt \" +\n                    (on\n                      ? (disabled ? \"fill-litt-fg-muted\" : \"fill-litt-fg\") + \" forced-colors:fill-[CanvasText]\"\n                      : \"fill-[color-mix(in_oklab,var(--litt-fg)_55%,transparent)] forced-colors:fill-[GrayText]\")\n                  }\n                />\n              );\n            })}\n          </svg>\n\n          <div className={\"relative h-8 pointer-coarse:h-11 \" + CAP_RING}>\n            <span\n              aria-hidden=\"true\"\n              className={\n                \"absolute inset-x-0 top-1/2 h-2 -translate-y-1/2 overflow-hidden rounded-[calc(var(--litt-radius-control)-2px)] forced-colors:border \" +\n                (disabled ? \"bg-litt-surface-3\" : \"bg-litt-slot shadow-(--litt-slot-shade)\")\n              }\n            >\n              <motion.span\n                className={\n                  \"absolute inset-y-0 left-0 w-full origin-left \" +\n                  (disabled ? \"bg-[color-mix(in_oklab,var(--litt-fg)_12%,transparent)]\" : \"cursor-grab bg-litt-slot-fill shadow-(--litt-slot-fill-shade) forced-colors:bg-[Highlight]\")\n                }\n                style={{ x: a, scaleX: fillScale }}\n              />\n            </span>\n            {anchorsPx.map((px, i) => (\n              <span key={i} aria-hidden=\"true\" className=\"absolute top-1/2 mt-1.5 h-1 w-px bg-litt-notch\" style={{ left: px }} />\n            ))}\n            <Cap x={a} edge=\"from\" held={held === \"start\" || held === \"both\"} disabled={disabled} faceRef={faceA} />\n            <Cap x={b} edge=\"to\" held={held === \"end\" || held === \"both\"} disabled={disabled} faceRef={faceB} />\n\n            {/* The three sliders: the whole rail moves the span, and each cap's box is one edge. They take no pointer: the plot does. */}\n            <span\n              ref={(el) => {\n                sliders.current.move = el;\n              }}\n              role=\"slider\"\n              tabIndex={disabled ? -1 : 0}\n              aria-label={`${label}, move range`}\n              aria-describedby={`${id}-hint`}\n              aria-valuemin={0}\n              aria-valuemax={Math.max(0, n - 1 - (range[1] - range[0]))}\n              aria-valuenow={range[0]}\n              aria-valuetext={wordsOf(range)}\n              aria-disabled={disabled || undefined}\n              onKeyDown={onKeyDown(\"move\")}\n              className={\"pointer-events-none absolute inset-0 rounded-litt-control \" + RING}\n            />\n            <motion.span\n              ref={(el) => {\n                sliders.current.from = el;\n              }}\n              role=\"slider\"\n              tabIndex={disabled ? -1 : 0}\n              aria-label={`${label}, from`}\n              aria-describedby={`${id}-hint`}\n              aria-valuemin={0}\n              aria-valuemax={range[1]}\n              aria-valuenow={range[0]}\n              aria-valuetext={n ? bars[range[0]].label : \"\"}\n              aria-disabled={disabled || undefined}\n              data-edge=\"from\"\n              onKeyDown={onKeyDown(\"from\")}\n              style={{ x: a }}\n              className={EDGE}\n            />\n            <motion.span\n              ref={(el) => {\n                sliders.current.to = el;\n              }}\n              role=\"slider\"\n              tabIndex={disabled ? -1 : 0}\n              aria-label={`${label}, to`}\n              aria-describedby={`${id}-hint`}\n              aria-valuemin={range[0]}\n              aria-valuemax={Math.max(0, n - 1)}\n              aria-valuenow={range[1]}\n              aria-valuetext={n ? bars[range[1]].label : \"\"}\n              aria-disabled={disabled || undefined}\n              data-edge=\"to\"\n              onKeyDown={onKeyDown(\"to\")}\n              style={{ x: b }}\n              className={EDGE}\n            />\n          </div>\n        </div>\n\n        <div aria-hidden=\"true\" className=\"relative h-4 text-litt-caption font-normal tabular-nums text-litt-fg-subtle\">\n          {axis\n            ? axis.map((t) => (\n                <span key={`${t.at}-${t.label}`} className=\"absolute top-0 whitespace-nowrap\" style={{ left: Math.max(0, Math.min(n, t.at)) * geo.perBar }}>\n                  {t.label}\n                </span>\n              ))\n            : n > 0 && (\n                <>\n                  <span className=\"absolute top-0 left-0 whitespace-nowrap\">{bars[0].label}</span>\n                  <span className=\"absolute top-0 right-0 whitespace-nowrap\">{bars[n - 1].label}</span>\n                </>\n              )}\n        </div>\n      </div>\n\n      <span id={`${id}-hint`} className=\"sr-only\">\n        {hint}\n      </span>\n      {/* A table will not shrink to sr-only's 1px, so a wrapper clips it; otherwise a long one widens a phone's page. */}\n      <div className=\"sr-only\">\n        <table className=\"[&_th]:font-normal\">\n          <caption>{label}</caption>\n          <thead>\n            <tr>\n              <th scope=\"col\">Bar</th>\n              <th scope=\"col\">{label}</th>\n              {segs.length > 0 && <th scope=\"col\">Segment</th>}\n            </tr>\n          </thead>\n          <tbody>\n            {bars.map((bar, i) => (\n              <tr key={i}>\n                <th scope=\"row\">{bar.label}</th>\n                <td>{fmt.format(bar.value)}</td>\n                {segs.length > 0 && <td>{segs.find((s) => i >= s.start && i < s.end)?.label ?? \"\"}</td>}\n              </tr>\n            ))}\n          </tbody>\n        </table>\n      </div>\n      <div role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {status}\n      </div>\n    </div>\n  );\n}\n",
      "target": "components/litt/range-chart.tsx"
    }
  ],
  "docs": "Import RangeChart from components/litt/range-chart. Give it a label, bars as { label, value }, and optionally segments as { label, start }, axis ticks and a unit. Hold the range in state with value and onValueChange ([first, last], both inclusive), or load on onValueCommit, which fires once the edges have settled. The figure is a sum, so the values must add up. A single moment in a run is a TimeScrubber; a value that stays is a Slider.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
