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.

n0n1n2n3n4n5TH
Customize
Graph
2
4
68
Pointers

Installation

npx shadcn@latest add https://craftbits.dev/r/loop-trap.json

Usage

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

  1. Two layout modes. If any node carries x / y, the component uses caller coordinates verbatim. Otherwise it auto-builds a ρ-shape: the first tailLength nodes 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.
  2. Directed edges via arrowEndpoint. Edges are shortened to stop at each node's boundary using the shared arrowEndpoint helper, so arrowheads never overlap node circles. Marker geometry comes from the shared SvgDefs block — the same arrows you see in DAGRenderer, CycleRingViz, and LinkedListViz.
  3. Step-driven pointers. Every walker is described by a { id, label, at } tuple inside step.pointers. The component reads at to position the dot above the corresponding node; when at changes between renders, motion glides the dot to the new position via layoutId.
  4. 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.
  5. Edge highlight on traversal. Edges whose from and to are both currently occupied are drawn bold in the accent colour. That is how the eye reads "the walker just stepped this edge".
  6. Meet halo. When step.meet is true AND a node holds two or more pointers, an accent halo pulses around that node — the canonical "tortoise meets hare" beat.
  7. Reduced motion. usePrefersReducedMotion() collapses every transition to instant — node entries, edge draws, pointer glides, and the meet halo all snap.

Props

PropTypeDefaultDescription
nodesLoopTrapNode[]requiredNodes in the ρ-graph. Each carries id, optional label, and optional x / y coordinates.
edgesLoopTrapEdge[]requiredDirected edges. Cycles are expected.
stepLoopTrapSteprequiredCurrent algorithm step: pointers array plus optional meet flag.
nodeRadiusnumber18Node circle radius in px.
tailLengthnumber2Auto-layout only — how many leading nodes form the ρ-tail.
cycleRadiusnumber70Auto-layout only — radius of the cycle ring.
transitionTransitionSPRINGS.smoothOverride the spring driving pointer glide and node enter motion.
classNamestringMerged onto the outer SVG element via cn().

Accessibility

  • The outer SVG is role="img" with an aria-label summarising 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.