Minimap Pantograph

Minimap

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.

Install
npx shadcn add https://design.helpmarq.com/r/minimap-pantograph.json
Source
registry/core/minimap-pantograph/component.tsx
"use client";

import { useEffect, useRef, type ReactNode } from "react";

// ScissorReach — a minimap whose viewport rectangle is physically wired to
// the real viewport by a live SVG pantograph. The minimap is a scaled clone
// of the same content (CSS transform: scale, never re-measured — width and
// height ratios are derived once from the fixed contentWidth/contentHeight
// props). Between the minimap's viewport rect and the real viewport's
// top-left corner, two mirrored 2-bar elbow arms are solved every frame by
// trivial IK from the two live anchor points; only the elbow (joint)
// springs — the anchors themselves track their DOM elements exactly, never
// lagging, so the linkage always reads as physically attached rather than
// floating. Direct-DOM rAF, refs only on the hot path, sleeps when the
// spring settles and dragging has stopped.

type Vec = { x: number; y: number };

const BOW = 26; // px — arm reach exceeds half the anchor span by this much
const SPRING_K = 200; // s^-2
const SPRING_ZETA = 0.8; // slightly underdamped -> a hair of catch-up snap
const SPRING_C = 2 * SPRING_ZETA * Math.sqrt(SPRING_K);
const SETTLE_EPS = 0.15; // px
const SETTLE_V_EPS = 2; // px/s
const IDLE_MS = 700; // idle relax delay before the linkage fades to 0.3
const STEP_X = 32; // px per ArrowLeft/Right, content space

function clamp(v: number, lo: number, hi: number) {
  return Math.min(hi, Math.max(lo, v));
}

// Two equal-length links whose combined reach is fixed at (d/2 + bow) for
// the current anchor distance d — always solvable (no negative sqrt), and
// the bow shrinks as a fraction of d as the anchors pull apart, which reads
// as "the linkage extends and flattens" rather than a floppy fixed arm.
function twoBarElbow(a: Vec, b: Vec, bow: number) {
  const dx = b.x - a.x;
  const dy = b.y - a.y;
  const d = Math.max(1e-4, Math.hypot(dx, dy));
  const link = d / 2 + bow;
  const h = Math.sqrt(Math.max(0, link * link - (d / 2) * (d / 2)));
  const mx = (a.x + b.x) / 2;
  const my = (a.y + b.y) / 2;
  const nx = -dy / d;
  const ny = dx / d;
  return {
    up: { x: mx + nx * h, y: my + ny * h },
    down: { x: mx - nx * h, y: my - ny * h },
  };
}

export function ScissorReach({
  children,
  contentWidth,
  contentHeight,
  minimapWidth = 140,
  minimapHeight = 220,
  viewportHeight = 340,
  rowHeight = 16,
  unitLabel = "rows",
  className = "",
  "aria-label": ariaLabel = "Document position",
}: {
  /** the full content — size it so it renders at exactly contentWidth x contentHeight */
  children: ReactNode;
  /** logical content extent in px that `children` occupies */
  contentWidth: number;
  contentHeight: number;
  /** minimap panel size in px — independent of content aspect ratio, like a scrollbar rail */
  minimapWidth?: number;
  minimapHeight?: number;
  /** fixed viewport pane height in px */
  viewportHeight?: number;
  /** row height in px — used only to phrase the aria-valuetext readout */
  rowHeight?: number;
  unitLabel?: string;
  className?: string;
  "aria-label"?: string;
}) {
  const containerRef = useRef<HTMLDivElement | null>(null);
  const trackRef = useRef<HTMLDivElement | null>(null);
  const handleRef = useRef<HTMLDivElement | null>(null);
  const viewportRef = useRef<HTMLDivElement | null>(null);
  const groupRef = useRef<SVGGElement | null>(null);
  const lineRefs = useRef<(SVGLineElement | null)[]>([]);
  const circleRefs = useRef<(SVGCircleElement | null)[]>([]);

  const totalRows = Math.max(1, Math.round(contentHeight / rowHeight));
  const initialEndRow = Math.min(totalRows, Math.ceil(viewportHeight / rowHeight));

  useEffect(() => {
    const container = containerRef.current;
    const track = trackRef.current;
    const handle = handleRef.current;
    const viewport = viewportRef.current;
    const group = groupRef.current;
    if (!container || !track || !handle || !viewport || !group) return;

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    const scaleX = minimapWidth / contentWidth;
    const scaleY = minimapHeight / contentHeight;

    let vpW = viewport.clientWidth;
    let vpH = viewport.clientHeight;

    let dragging = false;
    let dragStartX = 0;
    let dragStartY = 0;
    let dragScrollLeft = 0;
    let dragScrollTop = 0;

    let upPos: Vec = { x: 0, y: 0 };
    let upVel: Vec = { x: 0, y: 0 };
    let downPos: Vec = { x: 0, y: 0 };
    let downVel: Vec = { x: 0, y: 0 };
    let primed = false;

    let raf = 0;
    let last = 0;
    let idleTimer: ReturnType<typeof setTimeout> | null = null;

    const maxTop = () => Math.max(0, contentHeight - vpH);
    const maxLeft = () => Math.max(0, contentWidth - vpW);

    const markActive = () => {
      group.style.opacity = "1";
      if (idleTimer) clearTimeout(idleTimer);
      idleTimer = setTimeout(() => {
        group.style.opacity = "0.3";
      }, IDLE_MS);
    };

    // returns true once settled (always true immediately under reduced motion)
    const springTo = (pos: Vec, vel: Vec, target: Vec, dt: number) => {
      if (reduced) {
        pos.x = target.x;
        pos.y = target.y;
        vel.x = 0;
        vel.y = 0;
        return true;
      }
      vel.x += (SPRING_K * (target.x - pos.x) - SPRING_C * vel.x) * dt;
      vel.y += (SPRING_K * (target.y - pos.y) - SPRING_C * vel.y) * dt;
      pos.x += vel.x * dt;
      pos.y += vel.y * dt;
      const dErr = Math.hypot(target.x - pos.x, target.y - pos.y);
      const vErr = Math.hypot(vel.x, vel.y);
      return dErr < SETTLE_EPS && vErr < SETTLE_V_EPS;
    };

    const frame = (now: number) => {
      raf = 0;
      const dt = last ? clamp((now - last) / 1000, 0.001, 1 / 30) : 1 / 60;
      last = now;

      const scrollTop = clamp(viewport.scrollTop, 0, maxTop());
      const scrollLeft = clamp(viewport.scrollLeft, 0, maxLeft());

      const rectW = Math.min(minimapWidth, Math.max(4, vpW * scaleX));
      const rectH = Math.min(minimapHeight, Math.max(4, vpH * scaleY));
      const rectLeft = clamp(scrollLeft * scaleX, 0, Math.max(0, minimapWidth - rectW));
      const rectTop = clamp(scrollTop * scaleY, 0, Math.max(0, minimapHeight - rectH));

      handle.style.left = `${rectLeft}px`;
      handle.style.top = `${rectTop}px`;
      handle.style.width = `${rectW}px`;
      handle.style.height = `${rectH}px`;

      const trackBox = track.getBoundingClientRect();
      const containerBox = container.getBoundingClientRect();
      const viewportBox = viewport.getBoundingClientRect();
      const originX = trackBox.left - containerBox.left;
      const originY = trackBox.top - containerBox.top;

      // A: the rect's near corner (moves with the drag). B: the real
      // viewport's fixed corner. Neither is ever sprung — only the elbow
      // joints between them are, so the linkage always looks attached.
      const a: Vec = { x: originX + rectLeft + rectW, y: originY + rectTop };
      const b: Vec = { x: viewportBox.left - containerBox.left, y: viewportBox.top - containerBox.top };

      const target = twoBarElbow(a, b, BOW);
      if (!primed) {
        upPos = { ...target.up };
        downPos = { ...target.down };
        primed = true;
      }
      const settledUp = springTo(upPos, upVel, target.up, dt);
      const settledDown = springTo(downPos, downVel, target.down, dt);

      const segs = lineRefs.current;
      const write = (el: SVGLineElement | null | undefined, p1: Vec, p2: Vec) => {
        if (!el) return;
        el.setAttribute("x1", String(p1.x));
        el.setAttribute("y1", String(p1.y));
        el.setAttribute("x2", String(p2.x));
        el.setAttribute("y2", String(p2.y));
      };
      write(segs[0], a, upPos);
      write(segs[1], upPos, b);
      write(segs[2], a, downPos);
      write(segs[3], downPos, b);

      const pts = [a, upPos, downPos, b];
      circleRefs.current.forEach((c, i) => {
        const p = pts[i];
        if (c && p) {
          c.setAttribute("cx", String(p.x));
          c.setAttribute("cy", String(p.y));
        }
      });

      const startRow = Math.floor(scrollTop / rowHeight) + 1;
      const endRow = Math.min(totalRows, Math.ceil((scrollTop + vpH) / rowHeight));
      handle.setAttribute("aria-valuenow", String(startRow));
      handle.setAttribute(
        "aria-valuetext",
        `viewing ${unitLabel} ${startRow}-${endRow} of ${totalRows}`
      );

      const active = dragging || !settledUp || !settledDown;
      if (active) raf = requestAnimationFrame(frame);
    };

    const wake = () => {
      markActive();
      if (!raf) {
        last = 0;
        raf = requestAnimationFrame(frame);
      }
    };

    const onPointerDown = (e: PointerEvent) => {
      e.preventDefault();
      track.setPointerCapture(e.pointerId);
      handle.focus({ preventScroll: true });
      dragging = true;
      dragStartX = e.clientX;
      dragStartY = e.clientY;
      dragScrollLeft = viewport.scrollLeft;
      dragScrollTop = viewport.scrollTop;
      wake();
    };
    const onPointerMove = (e: PointerEvent) => {
      if (!dragging) return;
      const dxContent = (e.clientX - dragStartX) / scaleX;
      const dyContent = (e.clientY - dragStartY) / scaleY;
      viewport.scrollLeft = clamp(dragScrollLeft + dxContent, 0, maxLeft());
      viewport.scrollTop = clamp(dragScrollTop + dyContent, 0, maxTop());
      wake();
    };
    const endDrag = (e: PointerEvent) => {
      if (!dragging) return;
      dragging = false;
      try {
        track.releasePointerCapture(e.pointerId);
      } catch {
        // synthetic pointerId during autoplay — nothing to release
      }
      wake();
    };

    const onKeyDown = (e: KeyboardEvent) => {
      let handled = true;
      switch (e.key) {
        case "ArrowDown":
          viewport.scrollTop = clamp(viewport.scrollTop + rowHeight, 0, maxTop());
          break;
        case "ArrowUp":
          viewport.scrollTop = clamp(viewport.scrollTop - rowHeight, 0, maxTop());
          break;
        case "ArrowRight":
          viewport.scrollLeft = clamp(viewport.scrollLeft + STEP_X, 0, maxLeft());
          break;
        case "ArrowLeft":
          viewport.scrollLeft = clamp(viewport.scrollLeft - STEP_X, 0, maxLeft());
          break;
        case "PageDown":
          viewport.scrollTop = clamp(viewport.scrollTop + vpH * 0.9, 0, maxTop());
          break;
        case "PageUp":
          viewport.scrollTop = clamp(viewport.scrollTop - vpH * 0.9, 0, maxTop());
          break;
        case "Home":
          viewport.scrollTop = 0;
          break;
        case "End":
          viewport.scrollTop = maxTop();
          break;
        default:
          handled = false;
      }
      if (handled) {
        e.preventDefault();
        wake();
      }
    };

    const onScroll = () => wake();

    const ro = new ResizeObserver(() => {
      vpW = viewport.clientWidth;
      vpH = viewport.clientHeight;
      wake();
    });
    ro.observe(viewport);

    track.addEventListener("pointerdown", onPointerDown);
    track.addEventListener("pointermove", onPointerMove);
    track.addEventListener("pointerup", endDrag);
    track.addEventListener("pointercancel", endDrag);
    handle.addEventListener("keydown", onKeyDown);
    viewport.addEventListener("scroll", onScroll, { passive: true });

    wake();

    return () => {
      cancelAnimationFrame(raf);
      if (idleTimer) clearTimeout(idleTimer);
      ro.disconnect();
      track.removeEventListener("pointerdown", onPointerDown);
      track.removeEventListener("pointermove", onPointerMove);
      track.removeEventListener("pointerup", endDrag);
      track.removeEventListener("pointercancel", endDrag);
      handle.removeEventListener("keydown", onKeyDown);
      viewport.removeEventListener("scroll", onScroll);
    };
  }, [contentWidth, contentHeight, minimapWidth, minimapHeight, viewportHeight, rowHeight, unitLabel, totalRows]);

  return (
    <div ref={containerRef} className={`relative flex items-start gap-24 ${className}`}>
      <div
        ref={trackRef}
        data-scissor-track
        className="relative shrink-0 touch-none select-none overflow-hidden rounded-sm border border-border bg-background"
        style={{ width: minimapWidth, height: minimapHeight }}
      >
        <div
          aria-hidden
          className="pointer-events-none absolute left-0 top-0 origin-top-left opacity-70"
          style={{
            width: contentWidth,
            height: contentHeight,
            transform: `scale(${minimapWidth / contentWidth}, ${minimapHeight / contentHeight})`,
          }}
        >
          {children}
        </div>
        <div
          ref={handleRef}
          role="slider"
          tabIndex={0}
          aria-label={ariaLabel}
          aria-orientation="vertical"
          aria-valuemin={1}
          aria-valuemax={totalRows}
          aria-valuenow={1}
          aria-valuetext={`viewing ${unitLabel} 1-${initialEndRow} of ${totalRows}`}
          className="absolute cursor-grab rounded-[3px] border-2 border-foreground/60 bg-foreground/[0.06] outline-none transition-colors hover:border-accent focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background active:cursor-grabbing"
        />
      </div>

      <svg aria-hidden className="pointer-events-none absolute inset-0 h-full w-full overflow-visible">
        <g ref={groupRef} className="text-muted transition-opacity duration-300 ease-out" style={{ opacity: 1 }}>
          {[0, 1, 2, 3].map((i) => (
            <line
              key={i}
              ref={(el) => {
                lineRefs.current[i] = el;
              }}
              stroke="currentColor"
              strokeWidth={1}
            />
          ))}
          {[0, 1, 2, 3].map((i) => (
            <circle
              key={i}
              ref={(el) => {
                circleRefs.current[i] = el;
              }}
              r={3}
              fill="currentColor"
            />
          ))}
        </g>
      </svg>

      <div
        ref={viewportRef}
        className="relative min-w-0 flex-1 overflow-auto rounded-sm border border-border bg-background"
        style={{ height: viewportHeight }}
      >
        <div style={{ width: contentWidth, height: contentHeight }}>{children}</div>
      </div>
    </div>
  );
}
Use when

a minimap that shows its own scale ratio as a physical pantograph linkage between the small viewport rect and the real viewport, rather than leaving the amplification implied — reach for this when the point is watching a small drag mechanically drive a large pan (long documents, wide canvases, node graphs). Different from toc-minimap-mercury, which is a scroll-position TOC indicator (a liquid column climbing section ticks, no drag, no scale-transfer mechanism) and from slider-loupe, which is a single-value magnifier for reading a value precisely, not a 2D pan/navigation control.

Build spec

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-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-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-accent on hover, and an accent focus-visible ring (ring-2 ring-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.

Tags
minimappantographsvgdragscrollnavigationlinkageslider