feat(viz): heap pattern with kth largest

This commit is contained in:
2025-09-03 21:30:46 +01:00
parent cf0c2153db
commit 4311e97d24
7 changed files with 1153 additions and 1 deletions

View File

@@ -0,0 +1,71 @@
'use client';
import { useVisualization } from '@/lib/visualizations/use-visualization';
import type { AlgorithmDefinition } from '@/lib/visualizations/types';
import { VisualizationContainer } from '../core/visualization-container';
import { HeapView } from '../data-structures/heap-view';
import { ArrayView } from '../data-structures/array-view';
interface HeapVisualizationProps {
algorithm: AlgorithmDefinition;
className?: string;
}
export function HeapVisualization({
algorithm,
className,
}: HeapVisualizationProps) {
const {
currentStep,
currentStepIndex,
totalSteps,
playback,
controls,
currentPhase,
progress,
} = useVisualization(algorithm);
const { dataState } = currentStep;
const heap = dataState.heaps?.[0] ?? null;
const inputArray = dataState.arrays?.[0] ?? null;
return (
<VisualizationContainer
title={algorithm.title}
pattern={algorithm.pattern}
code={algorithm.code}
currentLine={currentStep.codeLine}
highlightLines={currentStep.codeHighlightLines}
explanation={currentStep.explanation}
decision={currentStep.decision}
phase={currentPhase}
variables={dataState.variables}
currentStepIndex={currentStepIndex}
totalSteps={totalSteps}
isPlaying={playback.isPlaying}
speed={playback.speed}
controls={controls}
progress={progress}
className={className}
>
<div className="flex flex-col items-center gap-6">
{/* Input array */}
{inputArray && (
<ArrayView
array={inputArray}
pointers={dataState.pointers}
elementSize="sm"
/>
)}
{/* Heap visualization */}
{heap && (
<HeapView
heap={heap}
showArrayView={true}
/>
)}
</div>
</VisualizationContainer>
);
}

View File

@@ -0,0 +1,195 @@
'use client';
import { useMemo } from 'react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
import type { HeapState, HeapNodeState } from '@/lib/visualizations/types';
import { HeapNode } from '../primitives/heap-node';
interface HeapViewProps {
heap: HeapState;
className?: string;
showArrayView?: boolean;
}
const NODE_RADIUS = 24;
const LEVEL_HEIGHT = 72;
const MIN_NODE_SPACING = 60;
const SVG_PADDING = 32;
const ARRAY_HEIGHT = 60;
interface NodePosition {
node: HeapNodeState;
x: number;
y: number;
parentX?: number;
parentY?: number;
}
function calculateHeapLayout(elements: HeapNodeState[], totalWidth: number): NodePosition[] {
const positions: NodePosition[] = [];
if (elements.length === 0) return positions;
for (let i = 0; i < elements.length; i++) {
const level = Math.floor(Math.log2(i + 1));
const posInLevel = i - (Math.pow(2, level) - 1);
const nodesInLevel = Math.pow(2, level);
const levelWidth = totalWidth / nodesInLevel;
const x = levelWidth * (posInLevel + 0.5);
const y = SVG_PADDING + level * LEVEL_HEIGHT + NODE_RADIUS;
let parentX: number | undefined;
let parentY: number | undefined;
if (i > 0) {
const parentIndex = Math.floor((i - 1) / 2);
const parentLevel = Math.floor(Math.log2(parentIndex + 1));
const parentPosInLevel = parentIndex - (Math.pow(2, parentLevel) - 1);
const parentNodesInLevel = Math.pow(2, parentLevel);
const parentLevelWidth = totalWidth / parentNodesInLevel;
parentX = parentLevelWidth * (parentPosInLevel + 0.5);
parentY = SVG_PADDING + parentLevel * LEVEL_HEIGHT + NODE_RADIUS;
}
positions.push({
node: elements[i],
x,
y,
parentX,
parentY,
});
}
return positions;
}
export function HeapView({
heap,
className,
showArrayView = true,
}: HeapViewProps) {
const { positions, svgWidth, svgHeight, treeHeight } = useMemo(() => {
if (heap.elements.length === 0) {
return { positions: [], svgWidth: 200, svgHeight: 100, treeHeight: 50 };
}
const depth = Math.floor(Math.log2(heap.elements.length)) + 1;
const maxNodesAtBottom = Math.pow(2, depth - 1);
const width = Math.max(
maxNodesAtBottom * MIN_NODE_SPACING + SVG_PADDING * 2,
200
);
const treeH = depth * LEVEL_HEIGHT + SVG_PADDING;
const height = treeH + (showArrayView ? ARRAY_HEIGHT : SVG_PADDING);
const pos = calculateHeapLayout(heap.elements, width);
return { positions: pos, svgWidth: width, svgHeight: height, treeHeight: treeH };
}, [heap.elements, showArrayView]);
const heapTypeLabel = heap.heapType === 'min' ? 'Min-Heap' : 'Max-Heap';
const sizeLabel = heap.maxSize ? ` (size ≤ ${heap.maxSize})` : '';
return (
<div className={cn('flex flex-col items-center', className)}>
{(heap.label || true) && (
<span className="mb-2 text-sm font-medium text-[var(--muted-foreground)]">
{heap.label || heapTypeLabel}{sizeLabel}
</span>
)}
<svg
width={svgWidth}
height={svgHeight}
viewBox={`0 0 ${svgWidth} ${svgHeight}`}
className="overflow-visible"
>
{/* Draw edges first (behind nodes) */}
{positions.map(({ node, x, y, parentX, parentY }) =>
parentX !== undefined && parentY !== undefined ? (
<motion.line
key={`edge-${node.id}`}
x1={parentX}
y1={parentY + NODE_RADIUS}
x2={x}
y2={y - NODE_RADIUS}
className="stroke-[var(--border)]"
strokeWidth={2}
initial={false}
animate={{
opacity: node.state === 'removing' ? 0.3 : 1,
}}
transition={{ duration: 0.2 }}
/>
) : null
)}
{/* Draw nodes */}
{positions.map(({ node, x, y }) => (
<HeapNode
key={node.id}
node={node}
x={x}
y={y}
radius={NODE_RADIUS}
/>
))}
{/* Root indicator (kth largest for min-heap top-k) */}
{positions.length > 0 && positions[0].node.state === 'highlighted' && (
<g>
<motion.polygon
points={`${positions[0].x},${positions[0].y - NODE_RADIUS - 8} ${positions[0].x - 6},${positions[0].y - NODE_RADIUS - 18} ${positions[0].x + 6},${positions[0].y - NODE_RADIUS - 18}`}
className="fill-[var(--primary)]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
/>
<motion.text
x={positions[0].x}
y={positions[0].y - NODE_RADIUS - 24}
textAnchor="middle"
className="fill-[var(--primary)] text-xs font-medium"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
>
root
</motion.text>
</g>
)}
{/* Array representation below tree */}
{showArrayView && heap.elements.length > 0 && (
<g transform={`translate(0, ${treeHeight})`}>
<text
x={SVG_PADDING}
y={12}
className="fill-[var(--muted-foreground)] text-xs"
>
Array: [{heap.elements.map(e => e.value).join(', ')}]
</text>
{/* Index markers */}
<text
x={SVG_PADDING}
y={28}
className="fill-[var(--muted-foreground)] text-[10px] opacity-70"
>
idx: {heap.elements.map((_, i) => i).join(' ')}
</text>
</g>
)}
{/* Empty state indicator */}
{heap.elements.length === 0 && (
<text
x={svgWidth / 2}
y={svgHeight / 2}
textAnchor="middle"
className="fill-[var(--muted-foreground)] text-sm italic"
>
(empty)
</text>
)}
</svg>
</div>
);
}

View File

@@ -15,6 +15,8 @@ export { StackElement } from "./primitives/stack-element";
export { QueueElement } from "./primitives/queue-element";
export { TreeNode } from "./primitives/tree-node";
export { GridCell } from "./primitives/grid-cell";
export { DecisionNode } from "./primitives/decision-node";
export { HeapNode } from "./primitives/heap-node";
// Data structures
export { ArrayView } from "./data-structures/array-view";
@@ -23,6 +25,8 @@ export { StackView } from "./data-structures/stack-view";
export { QueueView } from "./data-structures/queue-view";
export { BinaryTreeView } from "./data-structures/binary-tree-view";
export { GridView } from "./data-structures/grid-view";
export { DecisionTreeView } from "./data-structures/decision-tree-view";
export { HeapView } from "./data-structures/heap-view";
// Algorithm visualizations
export { MonotonicStackVisualization } from "./algorithms/monotonic-stack";
@@ -33,3 +37,5 @@ export { DFSVisualization } from "./algorithms/dfs";
export { TwoPointersVisualization } from "./algorithms/two-pointers";
export { LinkedListVisualization } from "./algorithms/linked-list";
export { CoinChangeVisualization } from "./algorithms/coin-change";
export { BacktrackingVisualization } from "./algorithms/backtracking";
export { HeapVisualization } from "./algorithms/heap";

View File

@@ -0,0 +1,87 @@
'use client';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
import type { HeapNodeState } from '@/lib/visualizations/types';
interface HeapNodeProps {
node: HeapNodeState;
x: number;
y: number;
radius?: number;
className?: string;
}
const STATE_CLASSES = {
normal: 'fill-[var(--surface-variant)] stroke-[var(--border)]',
comparing: 'fill-[var(--info)]/20 stroke-[var(--info)]',
swapping: 'fill-[var(--warning)]/20 stroke-[var(--warning)]',
settled: 'fill-[var(--success)]/20 stroke-[var(--success)]',
highlighted: 'fill-[var(--primary)]/30 stroke-[var(--primary)]',
removing: 'fill-[var(--destructive)]/20 stroke-[var(--destructive)] opacity-50',
} as const;
const TEXT_CLASSES = {
normal: 'fill-[var(--foreground)]',
comparing: 'fill-[var(--info)]',
swapping: 'fill-[var(--warning)]',
settled: 'fill-[var(--success)]',
highlighted: 'fill-[var(--primary)]',
removing: 'fill-[var(--destructive)] opacity-50',
} as const;
export function HeapNode({
node,
x,
y,
radius = 24,
className,
}: HeapNodeProps) {
const isActive = node.state === 'comparing' || node.state === 'swapping' || node.state === 'highlighted';
const isRemoving = node.state === 'removing';
return (
<motion.g
initial={false}
animate={{
scale: isActive ? 1.1 : isRemoving ? 0.8 : 1,
opacity: isRemoving ? 0.5 : 1,
}}
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
}}
style={{ transformOrigin: `${x}px ${y}px` }}
className={cn(className)}
>
<motion.circle
cx={x}
cy={y}
r={radius}
strokeWidth={2}
className={cn(
'transition-colors duration-200',
STATE_CLASSES[node.state]
)}
initial={false}
animate={{
filter: isActive ? 'drop-shadow(0 0 8px var(--primary))' : 'none',
}}
transition={{ duration: 0.2 }}
/>
<text
x={x}
y={y}
textAnchor="middle"
dominantBaseline="central"
className={cn(
'pointer-events-none select-none font-mono text-sm font-medium',
TEXT_CLASSES[node.state]
)}
>
{node.value}
</text>
</motion.g>
);
}