"use client"; import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent as ReactPointerEvent } from "react"; import { catenary, check, connect, unplug, type Cable, type Input, type Jack } from "./patch"; import "./patch-bay.css"; export type { Cable, Input, Jack } from "./patch"; export type PatchBayProps = { outputs: Jack[]; inputs: Input[]; defaultCables?: Cable[]; onChange?: (cables: Cable[]) => void; /** Cable colour per signal type. */ colors?: Record; title?: string; theme?: "light" | "dark"; motion?: boolean; className?: string; }; const PALETTE = ["#e0a23a", "#8b6cf0", "#1fa39a", "#3f7fe0", "#e05a8a", "#6aa84f"]; type Pt = [number, number]; /** * Patch Bay * Routing as cables: sources on the left, destinations on the right. Drag a * cable from a jack and it hangs with real slack; a destination that can't * take the signal refuses the plug and says why. Click a plug to pull it. */ export function PatchBay({ outputs, inputs, defaultCables = [], onChange, colors = {}, title = "Routing", theme = "light", motion = true, className = "" }: PatchBayProps) { const id = useId(); const [cables, setCables] = useState(defaultCables); const [armed, setArmed] = useState(null); const [drag, setDrag] = useState(null); const [over, setOver] = useState(null); const [refused, setRefused] = useState<{ to: string; reason: string } | null>(null); const [message, setMessage] = useState(""); const [pos, setPos] = useState>({}); const [settle, setSettle] = useState<{ to: string; t: number } | null>(null); const rack = useRef(null); const jacks = useRef(new Map()); const moved = useRef(false); const types = [...new Set(outputs.map((o) => o.type))]; const color = (t: string) => colors[t] ?? PALETTE[types.indexOf(t) % PALETTE.length]; const out = (oid: string) => outputs.find((o) => o.id === oid)!; const inp = (iid: string) => inputs.find((i) => i.id === iid)!; const plugged = (iid: string) => cables.find((c) => c.to === iid); // Jack centres in rack coordinates, kept fresh as the layout changes. const measure = useCallback(() => { const box = rack.current?.getBoundingClientRect(); if (!box) return; const next: Record = {}; jacks.current.forEach((el, k) => { const r = el.getBoundingClientRect(); next[k] = [r.left - box.left + r.width / 2, r.top - box.top + r.height / 2]; }); setPos(next); }, []); useLayoutEffect(() => { measure(); const ro = new ResizeObserver(measure); if (rack.current) ro.observe(rack.current); return () => ro.disconnect(); }, [measure]); // A freshly plugged cable swings once and settles. useEffect(() => { if (!settle) return; let raf = 0; const tick = () => { if (performance.now() - settle.t < 1100) { setPos((p) => ({ ...p })); raf = requestAnimationFrame(tick); } else setSettle(null); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [settle]); const slackFor = (to: string) => { if (!settle || settle.to !== to) return 0.22; const s = (performance.now() - settle.t) / 1000; return 0.22 + 0.28 * Math.exp(-s * 4.5) * Math.cos(s * 13); }; const commit = (next: Cable[], msg: string) => { setCables(next); onChange?.(next); setMessage(msg); }; const tryPlug = (from: string, to: string) => { const r = check(out(from), inp(to)); if (!r.ok) { setRefused({ to, reason: r.reason }); setMessage(`Refused. ${r.reason}.`); return false; } const prev = plugged(to); commit(connect(cables, { from, to }), `${out(from).name} connected to ${inp(to).name}${prev && prev.from !== from ? `, replacing ${out(prev.from).name}` : ""}.`); setRefused(null); const still = !motion || window.matchMedia("(prefers-reduced-motion: reduce)").matches; if (!still) setSettle({ to, t: performance.now() }); return true; }; const pull = (to: string) => { const c = plugged(to); if (!c) return; commit(unplug(cables, to), `${out(c.from).name} unplugged from ${inp(to).name}.`); }; useEffect(() => { if (!refused) return; const t = setTimeout(() => setRefused(null), 2600); return () => clearTimeout(t); }, [refused]); // Pointer: press a source jack and drag to a destination, or click one then the other. const local = (e: { clientX: number; clientY: number }): Pt => { const b = rack.current!.getBoundingClientRect(); return [e.clientX - b.left, e.clientY - b.top]; }; const targetAt = (e: { clientX: number; clientY: number }) => { for (const el of document.elementsFromPoint(e.clientX, e.clientY)) { const t = (el.closest("[data-in]") as HTMLElement | null)?.dataset.in; if (t) return t; } return null; }; const onOutDown = (e: ReactPointerEvent, oid: string) => { if (e.button !== 0) return; moved.current = false; setArmed(oid); setMessage(`Carrying a ${out(oid).type} cable from ${out(oid).name}. Choose a destination.`); const startX = e.clientX, startY = e.clientY; const move = (ev: PointerEvent) => { if (!moved.current && Math.hypot(ev.clientX - startX, ev.clientY - startY) < 6) return; moved.current = true; setDrag(local(ev)); setOver(targetAt(ev)); }; const up = (ev: PointerEvent) => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); setDrag(null); setOver(null); // The click that follows this release must not read as a keyboard press. setTimeout(() => { moved.current = false; }, 0); if (!moved.current) return; // a click: stay armed until a destination is clicked const t = targetAt(ev); if (t) tryPlug(oid, t); setArmed(null); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); }; useEffect(() => { if (!armed) return; const away = (e: PointerEvent) => { if (!(e.target as Element).closest?.("[data-in], .pbay__jack")) { setArmed(null); setMessage("Cable put down."); } }; document.addEventListener("pointerdown", away); return () => document.removeEventListener("pointerdown", away); }, [armed]); const onInClick = (iid: string) => { if (armed) { if (tryPlug(armed, iid)) setArmed(null); return; } pull(iid); }; // Keyboard: Enter on a source picks up a cable; arrows walk the destinations; Enter plugs; Escape drops it. const inIds = inputs.map((i) => i.id), outIds = outputs.map((o) => o.id); const focusJack = (k: string) => jacks.current.get(k)?.focus(); const walk = (e: KeyboardEvent, list: string[], cur: string) => { if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return false; e.preventDefault(); const i = list.indexOf(cur); focusJack(list[(i + (e.key === "ArrowDown" ? 1 : list.length - 1)) % list.length]); return true; }; const onOutKey = (e: KeyboardEvent, oid: string) => { if (walk(e, outIds, oid)) return; if (e.key === "Escape" && armed) { e.preventDefault(); setArmed(null); setMessage("Cable put down."); } }; const onOutClick = (oid: string) => { // Pointer presses arm in onOutDown; this handles Enter and Space. if (moved.current) return; if (armed === oid && !drag) return; setArmed(oid); setMessage(`Carrying a ${out(oid).type} cable from ${out(oid).name}. Choose a destination.`); const first = inputs.find((i) => check(out(oid), i).ok) ?? inputs[0]; requestAnimationFrame(() => focusJack(first.id)); }; const onInKey = (e: KeyboardEvent, iid: string) => { if (walk(e, inIds, iid)) return; if (e.key === "Escape" && armed) { e.preventDefault(); const a = armed; setArmed(null); setMessage("Cable put down."); focusJack(a); } if ((e.key === "Delete" || e.key === "Backspace") && !armed) { e.preventDefault(); pull(iid); } }; const describeOut = (o: Jack) => { const to = cables.filter((c) => c.from === o.id).map((c) => inp(c.to).name); return `${o.name} output, ${o.type}${to.length ? `, feeding ${to.join(" and ")}` : ""}${armed === o.id ? ", cable picked up" : ""}`; }; const describeIn = (i: Input) => { const c = plugged(i.id); const base = `${i.name} input, takes ${i.accepts.join(", ")}${c ? `, plugged from ${out(c.from).name}` : ""}`; if (!armed) return `${base}${c ? ". Press to unplug" : ""}`; const r = check(out(armed), i); return `${base}. ${r.ok ? `Press Enter to connect ${out(armed).name}` : `Refuses ${out(armed).type}`}`; }; const armedPos = armed ? pos[armed] : undefined; return (

{title}

{armed ? `Carrying ${out(armed).name} (${out(armed).type})` : `${cables.length} ${cables.length === 1 ? "cable" : "cables"} patched`}

    {outputs.map((o) => (
  • {o.name}{o.type}
  • ))}
    {inputs.map((i) => { const c = plugged(i.id); const verdict = armed ? check(out(armed), i) : null; return (
  • ); })}
Connections as a table {cables.length ? cables.map((c) => ( )) : }
FromSignalToAction
{out(c.from).name} {out(c.from).type} {inp(c.to).name}
Nothing patched yet.

{message}

); }