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
Max-Heap (lower half)Min-Heap (upper half)534126879median5
Customize
Layout

Installation

npx shadcn@latest add https://craftbits.dev/r/dual-heap-vis.json

Usage

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

  1. Array-indexed binary heap. Each heap is a flat array; children of slot i live at 2i + 1 and 2i + 2. The renderer trusts the order — it does not re-heapify. Push values that satisfy the heap invariant or expect a wrong visual.
  2. 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.
  3. Glide-on-rebalance. Every node carries a stable layoutId keyed by namespace + side + value. When a caller moves a value from lowHeap to highHeap (or vice versa) between renders, Motion's layout engine animates the circle across the gutter as a single transform instead of unmount + mount.
  4. Median derivation. When median is 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. Pass median explicitly when your algorithm computes it differently.
  5. Reduced motion. When prefers-reduced-motion: reduce is set, every spring collapses to { duration: 0 } — pushes, pops, and the cross-gutter glide all snap into place.

Props

PropTypeDefaultDescription
lowHeapreadonly number[]requiredMax-heap (lower half) as a flat array. [0] is the root and must be the largest of the lower half.
highHeapreadonly number[]requiredMin-heap (upper half) as a flat array. [0] is the root and must be the smallest of the upper half.
mediannumber | nullderivedPrecomputed median. Omit to let the component derive it.
showLabelsbooleantrueRender the heap headers above each pane.
direction"horizontal" | "vertical""horizontal"Layout orientation.
classNamestringMerged onto the outer <svg>.

Accessibility

  • The outer <svg> is role="img" with an aria-label summarising 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> with role="img" and its own aria-label describing 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 / layoutId glides 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's SabotageWidget — it carried a rebalanceFrom migration flag, an unreliable badge, and a hex track-color prop. The library extract reframes the surface around the data-structure-agnostic shape (two heaps + a median), uses layoutId glides keyed to value so any callsite gets the cross-gutter animation for free, and consumes --cb-accent from the theme instead of a per-instance track hex.