{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "minimap-pantograph",
  "title": "Minimap Pantograph",
  "description": "A minimap whose viewport rectangle is physically wired to the real viewport by a live SVG pantograph (two elbow arms solved by two-bar IK every frame), so dragging the small handle visibly swings and extends the linkage as the document pans, making the scale ratio legible instead of implied.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/minimap-pantograph/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, type ReactNode } from \"react\";\n\n// ScissorReach — a minimap whose viewport rectangle is physically wired to\n// the real viewport by a live SVG pantograph. The minimap is a scaled clone\n// of the same content (CSS transform: scale, never re-measured — width and\n// height ratios are derived once from the fixed contentWidth/contentHeight\n// props). Between the minimap's viewport rect and the real viewport's\n// top-left corner, two mirrored 2-bar elbow arms are solved every frame by\n// trivial IK from the two live anchor points; only the elbow (joint)\n// springs — the anchors themselves track their DOM elements exactly, never\n// lagging, so the linkage always reads as physically attached rather than\n// floating. Direct-DOM rAF, refs only on the hot path, sleeps when the\n// spring settles and dragging has stopped.\n\ntype Vec = { x: number; y: number };\n\nconst BOW = 26; // px — arm reach exceeds half the anchor span by this much\nconst SPRING_K = 200; // s^-2\nconst SPRING_ZETA = 0.8; // slightly underdamped -> a hair of catch-up snap\nconst SPRING_C = 2 * SPRING_ZETA * Math.sqrt(SPRING_K);\nconst SETTLE_EPS = 0.15; // px\nconst SETTLE_V_EPS = 2; // px/s\nconst IDLE_MS = 700; // idle relax delay before the linkage fades to 0.3\nconst STEP_X = 32; // px per ArrowLeft/Right, content space\n\nfunction clamp(v: number, lo: number, hi: number) {\n  return Math.min(hi, Math.max(lo, v));\n}\n\n// Two equal-length links whose combined reach is fixed at (d/2 + bow) for\n// the current anchor distance d — always solvable (no negative sqrt), and\n// the bow shrinks as a fraction of d as the anchors pull apart, which reads\n// as \"the linkage extends and flattens\" rather than a floppy fixed arm.\nfunction twoBarElbow(a: Vec, b: Vec, bow: number) {\n  const dx = b.x - a.x;\n  const dy = b.y - a.y;\n  const d = Math.max(1e-4, Math.hypot(dx, dy));\n  const link = d / 2 + bow;\n  const h = Math.sqrt(Math.max(0, link * link - (d / 2) * (d / 2)));\n  const mx = (a.x + b.x) / 2;\n  const my = (a.y + b.y) / 2;\n  const nx = -dy / d;\n  const ny = dx / d;\n  return {\n    up: { x: mx + nx * h, y: my + ny * h },\n    down: { x: mx - nx * h, y: my - ny * h },\n  };\n}\n\nexport function ScissorReach({\n  children,\n  contentWidth,\n  contentHeight,\n  minimapWidth = 140,\n  minimapHeight = 220,\n  viewportHeight = 340,\n  rowHeight = 16,\n  unitLabel = \"rows\",\n  className = \"\",\n  \"aria-label\": ariaLabel = \"Document position\",\n}: {\n  /** the full content — size it so it renders at exactly contentWidth x contentHeight */\n  children: ReactNode;\n  /** logical content extent in px that `children` occupies */\n  contentWidth: number;\n  /** logical content height in px that `children` occupies */\n  contentHeight: number;\n  /** minimap panel size in px — independent of content aspect ratio, like a scrollbar rail */\n  minimapWidth?: number;\n  /** minimap panel height in px */\n  minimapHeight?: number;\n  /** fixed viewport pane height in px */\n  viewportHeight?: number;\n  /** row height in px — used only to phrase the aria-valuetext readout */\n  rowHeight?: number;\n  /** unit noun appended to the aria-valuetext readout, e.g. \"rows\" */\n  unitLabel?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n  /** accessible name for the minimap. Default \"Document position\". */\n  \"aria-label\"?: string;\n}) {\n  const containerRef = useRef<HTMLDivElement | null>(null);\n  const trackRef = useRef<HTMLDivElement | null>(null);\n  const handleRef = useRef<HTMLDivElement | null>(null);\n  const viewportRef = useRef<HTMLDivElement | null>(null);\n  const groupRef = useRef<SVGGElement | null>(null);\n  const lineRefs = useRef<(SVGLineElement | null)[]>([]);\n  const circleRefs = useRef<(SVGCircleElement | null)[]>([]);\n\n  const totalRows = Math.max(1, Math.round(contentHeight / rowHeight));\n  const initialEndRow = Math.min(totalRows, Math.ceil(viewportHeight / rowHeight));\n\n  useEffect(() => {\n    const container = containerRef.current;\n    const track = trackRef.current;\n    const handle = handleRef.current;\n    const viewport = viewportRef.current;\n    const group = groupRef.current;\n    if (!container || !track || !handle || !viewport || !group) return;\n\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n    const scaleX = minimapWidth / contentWidth;\n    const scaleY = minimapHeight / contentHeight;\n\n    let vpW = viewport.clientWidth;\n    let vpH = viewport.clientHeight;\n\n    let dragging = false;\n    let dragStartX = 0;\n    let dragStartY = 0;\n    let dragScrollLeft = 0;\n    let dragScrollTop = 0;\n\n    let upPos: Vec = { x: 0, y: 0 };\n    let upVel: Vec = { x: 0, y: 0 };\n    let downPos: Vec = { x: 0, y: 0 };\n    let downVel: Vec = { x: 0, y: 0 };\n    let primed = false;\n\n    let raf = 0;\n    let last = 0;\n    let idleTimer: ReturnType<typeof setTimeout> | null = null;\n\n    const maxTop = () => Math.max(0, contentHeight - vpH);\n    const maxLeft = () => Math.max(0, contentWidth - vpW);\n\n    const markActive = () => {\n      group.style.opacity = \"1\";\n      if (idleTimer) clearTimeout(idleTimer);\n      idleTimer = setTimeout(() => {\n        group.style.opacity = \"0.3\";\n      }, IDLE_MS);\n    };\n\n    // returns true once settled (always true immediately under reduced motion)\n    const springTo = (pos: Vec, vel: Vec, target: Vec, dt: number) => {\n      if (reduced) {\n        pos.x = target.x;\n        pos.y = target.y;\n        vel.x = 0;\n        vel.y = 0;\n        return true;\n      }\n      vel.x += (SPRING_K * (target.x - pos.x) - SPRING_C * vel.x) * dt;\n      vel.y += (SPRING_K * (target.y - pos.y) - SPRING_C * vel.y) * dt;\n      pos.x += vel.x * dt;\n      pos.y += vel.y * dt;\n      const dErr = Math.hypot(target.x - pos.x, target.y - pos.y);\n      const vErr = Math.hypot(vel.x, vel.y);\n      return dErr < SETTLE_EPS && vErr < SETTLE_V_EPS;\n    };\n\n    const frame = (now: number) => {\n      raf = 0;\n      const dt = last ? clamp((now - last) / 1000, 0.001, 1 / 30) : 1 / 60;\n      last = now;\n\n      const scrollTop = clamp(viewport.scrollTop, 0, maxTop());\n      const scrollLeft = clamp(viewport.scrollLeft, 0, maxLeft());\n\n      const rectW = Math.min(minimapWidth, Math.max(4, vpW * scaleX));\n      const rectH = Math.min(minimapHeight, Math.max(4, vpH * scaleY));\n      const rectLeft = clamp(scrollLeft * scaleX, 0, Math.max(0, minimapWidth - rectW));\n      const rectTop = clamp(scrollTop * scaleY, 0, Math.max(0, minimapHeight - rectH));\n\n      handle.style.left = `${rectLeft}px`;\n      handle.style.top = `${rectTop}px`;\n      handle.style.width = `${rectW}px`;\n      handle.style.height = `${rectH}px`;\n\n      const trackBox = track.getBoundingClientRect();\n      const containerBox = container.getBoundingClientRect();\n      const viewportBox = viewport.getBoundingClientRect();\n      const originX = trackBox.left - containerBox.left;\n      const originY = trackBox.top - containerBox.top;\n\n      // A: the rect's near corner (moves with the drag). B: the real\n      // viewport's fixed corner. Neither is ever sprung — only the elbow\n      // joints between them are, so the linkage always looks attached.\n      const a: Vec = { x: originX + rectLeft + rectW, y: originY + rectTop };\n      const b: Vec = { x: viewportBox.left - containerBox.left, y: viewportBox.top - containerBox.top };\n\n      const target = twoBarElbow(a, b, BOW);\n      if (!primed) {\n        upPos = { ...target.up };\n        downPos = { ...target.down };\n        primed = true;\n      }\n      const settledUp = springTo(upPos, upVel, target.up, dt);\n      const settledDown = springTo(downPos, downVel, target.down, dt);\n\n      const segs = lineRefs.current;\n      const write = (el: SVGLineElement | null | undefined, p1: Vec, p2: Vec) => {\n        if (!el) return;\n        el.setAttribute(\"x1\", String(p1.x));\n        el.setAttribute(\"y1\", String(p1.y));\n        el.setAttribute(\"x2\", String(p2.x));\n        el.setAttribute(\"y2\", String(p2.y));\n      };\n      write(segs[0], a, upPos);\n      write(segs[1], upPos, b);\n      write(segs[2], a, downPos);\n      write(segs[3], downPos, b);\n\n      const pts = [a, upPos, downPos, b];\n      circleRefs.current.forEach((c, i) => {\n        const p = pts[i];\n        if (c && p) {\n          c.setAttribute(\"cx\", String(p.x));\n          c.setAttribute(\"cy\", String(p.y));\n        }\n      });\n\n      const startRow = Math.floor(scrollTop / rowHeight) + 1;\n      const endRow = Math.min(totalRows, Math.ceil((scrollTop + vpH) / rowHeight));\n      handle.setAttribute(\"aria-valuenow\", String(startRow));\n      handle.setAttribute(\n        \"aria-valuetext\",\n        `viewing ${unitLabel} ${startRow}-${endRow} of ${totalRows}`\n      );\n\n      const active = dragging || !settledUp || !settledDown;\n      if (active) raf = requestAnimationFrame(frame);\n    };\n\n    const wake = () => {\n      markActive();\n      if (!raf) {\n        last = 0;\n        raf = requestAnimationFrame(frame);\n      }\n    };\n\n    const onPointerDown = (e: PointerEvent) => {\n      e.preventDefault();\n      track.setPointerCapture(e.pointerId);\n      handle.focus({ preventScroll: true });\n      dragging = true;\n      dragStartX = e.clientX;\n      dragStartY = e.clientY;\n      dragScrollLeft = viewport.scrollLeft;\n      dragScrollTop = viewport.scrollTop;\n      wake();\n    };\n    const onPointerMove = (e: PointerEvent) => {\n      if (!dragging) return;\n      const dxContent = (e.clientX - dragStartX) / scaleX;\n      const dyContent = (e.clientY - dragStartY) / scaleY;\n      viewport.scrollLeft = clamp(dragScrollLeft + dxContent, 0, maxLeft());\n      viewport.scrollTop = clamp(dragScrollTop + dyContent, 0, maxTop());\n      wake();\n    };\n    const endDrag = (e: PointerEvent) => {\n      if (!dragging) return;\n      dragging = false;\n      try {\n        track.releasePointerCapture(e.pointerId);\n      } catch {\n        // synthetic pointerId during autoplay — nothing to release\n      }\n      wake();\n    };\n\n    const onKeyDown = (e: KeyboardEvent) => {\n      let handled = true;\n      switch (e.key) {\n        case \"ArrowDown\":\n          viewport.scrollTop = clamp(viewport.scrollTop + rowHeight, 0, maxTop());\n          break;\n        case \"ArrowUp\":\n          viewport.scrollTop = clamp(viewport.scrollTop - rowHeight, 0, maxTop());\n          break;\n        case \"ArrowRight\":\n          viewport.scrollLeft = clamp(viewport.scrollLeft + STEP_X, 0, maxLeft());\n          break;\n        case \"ArrowLeft\":\n          viewport.scrollLeft = clamp(viewport.scrollLeft - STEP_X, 0, maxLeft());\n          break;\n        case \"PageDown\":\n          viewport.scrollTop = clamp(viewport.scrollTop + vpH * 0.9, 0, maxTop());\n          break;\n        case \"PageUp\":\n          viewport.scrollTop = clamp(viewport.scrollTop - vpH * 0.9, 0, maxTop());\n          break;\n        case \"Home\":\n          viewport.scrollTop = 0;\n          break;\n        case \"End\":\n          viewport.scrollTop = maxTop();\n          break;\n        default:\n          handled = false;\n      }\n      if (handled) {\n        e.preventDefault();\n        wake();\n      }\n    };\n\n    const onScroll = () => wake();\n\n    const ro = new ResizeObserver(() => {\n      vpW = viewport.clientWidth;\n      vpH = viewport.clientHeight;\n      wake();\n    });\n    ro.observe(viewport);\n\n    track.addEventListener(\"pointerdown\", onPointerDown);\n    track.addEventListener(\"pointermove\", onPointerMove);\n    track.addEventListener(\"pointerup\", endDrag);\n    track.addEventListener(\"pointercancel\", endDrag);\n    handle.addEventListener(\"keydown\", onKeyDown);\n    viewport.addEventListener(\"scroll\", onScroll, { passive: true });\n\n    wake();\n\n    return () => {\n      cancelAnimationFrame(raf);\n      if (idleTimer) clearTimeout(idleTimer);\n      ro.disconnect();\n      track.removeEventListener(\"pointerdown\", onPointerDown);\n      track.removeEventListener(\"pointermove\", onPointerMove);\n      track.removeEventListener(\"pointerup\", endDrag);\n      track.removeEventListener(\"pointercancel\", endDrag);\n      handle.removeEventListener(\"keydown\", onKeyDown);\n      viewport.removeEventListener(\"scroll\", onScroll);\n    };\n  }, [contentWidth, contentHeight, minimapWidth, minimapHeight, viewportHeight, rowHeight, unitLabel, totalRows]);\n\n  return (\n    <div ref={containerRef} className={`relative flex items-start gap-24 ${className}`}>\n      <div\n        ref={trackRef}\n        data-scissor-track\n        className=\"relative shrink-0 touch-none select-none overflow-hidden rounded-sm border border-border bg-background\"\n        style={{ width: minimapWidth, height: minimapHeight }}\n      >\n        <div\n          aria-hidden\n          className=\"pointer-events-none absolute left-0 top-0 origin-top-left opacity-70\"\n          style={{\n            width: contentWidth,\n            height: contentHeight,\n            transform: `scale(${minimapWidth / contentWidth}, ${minimapHeight / contentHeight})`,\n          }}\n        >\n          {children}\n        </div>\n        <div\n          ref={handleRef}\n          role=\"slider\"\n          tabIndex={0}\n          aria-label={ariaLabel}\n          aria-orientation=\"vertical\"\n          aria-valuemin={1}\n          aria-valuemax={totalRows}\n          aria-valuenow={1}\n          aria-valuetext={`viewing ${unitLabel} 1-${initialEndRow} of ${totalRows}`}\n          className=\"absolute cursor-grab rounded-[3px] border-2 border-foreground/60 bg-foreground/[0.06] outline-none transition-colors hover:border-ns-accent focus-visible:ring-2 focus-visible:ring-ns-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background active:cursor-grabbing\"\n        />\n      </div>\n\n      <svg aria-hidden className=\"pointer-events-none absolute inset-0 h-full w-full overflow-visible\">\n        <g ref={groupRef} className=\"text-ns-muted transition-opacity duration-300 ease-out\" style={{ opacity: 1 }}>\n          {[0, 1, 2, 3].map((i) => (\n            <line\n              key={i}\n              ref={(el) => {\n                lineRefs.current[i] = el;\n              }}\n              stroke=\"currentColor\"\n              strokeWidth={1}\n            />\n          ))}\n          {[0, 1, 2, 3].map((i) => (\n            <circle\n              key={i}\n              ref={(el) => {\n                circleRefs.current[i] = el;\n              }}\n              r={3}\n              fill=\"currentColor\"\n            />\n          ))}\n        </g>\n      </svg>\n\n      <div\n        ref={viewportRef}\n        className=\"relative min-w-0 flex-1 overflow-auto rounded-sm border border-border bg-background\"\n        style={{ height: viewportHeight }}\n      >\n        <div style={{ width: contentWidth, height: contentHeight }}>{children}</div>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/minimap-pantograph.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": [
      "minimap",
      "pantograph",
      "svg",
      "drag",
      "scroll",
      "navigation",
      "linkage",
      "slider"
    ],
    "instruction": "Build a minimap + viewport pair for navigating content taller (and optionally wider) than its visible window, with a live SVG pantograph drawn between them. Layout: a small minimap track (fixed px size, independent of the content's aspect ratio, like a scrollbar rail) contains a scaled CSS-transform clone of the same children (scale(minimapWidth/contentWidth, minimapHeight/contentHeight), aria-hidden, pointer-events-none, opacity 0.7) plus a draggable rect overlay sized to the viewport's visible fraction; a real viewport pane (overflow-auto, fixed height) contains the full-size children in a contentWidth x contentHeight wrapper. Between the rect's near corner and the viewport pane's top-left corner, an aria-hidden SVG draws a two-arm scissor linkage: two mirrored 2-bar elbow arms (four 1px currentColor line segments total, text-ns-muted, with 3px filled pivot circles at both anchors and both elbows) computed by trivial two-bar IK — given anchor distance d, each arm's fixed reach is d/2 + 26px (a constant bow past half the span), so the elbow height off the anchor line is sqrt(link^2 - (d/2)^2), always solvable, and shrinks as a fraction of d as the anchors pull apart — extending the linkage visibly flattens it, compressing it visibly opens it, which is the scissor read. The two anchor points themselves (rect corner, viewport corner) are never sprung — they track their DOM elements exactly via getBoundingClientRect every active frame, so the linkage always looks physically attached; only the two elbow joints ease toward their per-frame IK target on a k=200 s^-2, zeta=0.8 spring, so a fast drag flick shows the arms lag half a beat behind the handle before snapping into the new shape. The whole linkage group relaxes to 0.3 opacity 700ms after the last drag/scroll/key input and snaps back to full opacity on the next one (CSS opacity transition, 300ms). Dragging: pointer handlers live on the minimap track (not just the small rect) so a grab starting anywhere in the track is captured via setPointerCapture, mirroring how a real trackpad-style control is grabbed; move delta in screen px is divided by the fixed scaleX/scaleY (minimapWidth/contentWidth, minimapHeight/contentHeight) to get the content-space scroll delta, so e.g. a 140px-wide minimap over 560px content moves the document 4x whatever the handle moved, and a taller document over the same minimap height moves proportionally more — the ratio is whatever the two fixed sizes imply, not hardcoded. A11y: the rect is a focusable two-axis control, role=slider, tabIndex=0, aria-orientation=\"vertical\" (the dominant axis for the long-document case this demonstrates), aria-valuemin=1, aria-valuemax=totalRows (contentHeight/rowHeight), aria-valuenow=current start row, aria-valuetext e.g. 'viewing rows 120-160 of 900' updated directly via setAttribute every active frame (not React state, to avoid re-rendering the host tree on every scroll tick). ArrowUp/Down pan one row (rowHeight px); ArrowLeft/Right pan one 32px step (for content wider than the viewport); PageUp/PageDown pan 90% of a screenful; Home/End jump to the vertical extremes. The viewport pane's own native scroll (wheel, trackbar, touch) stays live and wakes the same render loop via a passive scroll listener, so dragging the handle and scrolling the content directly stay in sync either way; a ResizeObserver on the viewport re-measures its client box so the rect's size tracks real available width. Direct-DOM rAF: a single loop, refs only, sleeps once both elbow springs are within 0.15px / 2px-per-second of their targets and no drag is in progress, wakes on pointerdown/move, keydown, scroll, or resize. Reduced motion: the elbow springs are skipped entirely (position snaps straight to the IK target every frame) so the arms still track the handle rigidly, just without the lag-then-snap; the opacity idle-relax is unaffected (a plain fade, not translational motion). Tokens only: all linkage ink is currentColor via a text-ns-muted class on the group, track/viewport borders are border-border, backgrounds are bg-background, and the handle uses border-foreground/60 at rest, border-ns-accent on hover, and an accent focus-visible ring (ring-2 ring-ns-accent, offset from bg-background) — never outline-none paired with focus-visible:outline on the same element. Cleanup: rAF, ResizeObserver, and all native listeners (attached via addEventListener in the same effect that owns the loop, not JSX props, since the geometry closures need direct refs) are torn down on unmount."
  },
  "type": "registry:ui"
}