Patchbay ASCII Cable

Patchbay

A patchbay with real, persistent, user-authored topology: drag from one jack to another to create a patch, routed orthogonally onto the shared monospace glyph grid, and once connected a small pulse travels the cable on a loop.

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

import { useEffect, useMemo, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// AsciiPatchbay — a patchbay whose distinguishing mechanic is PERSISTENT,
// user-authored topology: dragging from one jack to another creates a patch
// (arbitrary jack-to-jack, not a fixed slider ratio or a one-shot transfer
// tube), the cable is routed orthogonally onto the same shared monospace
// glyph grid diagram-ascii-flow's router uses, and once connected a small
// glyph pulse travels the length of the cable on a loop. Grabbing a jack
// that already carries a patch unplugs it immediately (classic patchbay
// behavior — picking up the far end of a live cable disconnects it) and
// hands you a loose plug to drop on a new jack or nowhere at all. Every
// jack is a real button; Enter/Space "arms" it, a second Enter/Space on a
// different jack completes the patch, and pressing the same jack again
// cancels — so the whole mechanic is reachable without a pointer.
// ---------------------------------------------------------------------------

export interface PatchbayJack {
  id: string;
  label: string;
  row: "top" | "bottom";
  col: number; // 0-based slot index within its row
}

export interface AsciiPatchbayProps {
  jacks?: PatchbayJack[];
  className?: string;
}

const DEFAULT_JACKS: PatchbayJack[] = [
  { id: "a", label: "A", row: "top", col: 0 },
  { id: "b", label: "B", row: "top", col: 1 },
  { id: "c", label: "C", row: "top", col: 2 },
  { id: "1", label: "1", row: "bottom", col: 0 },
  { id: "2", label: "2", row: "bottom", col: 1 },
  { id: "3", label: "3", row: "bottom", col: 2 },
];

const COLS = 26;
const ROWS = 12;
const CELL_W = 13;
const CELL_H = 17;
const JACK_W = 3;
const JACK_H = 2;
const SLOT_GAP = 9;
const TOP_ROW = 1;
const BOTTOM_ROW = 9;

function jackCol(col: number): number {
  return 1 + col * SLOT_GAP;
}
function jackRow(row: "top" | "bottom"): number {
  return row === "top" ? TOP_ROW : BOTTOM_ROW;
}
// The point just outside the jack's box in its natural cabling direction —
// down for a top-row jack, up for a bottom-row one.
function anchor(jack: PatchbayJack): [number, number] {
  const col = jackCol(jack.col) + Math.floor(JACK_W / 2);
  const row = jack.row === "top" ? jackRow(jack.row) + JACK_H : jackRow(jack.row) - 1;
  return [col, row];
}

// Direction bits and glyph table — identical scheme to diagram-ascii-flow's
// shared router, reimplemented here since each component folder is
// self-contained (no shared lib file across registry entries).
const N = 1;
const E = 2;
const S = 4;
const W = 8;
const GLYPH: Record<number, string> = {
  0: " ",
  1: "│",
  2: "─",
  4: "│",
  8: "─",
  5: "│",
  10: "─",
  3: "└",
  9: "┘",
  6: "┌",
  12: "┐",
  7: "├",
  13: "┤",
  14: "┬",
  11: "┴",
  15: "┼",
};
function dirBit(dx: number, dy: number): number {
  if (dx === 1) return E;
  if (dx === -1) return W;
  if (dy === 1) return S;
  if (dy === -1) return N;
  return 0;
}
const OPPOSITE: Record<number, number> = { [N]: S, [S]: N, [E]: W, [W]: E };

// Two-point single-bend orthogonal path: straight if already aligned, else a
// vertical-horizontal-vertical jog through the midpoint row — cables drop
// out of the jack, jog across, then drop into the target jack.
function orthogonalPath(a: [number, number], b: [number, number]): [number, number][] {
  if (a[0] === b[0] || a[1] === b[1]) return [a, b];
  const midY = Math.round((a[1] + b[1]) / 2);
  return [a, [a[0], midY], [b[0], midY], b];
}

function flattenPath(points: [number, number][]): [number, number][] {
  const cells: [number, number][] = [points[0]];
  for (let s = 0; s < points.length - 1; s++) {
    let [x, y] = points[s];
    const [ex, ey] = points[s + 1];
    const dx = Math.sign(ex - x);
    const dy = Math.sign(ey - y);
    while (x !== ex || y !== ey) {
      x += dx;
      y += dy;
      cells.push([x, y]);
    }
  }
  return cells;
}

function tracePolyline(points: [number, number][], grid: Map<string, number>) {
  for (let s = 0; s < points.length - 1; s++) {
    let [x, y] = points[s];
    const [ex, ey] = points[s + 1];
    const dx = Math.sign(ex - x);
    const dy = Math.sign(ey - y);
    if (dx !== 0 && dy !== 0) continue;
    while (x !== ex || y !== ey) {
      const nx = x + dx;
      const ny = y + dy;
      const out = dirBit(dx, dy);
      const into = OPPOSITE[out];
      const k1 = `${x},${y}`;
      const k2 = `${nx},${ny}`;
      grid.set(k1, (grid.get(k1) ?? 0) | out);
      grid.set(k2, (grid.get(k2) ?? 0) | into);
      x = nx;
      y = ny;
    }
  }
}

// No JS token reads here on purpose — see diagram-ascii-flow's component.tsx
// for the full story. Short version: this component used to hold
// getComputedStyle(--foreground/--background/etc) in state and apply it via
// inline `style`, and that's a real bug, not a style choice — SSR always
// renders with no `document`, so the FIRST markup bakes in the light-theme
// fallback hex, and React's hydration does not force a mismatched inline
// style property to the client's value the way it does for text content. On
// a genuinely dark-themed load, a value that never CHANGES across renders
// (this component's own render logic recomputes the same "correct" dark hex
// every time) never gets patched into the DOM, because React only writes an
// attribute when it differs from the PREVIOUS render's value, not from
// what's actually painted — confirmed live, reproducibly, on the sibling
// component. Tailwind classes bound to the same custom properties sidestep
// the whole bug class: the cascade resolves --background per theme at PAINT
// time, no JS or hydration step involved.

type Patch = [string, string];

export function AsciiPatchbay({ jacks = DEFAULT_JACKS, className = "" }: AsciiPatchbayProps) {
  const [patches, setPatches] = useState<Patch[]>([
    ["a", "2"],
    ["c", "1"],
  ]);
  const [armedId, setArmedId] = useState<string | null>(null);
  const [livePointer, setLivePointer] = useState<[number, number] | null>(null);
  const [hoverId, setHoverId] = useState<string | null>(null);
  const containerRef = useRef<HTMLDivElement>(null);
  const draggingRef = useRef(false);
  const gestureRef = useRef<{ id: string; moved: boolean; ownArm: boolean } | null>(null);
  const suppressClickRef = useRef(false);
  const markerRefs = useRef<Record<number, HTMLSpanElement | null>>({});
  const reducedRef = useRef(false);

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const sync = () => {
      reducedRef.current = mq.matches;
    };
    sync();
    mq.addEventListener("change", sync);
    return () => mq.removeEventListener("change", sync);
  }, []);

  const byId = useMemo(() => new Map(jacks.map((j) => [j.id, j])), [jacks]);
  const partnerOf = (id: string) => {
    for (const [a, b] of patches) {
      if (a === id) return b;
      if (b === id) return a;
    }
    return null;
  };

  const removePatchFor = (id: string) => {
    setPatches((cur) => cur.filter(([a, b]) => a !== id && b !== id));
  };

  const completePatch = (targetId: string, from: string) => {
    setPatches((cur) => cur.filter(([a, b]) => a !== from && b !== from && a !== targetId && b !== targetId).concat([[from, targetId]]));
    setArmedId(null);
  };

  const cellFromClientPoint = (clientX: number, clientY: number): [number, number] | null => {
    const rect = containerRef.current?.getBoundingClientRect();
    if (!rect) return null;
    const x = Math.max(0, Math.min(COLS - 1, Math.round((clientX - rect.left) / CELL_W)));
    const y = Math.max(0, Math.min(ROWS - 1, Math.round((clientY - rect.top) / CELL_H)));
    return [x, y];
  };

  // A gesture's outcome depends on whether the pointer actually moved, not
  // just on where it started/ended — a plain tap (down+up with no movement,
  // exactly what a synthetic/keyboard-equivalent click produces) must never
  // destructively unplug an existing patch; only a real drag-away does.
  const onJackPointerDown = (jack: PatchbayJack) => (e: React.PointerEvent<HTMLButtonElement>) => {
    if (e.button !== 0) return;
    (e.target as HTMLElement).setPointerCapture(e.pointerId);
    const ownArm = !armedId;
    gestureRef.current = { id: jack.id, moved: false, ownArm };
    draggingRef.current = true;
    if (ownArm) {
      setArmedId(jack.id);
      setLivePointer(anchor(jack));
    }
  };

  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
    if (!draggingRef.current) return;
    const g = gestureRef.current;
    if (g && g.ownArm && !g.moved) {
      g.moved = true;
      // first real movement of a fresh pickup: NOW unplug, giving the
      // dragged cable's far end live visual feedback for the rest of the
      // gesture rather than silently vanishing at pointerdown.
      if (partnerOf(g.id)) removePatchFor(g.id);
    } else if (g) {
      g.moved = true;
    }
    if (armedId) {
      const cell = cellFromClientPoint(e.clientX, e.clientY);
      if (cell) setLivePointer(cell);
    }
  };

  const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
    if (!draggingRef.current) return;
    draggingRef.current = false;
    setLivePointer(null);
    const g = gestureRef.current;
    gestureRef.current = null;
    suppressClickRef.current = true;
    if (!armedId) return;
    const target = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
    const targetId = target?.closest<HTMLElement>("[data-patchbay-jack]")?.dataset.patchbayJack ?? null;
    if (targetId && targetId !== armedId) {
      completePatch(targetId, armedId);
    } else if (!g?.moved) {
      // a tap that never moved: stay armed, awaiting a second jack — this is
      // the two-step click/keyboard flow's first half, not a cancel.
      return;
    } else {
      // a real drag that ended on empty space (or dropped back on itself):
      // cancel — the plug (already detached, if it had a patch) stays loose.
      setArmedId(null);
    }
  };

  const onJackClick = (jack: PatchbayJack) => () => {
    // reached only via genuine keyboard activation (Enter/Space) — a
    // pointer-driven click's trailing synthetic click is always suppressed
    // above, since that whole gesture was already handled in onPointerUp.
    if (suppressClickRef.current) {
      suppressClickRef.current = false;
      return;
    }
    if (!armedId) {
      setArmedId(jack.id);
    } else if (armedId === jack.id) {
      setArmedId(null);
    } else {
      completePatch(jack.id, armedId);
    }
  };

  const grid = useMemo(() => {
    const g = new Map<string, number>();
    for (const [a, b] of patches) {
      const ja = byId.get(a);
      const jb = byId.get(b);
      if (!ja || !jb) continue;
      tracePolyline(orthogonalPath(anchor(ja), anchor(jb)), g);
    }
    if (armedId && livePointer) {
      const ja = byId.get(armedId);
      if (ja) tracePolyline(orthogonalPath(anchor(ja), livePointer), g);
    }
    return g;
  }, [patches, byId, armedId, livePointer]);

  const rows = useMemo(() => {
    const out: string[] = [];
    for (let y = 0; y < ROWS; y++) {
      let line = "";
      for (let x = 0; x < COLS; x++) line += GLYPH[grid.get(`${x},${y}`) ?? 0] ?? " ";
      out.push(line);
    }
    return out;
  }, [grid]);

  // Pulse: one small rAF loop, only while at least one patch exists and
  // motion isn't reduced, moving a marker span along each patch's flattened
  // cell path directly via refs (no per-frame React state).
  useEffect(() => {
    if (reducedRef.current || patches.length === 0) return;
    const paths = patches.map(([a, b]) => {
      const ja = byId.get(a);
      const jb = byId.get(b);
      return ja && jb ? flattenPath(orthogonalPath(anchor(ja), anchor(jb))) : [];
    });
    let raf = 0;
    const period = 1400;
    const start = performance.now();
    const tick = (now: number) => {
      const frac = ((now - start) % period) / period;
      paths.forEach((cells, i) => {
        const marker = markerRefs.current[i];
        if (!marker || cells.length === 0) return;
        const idx = Math.min(cells.length - 1, Math.floor(frac * cells.length));
        const [cx, cy] = cells[idx];
        marker.style.left = `${cx * CELL_W}px`;
        marker.style.top = `${cy * CELL_H}px`;
      });
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [patches, byId]);

  return (
    <div className={`ns-pac font-mono ${className}`}>
      <style>{CSS}</style>
      <div
        ref={containerRef}
        className="ns-pac-canvas relative select-none"
        style={{ width: COLS * CELL_W, height: ROWS * CELL_H, maxWidth: "100%" }}
        onPointerMove={onPointerMove}
        onPointerUp={onPointerUp}
      >
        <div aria-hidden className="pointer-events-none absolute inset-0 text-muted" style={{ fontSize: 11 }}>
          {rows.map((line, y) => (
            <div key={y} style={{ height: CELL_H, lineHeight: `${CELL_H}px`, whiteSpace: "nowrap" }}>
              {line.split("").map((ch, x) => (
                <span key={x} style={{ display: "inline-block", width: CELL_W, textAlign: "center" }}>
                  {ch}
                </span>
              ))}
            </div>
          ))}
        </div>

        {patches.map(([a], i) => (
          <span
            key={`${a}-${i}`}
            ref={(el) => {
              markerRefs.current[i] = el;
            }}
            aria-hidden
            className="ns-pac-pulse pointer-events-none absolute rounded-full bg-accent"
            style={{ width: 6, height: 6, marginLeft: CELL_W / 2 - 3, marginTop: CELL_H / 2 - 3 }}
          />
        ))}

        {jacks.map((jack) => {
          const col = jackCol(jack.col);
          const row = jackRow(jack.row);
          const armed = armedId === jack.id;
          const patched = !!partnerOf(jack.id);
          const hovered = hoverId === jack.id;
          return (
            <button
              key={jack.id}
              type="button"
              data-patchbay-jack={jack.id}
              className={`ns-pac-jack absolute flex items-center justify-center border bg-background text-[11px] transition-colors duration-150 ${
                armed
                  ? "border-accent text-foreground"
                  : hovered
                    ? "border-accent/40 text-foreground"
                    : patched
                      ? "border-border text-foreground"
                      : "border-border text-muted"
              }`}
              style={{
                left: col * CELL_W,
                top: row * CELL_H,
                width: JACK_W * CELL_W,
                height: JACK_H * CELL_H,
              }}
              aria-label={`Jack ${jack.label}${patched ? `, patched to ${byId.get(partnerOf(jack.id)!)?.label}` : ", unpatched"}. Press Enter to ${
                armedId && armedId !== jack.id ? "complete the patch" : armed ? "cancel" : "arm this jack"
              }.`}
              aria-pressed={armed}
              onPointerEnter={() => setHoverId(jack.id)}
              onPointerLeave={() => setHoverId((c) => (c === jack.id ? null : c))}
              onPointerDown={onJackPointerDown(jack)}
              onClick={onJackClick(jack)}
              onKeyDown={(e) => {
                if (e.key === "Escape") setArmedId(null);
              }}
            >
              {jack.label}
            </button>
          );
        })}
      </div>

      <div className="mt-3 min-h-[1.5em] font-mono text-xs text-muted">
        {armedId ? (
          <span data-patchbay-armed>
            Armed: <strong className="text-foreground">{byId.get(armedId)?.label}</strong> — select another jack to
            patch, or press Escape to cancel.
          </span>
        ) : (
          <span>Drag from one jack to another to patch them, or press Enter to arm.</span>
        )}
      </div>
    </div>
  );
}

const CSS = `
.ns-pac-jack:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.ns-pac-pulse { animation: ns-pac-blink 1.4s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
  .ns-pac-pulse { display: none; }
}
@keyframes ns-pac-blink {
  0%, 100% { opacity: 0.35; }
  50% { opacity: 1; }
}
`;
Use when

A patchbay with real, persistent, user-authored topology: drag from one jack to another to create a patch, routed orthogonally onto the shared monospace glyph grid, and once connected a small pulse travels the cable on a loop. Grabbing a jack that already carries a patch unplugs it — the classic pull-the-plug gesture — leaving a loose end you can drop on a new jack or nowhere at all.

any-to-any user-created routing where the connections themselves are the data (audio patchbays, signal routing, manual pairing UIs) — pick slider-allocation-wire instead for a single fixed two-way ratio, tabs-slack-cable for a selection indicator that only ever points at one predetermined tab, or transfer-list-siphon for a one-shot transfer whose tube exists only to animate items moving, never a persistent connection the user can later unplug.

Build spec

Build a patchbay from a `jacks` prop (PatchbayJack[] — `{id, label, row: 'top'|'bottom', col}`), defaulting to 3 top jacks (A/B/C) and 3 bottom jacks (1/2/3) with two already-patched pairs. State is a `patches` array of `[jackId, jackId]` pairs — the real, persistent data model, not a visual-only highlight. ROUTING: each jack has a fixed `anchor` point one cell outside its box in its natural cabling direction (straight down for a top-row jack, straight up for a bottom-row one); a patch between two jacks is drawn with `orthogonalPath(anchorA, anchorB)` — a straight line if the anchors already share a row or column, otherwise a vertical-horizontal-vertical jog through the midpoint row — walked cell-by-cell into a shared direction-bit grid (`tracePolyline`, the same N/E/S/W-mask-to-box-glyph technique diagram-ascii-flow's router uses, reimplemented locally since each component folder is self-contained) so that where two patches' cables cross or run parallel, the shared cells resolve to the correct junction glyph (┼ ├ ┤ ┬ ┴) rather than one path overwriting the other. Every character is rendered as its own fixed-width span (never one flowing text string per row) so the cable grid stays pixel-aligned with the absolutely-positioned jack buttons regardless of the actual monospace glyph advance width. THE PATCH GESTURE: pointerdown on a jack starts a gesture without immediately mutating anything; only once the pointer actually MOVES does a fresh pickup (i.e. no jack was already armed) unplug that jack's existing patch, if it had one, and start drawing a live preview cable from its anchor to the current pointer cell — a plain tap that never moves must never destructively unplug, or a keyboard-equivalent click would silently break a live connection. Releasing over a different jack completes the patch (dropping any prior patch either endpoint held); releasing over empty space after a real drag cancels, leaving the plug loose; releasing back on the same jack with no movement leaves it armed, which is also exactly the state a keyboard Enter/Space produces. KEYBOARD: every jack is a real, always-focusable button with an `aria-label` stating its patched partner or 'unpatched' plus the next available action; Enter/Space on an unarmed jack arms it (a `data-patchbay-armed` readout names it beneath the canvas), Enter/Space on a second, different jack completes the patch exactly like a drag-drop would, Enter/Space on the already-armed jack or Escape cancels. PULSE: once a patch exists, a single rAF loop (skipped entirely under `prefers-reduced-motion: reduce`, whose cable still renders, just motionless) walks a small `--accent` marker span along each patch's flattened cell path on a 1.4s loop, writing its position straight to the DOM via a ref — no per-frame React state. Hover and keyboard focus both shift a jack's border toward `--accent`; an armed jack's border is a persistent `--accent`; a patched-but-idle jack's label is `--foreground` rather than `--muted`. Tokens only (`--background --foreground --muted --border --accent`, read via `getComputedStyle` on the document root, re-read on a `MutationObserver` watching its class attribute) — no hardcoded hex, correct in both themes. Zero dependencies, pure DOM + CSS.

Tags
patchbaycablegraphasciibox-drawingdragkeyboard-navigation