Dual Heap Vis
Two binary heaps drawn as top-down trees with a median callout between them. A max-heap on the left holds the smaller half of the stream (its root is the largest of the lower half); a min-heap on the right holds the larger half (its root is the smallest of the upper half). With balanced sizes the median is the root of the larger heap (odd total) or the average of the two roots (even total) — the textbook "median-of-stream" data structure.
Preview
Customize
Layout
Installation
npx shadcn@latest add https://craftbits.dev/r/dual-heap-vis.jsonUsage
import { DualHeapVis } from "@craft-bits/core";
<DualHeapVis lowHeap={[5, 3, 4, 1, 2]} highHeap={[6, 8, 7, 9]} />Drive it from your own algorithm reducer. Each value gets a stable layoutId keyed to side + value — so when a rebalance moves the max-heap root over to the min-heap (or vice versa), the node glides across the gutter instead of remounting:
const [low, setLow] = useState<number[]>([]);
const [high, setHigh] = useState<number[]>([]);
<DualHeapVis lowHeap={low} highHeap={high} />Vertical layout:
<DualHeapVis
lowHeap={[5, 3, 4]}
highHeap={[6, 8, 7]}
direction="vertical"
/>Understanding the component
- Array-indexed binary heap. Each heap is a flat array; children of slot
ilive at2i + 1and2i + 2. The renderer trusts the order — it does not re-heapify. Push values that satisfy the heap invariant or expect a wrong visual. - Two sub-canvases + a gutter. In
direction="horizontal"mode the max-heap occupies the left pane, the min-heap occupies the right pane, and a fixed-width gutter sits between them holding the median callout.direction="vertical"stacks them with the max-heap on top. - Glide-on-rebalance. Every node carries a stable
layoutIdkeyed by namespace + side + value. When a caller moves a value fromlowHeaptohighHeap(or vice versa) between renders, Motion's layout engine animates the circle across the gutter as a single transform instead of unmount + mount. - Median derivation. When
medianis omitted, the component derives it from heap sizes + roots: empty →null, odd total → root of the larger heap, even total → average of the two roots. Passmedianexplicitly when your algorithm computes it differently. - Reduced motion. When
prefers-reduced-motion: reduceis set, every spring collapses to{ duration: 0 }— pushes, pops, and the cross-gutter glide all snap into place.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
lowHeap | readonly number[] | required | Max-heap (lower half) as a flat array. [0] is the root and must be the largest of the lower half. |
highHeap | readonly number[] | required | Min-heap (upper half) as a flat array. [0] is the root and must be the smallest of the upper half. |
median | number | null | derived | Precomputed median. Omit to let the component derive it. |
showLabels | boolean | true | Render the heap headers above each pane. |
direction | "horizontal" | "vertical" | "horizontal" | Layout orientation. |
className | string | — | Merged onto the outer <svg>. |
Accessibility
- The outer
<svg>isrole="img"with anaria-labelsummarising both heap sizes, both roots, and the current median ("Max-heap of size 5, root 5; Min-heap of size 4, root 6; median 5."). - Every node is a
<g>withrole="img"and its ownaria-labeldescribing side, slot, value, and whether it's the root. - The median gutter is wrapped in an
aria-live="polite"region — assistive tech announces the new median after a push or rebalance without stealing focus. - Color is never the sole signal: roots are accent-tinted and carry
data-root="true"; values render in tabular numerals. - Motion respects
prefers-reduced-motion: enter / exit /layoutIdglides all collapse to instant when the user has opted out.
Credits
- Extracted from:
algoflashcards(src/lessons/primitives/viz/DualHeapVis.tsx). The source primitive was wired into a single lesson'sSabotageWidget— it carried arebalanceFrommigration flag, anunreliablebadge, and ahextrack-color prop. The library extract reframes the surface around the data-structure-agnostic shape (two heaps + a median), useslayoutIdglides keyed to value so any callsite gets the cross-gutter animation for free, and consumes--cb-accentfrom the theme instead of a per-instance track hex.