Cycle Ring Viz

Cycle nodes laid out around a ring with directed clockwise edges and animated pointer dots that glide along the perimeter. Designed for cycle-detection narratives — Floyd's tortoise/hare, Brent's algorithm, hash-chain cycle hunting — where the visual point is motion around the cycle, not the leading ρ-tail.

CycleRingViz is a pure layout primitive. The component does not run the algorithm; it places nodes on a circle and animates pointer dots to whatever positions the caller (or its internal autoplay timer) requests.

012345SF
Customize
Ring
6
84
Pointers

Installation

npx shadcn@latest add https://craftbits.dev/r/cycle-ring-viz.json

Usage

import { CycleRingViz, type CycleRingPointer } from "@craft-bits/core";
 
const pointers: CycleRingPointer[] = [
  { id: "slow", position: 0, label: "S" },
  { id: "fast", position: 0, label: "F" },
];
 
<CycleRingViz cycleLength={6} pointers={pointers} />;

Uncontrolled — let the component own pointer state and autoplay them around the ring:

<CycleRingViz
  cycleLength={8}
  defaultPointers={[{ id: "walker", position: 0, label: "i" }]}
  playing
  playSpeed={500}
/>

Driven by a real tortoise/hare loop from outside:

const [slow, setSlow] = useState(0);
const [fast, setFast] = useState(0);
 
useEffect(() => {
  const id = window.setInterval(() => {
    setSlow((s) => (s + 1) % 6);
    setFast((f) => (f + 2) % 6);
  }, 700);
  return () => window.clearInterval(id);
}, []);
 
<CycleRingViz
  cycleLength={6}
  pointers={[
    { id: "slow", position: slow, label: "S" },
    { id: "fast", position: fast, label: "F" },
  ]}
/>;

Understanding the component

  1. Uniform polar layout. Nodes are placed at angles starting from -PI/2 (twelve o'clock) and stepping clockwise by 2*PI / cycleLength. The ring radius defaults to a value scaled from the node count so small rings stay readable and large rings don't crowd.
  2. Directed edges via arrowEndpoint. Edges between adjacent ring nodes are shortened to stop at the node boundary using the shared arrowEndpoint helper, so arrowheads never collide with node circles. Marker geometry is pulled from the shared SvgDefs block.
  3. Pointer dots orbit the ring. Each CycleRingPointer carries a stable id, a cycle-node position, and a short label. The dot is drawn just outside the ring along the same angle as its target node — far enough to never overlap the node, close enough to read as "at" it.
  4. Glide via layoutId. Each pointer's group carries a stable layoutId. When position changes, motion handles the cross-fade and the glide along SPRINGS.smooth (or your transition override).
  5. Controlled + uncontrolled. Pass pointers + onPointerStep for controlled mode (the Radix pattern). Pass defaultPointers to let the component own its pointer set internally. The autoplay timer reads from a ref so it never re-subscribes mid-flight.
  6. Overlay slot. Anything you pass to overlay renders inside the SVG above the edges and beneath the pointer dots — typically a GapArc highlighting the chord between two pointers.
  7. Reduced motion. usePrefersReducedMotion() short-circuits every transition to instant. Node entries, edge draws, and pointer glides snap; autoplay never starts.

Props

PropTypeDefaultDescription
cycleLengthnumberrequiredNumber of nodes around the ring. Clamped to at least 2.
nodeLabelsreadonly string[]Optional per-node labels; missing entries fall back to numeric index.
pointersreadonly CycleRingPointer[]Controlled pointer set. Pair with onPointerStep.
defaultPointersreadonly CycleRingPointer[]Uncontrolled initial pointer set.
onPointerStep(id: string, next: number) => voidFires on every autoplay tick with the next wrapped position.
ringRadiusnumberscaled from cycleLengthRing radius in px.
playingbooleanfalseAutoplay every pointer by one each tick. Off under reduced motion.
playSpeednumber600Milliseconds between autoplay ticks (floored at 80 ms).
overlayReactNodeSVG content rendered above edges, beneath pointers.
transitionTransitionSPRINGS.smoothOverride the pointer-glide / edge-draw spring.
classNamestringMerged onto the outer SVG element.

Accessibility

  • The outer SVG is role="img" with an aria-label summarising the ring shape and each pointer's current position (for example, "Cycle ring with 6 nodes. S at node 2, F at node 4.").
  • Every node renders its label as a child text element, so position is never communicated through colour alone.
  • Motion respects prefers-reduced-motion: pointer glides, node entries, and edge draws all collapse to instant; autoplay is suppressed entirely.

Credits

  • Extracted from: algoflashcards (src/lessons/primitives/viz/CycleRingViz.tsx). The original was scoped to a specific lesson — it pulled a trackHex accent, hard-wired pointer colours from a tone palette, and rendered HTML chrome around the SVG. The library extract drops the lesson chrome, generalises pointers to a stable identifier-keyed shape, adds controlled / uncontrolled state on the Radix pattern, switches to the shared SvgDefs marker block, and routes node and edge dimensions through SVG_TOKENS so it sits next to DAGRenderer without visual drift.