Loop Trap
A directed ρ-graph (tail + cycle) with animated walker pointer dots that glide between nodes as the caller advances a single step. Designed for cycle-detection narratives — Floyd's tortoise-and-hare, Brent's algorithm, hash-chain loop hunting — where the visual point is the moment two walkers traverse the cycle at different speeds and collide.
LoopTrap is a pure layout primitive. The component does not run the algorithm; it lays the graph out, draws directed edges, and glides labelled pointer dots to whatever node the caller asks. Pair it with any cycle-finding loop in your own state to get the canonical "tortoise meets hare" picture.
Installation
npx shadcn@latest add https://craftbits.dev/r/loop-trap.jsonUsage
import { LoopTrap } from "@craft-bits/core";
const nodes = [
{ id: "n0" }, { id: "n1" }, { id: "n2" },
{ id: "n3" }, { id: "n4" }, { id: "n5" },
];
const edges = [
{ from: "n0", to: "n1" },
{ from: "n1", to: "n2" },
{ from: "n2", to: "n3" },
{ from: "n3", to: "n4" },
{ from: "n4", to: "n5" },
{ from: "n5", to: "n2" },
];
<LoopTrap
nodes={nodes}
edges={edges}
tailLength={2}
step={{
pointers: [
{ id: "tortoise", label: "T", at: "n2" },
{ id: "hare", label: "H", at: "n4" },
],
}}
/>;Driven by a real tortoise/hare loop from parent state:
const [tortoise, setTortoise] = useState("n0");
const [hare, setHare] = useState("n0");
useEffect(() => {
const id = window.setInterval(() => {
setTortoise((t) => next[t]);
setHare((h) => next[next[h]]);
}, 750);
return () => window.clearInterval(id);
}, []);
<LoopTrap
nodes={nodes}
edges={edges}
tailLength={2}
step={{
pointers: [
{ id: "tortoise", label: "T", at: tortoise },
{ id: "hare", label: "H", at: hare },
],
meet: tortoise === hare,
}}
/>;Explicit coordinates — opt out of auto-layout for a bespoke shape:
<LoopTrap
nodes={[
{ id: "a", x: 40, y: 80 },
{ id: "b", x: 110, y: 80 },
{ id: "c", x: 180, y: 40 },
{ id: "d", x: 220, y: 120 },
]}
edges={[
{ from: "a", to: "b" },
{ from: "b", to: "c" },
{ from: "c", to: "d" },
{ from: "d", to: "b" },
]}
step={{ pointers: [{ id: "i", label: "i", at: "c" }] }}
/>Understanding the component
- Two layout modes. If any node carries
x/y, the component uses caller coordinates verbatim. Otherwise it auto-builds a ρ-shape: the firsttailLengthnodes are placed left-to-right as the tail, the remaining nodes are arranged clockwise around a circle as the cycle, and the tail's last node lands next to cycle index 0. - Directed edges via
arrowEndpoint. Edges are shortened to stop at each node's boundary using the sharedarrowEndpointhelper, so arrowheads never overlap node circles. Marker geometry comes from the sharedSvgDefsblock — the same arrows you see inDAGRenderer,CycleRingViz, andLinkedListViz. - Step-driven pointers. Every walker is described by a
{ id, label, at }tuple insidestep.pointers. The component readsatto position the dot above the corresponding node; whenatchanges between renders, motion glides the dot to the new position vialayoutId. - Pointer fan-out. When two or more pointers share a node, they are stacked vertically above the node so both labels stay visible — no overlap, no shimmer.
- Edge highlight on traversal. Edges whose
fromandtoare both currently occupied are drawn bold in the accent colour. That is how the eye reads "the walker just stepped this edge". - Meet halo. When
step.meetistrueAND a node holds two or more pointers, an accent halo pulses around that node — the canonical "tortoise meets hare" beat. - Reduced motion.
usePrefersReducedMotion()collapses every transition to instant — node entries, edge draws, pointer glides, and the meet halo all snap.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
nodes | LoopTrapNode[] | required | Nodes in the ρ-graph. Each carries id, optional label, and optional x / y coordinates. |
edges | LoopTrapEdge[] | required | Directed edges. Cycles are expected. |
step | LoopTrapStep | required | Current algorithm step: pointers array plus optional meet flag. |
nodeRadius | number | 18 | Node circle radius in px. |
tailLength | number | 2 | Auto-layout only — how many leading nodes form the ρ-tail. |
cycleRadius | number | 70 | Auto-layout only — radius of the cycle ring. |
transition | Transition | SPRINGS.smooth | Override the spring driving pointer glide and node enter motion. |
className | string | — | Merged onto the outer SVG element via cn(). |
Accessibility
- The outer SVG is
role="img"with anaria-labelsummarising the graph shape, current pointer positions, and whether the pointers have met (for example, "Loop-trap graph: 6 nodes (~2 tail), 6 edges. T at n3, H at n3 — pointers meet."). - Every node renders its label as a child text element, so position is never communicated through colour alone.
- Pointer dots fan out vertically when they share a node, so the "two pointers at the same node" state is visible without colour cues.
- Motion respects
prefers-reduced-motion: pointer glides, node entries, edge draws, and the meet halo all collapse to instant.
Credits
- Extracted from:
algoflashcards(src/lessons/primitives/construction/LoopTrap.tsx). The original was a four-act bespoke lesson component coupling cycle visualisation to scoring, audio cues, a "(Not Responding)" freeze overlay, and binary-search-specific diagnosis questions. The library extract keeps only the cycle-detection visualisation primitive — directed ρ-graph, walker pointer dots, step-driven glide, meet halo — and lets the caller compose any pedagogy, scoring, audio, or freeze theatre on top.