{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chain-scale",
  "title": "Chain Scale",
  "description": "A map zoom control that is the scale bar: drag its end handle in log space to zoom, and the bar's length always reads a sayable 1-2-5 round distance, snapping with a 180ms width spring at every ladder crossing instead of drawing an arbitrary raw number.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/chain-scale/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// ChainScale — a map zoom control that IS the scale bar. One governing\n// scalar, metersPerPixel, drives everything: the bar's rendered length is\n// denom/metersPerPixel, where denom is the LARGEST value from the 1-2-5\n// preferred series (…100 200 500 1000…) whose length still fits maxWidth.\n// The printed distance is therefore always a sayable round number, never the\n// raw drag value — because the formula is continuous in metersPerPixel while\n// the ladder is discrete, the bar keeps growing under the pointer with its\n// OLD label still attached, right up until the next rung overtakes it, at\n// which instant the denomination jumps and the bar visibly snaps to the new\n// fraction of maxWidth. That snap is the one thing worth animating: a 180ms\n// ease-out-expo width spring plus a 1px tick flash mark the detent, while\n// every other frame — ordinary dragging within one denomination — tracks\n// the pointer with zero added lag. Dragging maps handle dx to\n// metersPerPixel in LOG space (linear would make a city-to-street zoom take\n// a hundred screens of drag). Wheel over the control walks the same log\n// mapping; arrow keys step exactly one ladder rung (an inherent detent);\n// double-click resets to the nearest rung where the bar sits at 3/4 of\n// maxWidth. Only the animated pixel geometry (bar width, handle x, end\n// label x) is ever written imperatively via refs/rAF; block COUNT and fill\n// pattern are ordinary React state, since they only change at rung\n// granularity — so a crossing shows the new block count immediately while\n// the container that holds it is still springing to size. DOM + CSS only,\n// no canvas; every ink value is a CSS custom property, never a literal.\n// ---------------------------------------------------------------------------\n\nconst MANTISSAS = [1, 2, 5] as const;\nconst MIN_EXP = -2; // 0.01 m floor denomination\nconst MAX_EXP = 7; // 10,000,000 m ceiling denomination\nconst SPRING_MS = 180;\nconst WHEEL_LOG_STEP = 0.12;\nconst DRAG_LOG_PER_PX = 0.01;\nconst BAR_H = 10;\nconst HANDLE_W = 10;\nconst LABEL_ROW_TOP = BAR_H + 6;\n\ninterface Rung {\n  v: number; // denomination, in metres\n  mant: 1 | 2 | 5;\n}\n\nfunction buildLadder(): Rung[] {\n  const out: Rung[] = [];\n  for (let e = MIN_EXP; e <= MAX_EXP; e++) {\n    for (const mant of MANTISSAS) {\n      out.push({ v: Number((mant * Math.pow(10, e)).toPrecision(6)), mant });\n    }\n  }\n  return out;\n}\nconst LADDER = buildLadder();\n\nfunction blocksForMant(mant: 1 | 2 | 5): number {\n  return mant === 1 ? 2 : mant;\n}\n\nfunction formatShort(v: number): string {\n  if (v < 1) return `${Math.round(v * 100)} cm`;\n  if (v < 1000) return `${v} m`;\n  return `${v / 1000} km`;\n}\n\nfunction formatWords(v: number): string {\n  if (v < 1) return `${Math.round(v * 100)} centimetres`;\n  if (v < 1000) return `${v} metres`;\n  return `${v / 1000} kilometres`;\n}\n\nfunction formatPerPixel(m: number): string {\n  if (m < 1) return `${(m * 100).toFixed(0)} cm`;\n  if (m < 10) return `${m.toFixed(1)} m`;\n  return `${Math.round(m)} m`;\n}\n\n// largest rung whose bar (rung.v / m) still fits maxWidthPx — the whole\n// falsifiable contract in one function.\nfunction pickIndex(m: number, maxWidthPx: number): number {\n  let idx = 0;\n  for (let i = 0; i < LADDER.length; i++) {\n    if (LADDER[i].v / m <= maxWidthPx) idx = i;\n    else break;\n  }\n  return idx;\n}\n\nfunction easeOutExpo(t: number): number {\n  return t >= 1 ? 1 : 1 - Math.pow(2, -10 * t);\n}\n\nexport interface ChainScaleProps {\n  /** controlled metres-per-pixel; omit for uncontrolled */\n  metersPerPixel?: number;\n  /** uncontrolled initial metres-per-pixel */\n  defaultMetersPerPixel?: number;\n  /** most-zoomed-in bound (smallest metersPerPixel) */\n  minMetersPerPixel?: number;\n  /** most-zoomed-out bound (largest metersPerPixel) */\n  maxMetersPerPixel?: number;\n  /** pixel budget the bar is never allowed to exceed */\n  maxWidth?: number;\n  /** accessible name */\n  label?: string;\n  /** stable id; the live description renders at `${id}-desc` so a host map\n   *  region can point its own aria-describedby at it */\n  id?: string;\n  /** fires with the new metres-per-pixel on every change — wire this to a map's zoom */\n  onValueChange?: (metersPerPixel: number) => void;\n  /** extra classes merged onto the root element */\n  className?: string;\n}\n\nexport function ChainScale({\n  metersPerPixel,\n  defaultMetersPerPixel = 1,\n  minMetersPerPixel = 0.02,\n  maxMetersPerPixel = 20000,\n  maxWidth = 200,\n  label = \"Map scale\",\n  id,\n  onValueChange,\n  className = \"\",\n}: ChainScaleProps) {\n  const uid = useId().replace(/[^a-zA-Z0-9]/g, \"\");\n  const descId = id ? `${id}-desc` : `chain-scale-desc-${uid}`;\n\n  const clamp = (v: number) =>\n    Math.min(maxMetersPerPixel, Math.max(minMetersPerPixel, v));\n\n  const isControlled = metersPerPixel !== undefined;\n  const [internal, setInternal] = useState(() => clamp(defaultMetersPerPixel));\n  const m = isControlled ? clamp(metersPerPixel as number) : internal;\n  const mRef = useRef(m);\n  mRef.current = m;\n\n  const commitRef = useRef<(v: number) => void>(() => {});\n  commitRef.current = (v: number) => {\n    const c = clamp(v);\n    if (!isControlled) setInternal(c);\n    onValueChange?.(c);\n  };\n\n  const index = useMemo(() => pickIndex(m, maxWidth), [m, maxWidth]);\n  // pickIndex(m, .) is monotonic in m, so the reachable index band is just\n  // the two clamp bounds run through it — the full LADDER is padded far\n  // past what min/maxMetersPerPixel can ever select, so those raw bounds\n  // would advertise a range the slider can't actually reach.\n  const minIndex = useMemo(\n    () => pickIndex(minMetersPerPixel, maxWidth),\n    [minMetersPerPixel, maxWidth]\n  );\n  const maxIndex = useMemo(\n    () => pickIndex(maxMetersPerPixel, maxWidth),\n    [maxMetersPerPixel, maxWidth]\n  );\n  const rung = LADDER[index];\n  const blocks = blocksForMant(rung.mant);\n  const shortLabel = formatShort(rung.v);\n  const wordsLabel = formatWords(rung.v);\n  const perPixel = formatPerPixel(m);\n  const sentence = `Scale: ${wordsLabel}. 1 pixel equals ${perPixel}.`;\n  const valueText = `${shortLabel} — 1px = ${perPixel}`;\n\n  const trackRef = useRef<HTMLDivElement>(null);\n  const barRef = useRef<HTMLDivElement>(null);\n  const handleRef = useRef<HTMLDivElement>(null);\n  const endLabelRef = useRef<HTMLDivElement>(null);\n  const tickRef = useRef<HTMLDivElement>(null);\n  const indexRef = useRef(index);\n\n  const engineRef = useRef<{\n    applyFromM: (mv: number) => void;\n    dragStart: (clientX: number) => void;\n    dragMove: (clientX: number) => void;\n    dragEnd: () => void;\n  } | null>(null);\n\n  // -- imperative geometry engine: direct DOM writes, refs only ------------\n  useLayoutEffect(() => {\n    const track = trackRef.current;\n    const bar = barRef.current;\n    const handle = handleRef.current;\n    const endLabel = endLabelRef.current;\n    const tick = tickRef.current;\n    if (!track || !bar || !handle || !endLabel || !tick) return;\n\n    const reduced = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n\n    let visWidth = Math.min(\n      maxWidth,\n      LADDER[indexRef.current].v / mRef.current\n    );\n    let targetWidth = visWidth;\n    let raf = 0;\n    let tweenFrom = visWidth;\n    let tweenStart = 0;\n    let springing = false;\n    let dragging = false;\n    let dragStartClientX = 0;\n    let dragStartLogM = 0;\n\n    const place = (px: number) => {\n      visWidth = px;\n      bar.style.width = `${px}px`;\n      handle.style.transform = `translate3d(${(px - HANDLE_W / 2).toFixed(2)}px,0,0)`;\n      endLabel.style.transform = `translate3d(${px.toFixed(2)}px,0,0) translateX(-100%)`;\n    };\n    place(visWidth);\n\n    const flashTick = (px: number) => {\n      tick.style.left = `${px.toFixed(2)}px`;\n      tick.style.animation = \"none\";\n      void tick.offsetWidth; // force reflow so re-adding the class restarts it\n      tick.style.animation = \"\";\n    };\n\n    const loop = (now: number) => {\n      raf = 0;\n      const t = Math.min(1, (now - tweenStart) / SPRING_MS);\n      place(tweenFrom + (targetWidth - tweenFrom) * easeOutExpo(t));\n      if (t < 1) raf = requestAnimationFrame(loop);\n      else springing = false;\n    };\n    const wake = () => {\n      if (!raf) raf = requestAnimationFrame(loop);\n    };\n\n    const applyFromM = (mv: number) => {\n      const idx = pickIndex(mv, maxWidth);\n      const crossed = idx !== indexRef.current;\n      indexRef.current = idx;\n      const target = Math.min(maxWidth, LADDER[idx].v / mv);\n      targetWidth = target;\n      if (reduced) {\n        cancelAnimationFrame(raf);\n        raf = 0;\n        springing = false;\n        place(target);\n        return;\n      }\n      if (crossed) {\n        tweenFrom = visWidth;\n        tweenStart = performance.now();\n        springing = true;\n        flashTick(target);\n        wake();\n      } else if (!springing) {\n        place(target);\n      }\n    };\n\n    const dragMove = (clientX: number) => {\n      if (!dragging) return;\n      const dx = clientX - dragStartClientX;\n      const nextM = clamp(Math.exp(dragStartLogM - dx * DRAG_LOG_PER_PX));\n      applyFromM(nextM);\n      commitRef.current(nextM);\n    };\n\n    const dragStart = (clientX: number) => {\n      dragging = true;\n      dragStartClientX = clientX;\n      dragStartLogM = Math.log(mRef.current);\n    };\n\n    const dragEnd = () => {\n      dragging = false;\n    };\n\n    // React binds `wheel` passively on the root, so a synthetic onWheel\n    // prop can never preventDefault (Chrome logs and the page scrolls\n    // under the drag) — bind a real non-passive listener here instead.\n    const onWheelNative = (e: WheelEvent) => {\n      e.preventDefault();\n      const dir = e.deltaY > 0 ? 1 : -1; // scroll down -> zoom out\n      const nextM = clamp(\n        Math.exp(Math.log(mRef.current) + dir * WHEEL_LOG_STEP)\n      );\n      applyFromM(nextM);\n      commitRef.current(nextM);\n    };\n    track.addEventListener(\"wheel\", onWheelNative, { passive: false });\n\n    engineRef.current = { applyFromM, dragStart, dragMove, dragEnd };\n\n    return () => {\n      cancelAnimationFrame(raf);\n      track.removeEventListener(\"wheel\", onWheelNative);\n      engineRef.current = null;\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [maxWidth, minMetersPerPixel, maxMetersPerPixel]);\n\n  useLayoutEffect(() => {\n    engineRef.current?.applyFromM(m);\n  }, [m]);\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    switch (e.key) {\n      case \"ArrowRight\":\n      case \"ArrowUp\": {\n        e.preventDefault();\n        const nextIndex = Math.min(LADDER.length - 1, index + 1);\n        // *(1+1e-9): landing exactly on v/maxWidth is a round-trip\n        // division that pickIndex re-derives from m — floating-point\n        // noise can land it one ULP over and read back the rung BELOW,\n        // so the arrow silently no-ops at certain widths. Nudge just\n        // inside the rung; ladder steps are all >=1.6x apart so this\n        // never bleeds into a neighbor.\n        const nextM = clamp((LADDER[nextIndex].v / maxWidth) * (1 + 1e-9));\n        if (nextM !== mRef.current) commitRef.current(nextM);\n        return;\n      }\n      case \"ArrowLeft\":\n      case \"ArrowDown\": {\n        e.preventDefault();\n        const nextIndex = Math.max(0, index - 1);\n        const nextM = clamp((LADDER[nextIndex].v / maxWidth) * (1 + 1e-9));\n        if (nextM !== mRef.current) commitRef.current(nextM);\n        return;\n      }\n      case \"Home\":\n        // jump to the min/max SCALE (the props), not the full 0.01m-10Mm\n        // ladder — the ladder is padded far past what min/maxMetersPerPixel\n        // can ever reach, so indexing into it directly would advertise and\n        // land on a rung the control can never otherwise show.\n        e.preventDefault();\n        if (minMetersPerPixel !== mRef.current)\n          commitRef.current(minMetersPerPixel);\n        return;\n      case \"End\":\n        e.preventDefault();\n        if (maxMetersPerPixel !== mRef.current)\n          commitRef.current(maxMetersPerPixel);\n        return;\n      default:\n        return;\n    }\n  };\n\n  const onDoubleClick = () => {\n    const nextM = clamp(rung.v / (0.75 * maxWidth));\n    commitRef.current(nextM);\n  };\n\n  const onPointerDown = (e: React.PointerEvent) => {\n    e.preventDefault();\n    trackRef.current?.setPointerCapture(e.pointerId);\n    trackRef.current?.focus({ preventScroll: true });\n    engineRef.current?.dragStart(e.clientX);\n  };\n  const onPointerMove = (e: React.PointerEvent) => {\n    engineRef.current?.dragMove(e.clientX);\n  };\n  const onPointerUp = () => engineRef.current?.dragEnd();\n  const onPointerCancel = () => engineRef.current?.dragEnd();\n\n  return (\n    <div\n      className={`inline-flex flex-col items-start gap-2 rounded-md border border-border bg-background p-3 font-mono ${className}`}\n    >\n      <style>{`\n.chain-scale-slider:hover .chain-scale-handle,\n.chain-scale-slider:focus-visible .chain-scale-handle,\n.chain-scale-slider:active .chain-scale-handle {\n  background-color: var(--ns-accent);\n  border-color: var(--ns-accent);\n}\n.chain-scale-handle {\n  transition: background-color 150ms ease-out, border-color 150ms ease-out;\n}\n.chain-scale-tick {\n  animation: chain-scale-tick-flash 380ms ease-out;\n}\n@keyframes chain-scale-tick-flash {\n  0% { opacity: 1; }\n  100% { opacity: 0; }\n}\n@media (prefers-reduced-motion: reduce) {\n  .chain-scale-tick { animation: none !important; opacity: 0 !important; }\n  .chain-scale-handle { transition: none !important; }\n}\n`}</style>\n\n      <span className=\"select-none text-[10px] uppercase tracking-[0.2em] text-ns-muted\">\n        {label}\n      </span>\n\n      <div\n        ref={trackRef}\n        role=\"slider\"\n        tabIndex={0}\n        aria-label={label}\n        aria-orientation=\"horizontal\"\n        aria-valuemin={minIndex}\n        aria-valuemax={maxIndex}\n        aria-valuenow={index}\n        aria-valuetext={valueText}\n        aria-describedby={descId}\n        data-chain-scale-slider\n        onKeyDown={onKeyDown}\n        onDoubleClick={onDoubleClick}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={onPointerUp}\n        onPointerCancel={onPointerCancel}\n        className=\"chain-scale-slider relative block cursor-ew-resize touch-none select-none rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ns-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n        style={{ width: maxWidth, height: LABEL_ROW_TOP + 14 }}\n      >\n        {/* checkerboard bar — presentational; the wrapper above carries the value */}\n        <div\n          ref={barRef}\n          aria-hidden\n          className=\"absolute left-0 top-0 flex overflow-hidden rounded-[2px] border border-border\"\n          style={{ height: BAR_H }}\n        >\n          {Array.from({ length: blocks }).map((_, i) => (\n            <div\n              key={i}\n              className=\"h-full flex-1\"\n              style={{\n                backgroundColor: i % 2 === 0 ? \"var(--foreground)\" : \"transparent\",\n                borderRight: i < blocks - 1 ? \"1px solid var(--border)\" : \"none\",\n              }}\n            />\n          ))}\n        </div>\n\n        {/* detent tick — flashes on each ladder crossing, positioned by the engine */}\n        <div\n          ref={tickRef}\n          aria-hidden\n          className=\"chain-scale-tick absolute w-px bg-foreground opacity-0\"\n          style={{ top: -2, height: BAR_H + 4 }}\n        />\n\n        {/* \"0\" — the only numeral besides the end label; no interior numerals */}\n        <span\n          aria-hidden\n          className=\"absolute select-none text-[10px] text-ns-muted\"\n          style={{ left: 0, top: LABEL_ROW_TOP }}\n        >\n          0\n        </span>\n\n        {/* end label — tracks the animated bar edge via the engine */}\n        <div\n          ref={endLabelRef}\n          aria-hidden\n          className=\"absolute select-none whitespace-nowrap text-[10px] text-foreground\"\n          style={{ top: LABEL_ROW_TOP }}\n        >\n          {shortLabel}\n        </div>\n\n        {/* grab handle — the only element allowed --ns-accent, and only on interaction */}\n        <div\n          ref={handleRef}\n          aria-hidden\n          className=\"chain-scale-handle absolute rounded-full border border-foreground bg-background\"\n          style={{ top: -2, width: HANDLE_W, height: BAR_H + 4 }}\n        />\n      </div>\n\n      <p id={descId} aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {sentence}\n      </p>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/chain-scale.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "slider",
      "map",
      "zoom",
      "scale-bar",
      "cartography",
      "drag",
      "form",
      "micro-interaction"
    ],
    "instruction": "Build a scale bar that doubles as the zoom control, governed by one scalar, metersPerPixel (m). At any m, the bar's rendered length is denom/m, where denom is the LARGEST value from the 1-2-5 preferred series (…0.5 1 2 5 10 20 50 100 200 500 1000…, spanning roughly 1cm to 10,000km) whose length still fits a maxWidth prop — pickIndex(m, maxWidth) walks the ascending ladder and keeps the last rung whose v/m <= maxWidth. This is the falsifiable contract: the printed distance is ALWAYS a 1-2-5 round number, never the raw drag value. Because the formula is continuous in m while the ladder is discrete, holding denom fixed and changing m makes the bar's pixel length grow or shrink continuously (following the pointer with zero added lag), right up until the next rung overtakes it — at that instant denom jumps and the bar visibly snaps to the new fraction of maxWidth. Animate exactly that snap and nothing else: on a ladder crossing, ease the bar's width from its pre-crossing pixel length to the new target over 180ms ease-out-expo (a cubic tween written directly to refs via rAF, restarting from whatever the visual width currently is if a new crossing arrives mid-tween), flash a 1px --foreground tick at the new edge as the detent mark, and slide the round-number label out/in. Within a single denomination, width/handle/label positions are set directly via refs with NO easing — only React re-renders for the checkerboard's block COUNT and fill pattern, which are ordinary state driven by the denomination's leading digit (mantissa 1 -> 2 blocks, 2 -> 2 blocks, 5 -> 5 blocks), alternating --foreground fill and transparent, 1px --border rules between them, no interior numerals — only '0' at the left and the current denomination's label at the right, in Geist Mono. Never let React's render-time style props touch the animated width/x values (that fight is what makes the spring flash and this is the exact anti-pattern to avoid); the wrapper div carrying role=slider is the ONLY place the drag/keyboard/wheel handlers live, and the checkerboard/tick/label/handle beneath it are all aria-hidden presentation. INTERACTION: pointerdown on the wrapper starts a drag that maps handle dx to metersPerPixel in LOG space (m = clamp(exp(startLogM - dx*k))) — never linear, or a city-to-street zoom would take a hundred screens of drag; wheel over the wrapper walks the same log mapping one WHEEL_LOG_STEP per notch; ArrowLeft/Right/Up/Down step exactly one ladder rung by setting m so the new rung sits at exactly maxWidth (an inherent detent, not a quantized continuous value); Home/End jump to the ladder's min/max rung; double-click resets m so the CURRENT rung's bar sits at exactly 3/4 of maxWidth. ACCESSIBILITY: the wrapper is role=slider tabIndex=0 with aria-valuemin/max as the ladder's index bounds, aria-valuenow the current rung's index, and aria-valuetext combining the denomination and the raw per-pixel reading (e.g. '500 m — 1px = 3.2 m'); aria-describedby points at a visually-hidden aria-live=polite sentence ('Scale: 500 metres. 1 pixel equals 3.2 metres.') rendered at a stable `${id}-desc` id so a host map region's own aria-describedby can reference the same live text; prefers-reduced-motion cancels the width spring and tick flash entirely, snapping straight to the new geometry. Zero dependencies, DOM + CSS only — no canvas, no SVG required for the geometry (plain divs), no hex or Tailwind palette colors anywhere in markup or inline style."
  },
  "type": "registry:ui"
}