feat(viz): tree/BFS/DFS patterns

This commit is contained in:
2025-09-01 20:49:11 +01:00
parent ffecb9e591
commit ed03d3251e
13 changed files with 2119 additions and 1 deletions

View File

@@ -0,0 +1,85 @@
'use client';
import { useVisualization } from '@/lib/visualizations/use-visualization';
import type { AlgorithmDefinition } from '@/lib/visualizations/types';
import { VisualizationContainer } from '../core/visualization-container';
import { BinaryTreeView } from '../data-structures/binary-tree-view';
import { QueueView } from '../data-structures/queue-view';
import { ArrayView } from '../data-structures/array-view';
interface BFSVisualizationProps {
algorithm: AlgorithmDefinition;
className?: string;
}
export function BFSVisualization({
algorithm,
className,
}: BFSVisualizationProps) {
const {
currentStep,
currentStepIndex,
totalSteps,
playback,
controls,
currentPhase,
progress,
} = useVisualization(algorithm);
const { dataState } = currentStep;
const tree = dataState.trees?.[0] ?? null;
const bfsQueue = dataState.queues?.[0] ?? null;
const outputArray = dataState.arrays?.[0] ?? null;
// Find the current node ID based on node states
const currentNodeId = tree?.nodes.find((n) => n.state === 'current')?.id;
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 gap-6">
{/* Top: Binary tree */}
{tree && (
<BinaryTreeView
tree={tree}
currentNodeId={currentNodeId}
/>
)}
{/* Bottom: Queue + Output */}
<div className="flex flex-col items-center gap-4">
{/* BFS Queue */}
{bfsQueue && (
<QueueView queue={bfsQueue} />
)}
{/* Output array */}
{outputArray && (
<ArrayView
array={outputArray}
pointers={dataState.pointers}
elementSize="sm"
showIndices={false}
/>
)}
</div>
</div>
</VisualizationContainer>
);
}

View File

@@ -0,0 +1,85 @@
'use client';
import { useVisualization } from '@/lib/visualizations/use-visualization';
import type { AlgorithmDefinition } from '@/lib/visualizations/types';
import { VisualizationContainer } from '../core/visualization-container';
import { BinaryTreeView } from '../data-structures/binary-tree-view';
import { StackView } from '../data-structures/stack-view';
import { ArrayView } from '../data-structures/array-view';
interface DFSVisualizationProps {
algorithm: AlgorithmDefinition;
className?: string;
}
export function DFSVisualization({
algorithm,
className,
}: DFSVisualizationProps) {
const {
currentStep,
currentStepIndex,
totalSteps,
playback,
controls,
currentPhase,
progress,
} = useVisualization(algorithm);
const { dataState } = currentStep;
const tree = dataState.trees?.[0] ?? null;
const dfsStack = dataState.stacks?.[0] ?? null;
const outputArray = dataState.arrays?.[0] ?? null;
// Find the current node ID based on node states
const currentNodeId = tree?.nodes.find((n) => n.state === 'current')?.id;
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 items-start gap-8">
{/* Left: Binary tree */}
{tree && (
<BinaryTreeView
tree={tree}
currentNodeId={currentNodeId}
/>
)}
{/* Right side: Stack + Output */}
<div className="flex flex-col gap-6">
{/* DFS Stack */}
{dfsStack && (
<StackView stack={dfsStack} />
)}
{/* Output array */}
{outputArray && (
<ArrayView
array={outputArray}
pointers={dataState.pointers}
elementSize="sm"
showIndices={false}
/>
)}
</div>
</div>
</VisualizationContainer>
);
}

View File

@@ -0,0 +1,85 @@
'use client';
import { useVisualization } from '@/lib/visualizations/use-visualization';
import type { AlgorithmDefinition } from '@/lib/visualizations/types';
import { VisualizationContainer } from '../core/visualization-container';
import { BinaryTreeView } from '../data-structures/binary-tree-view';
import { StackView } from '../data-structures/stack-view';
import { ArrayView } from '../data-structures/array-view';
interface TreeTraversalVisualizationProps {
algorithm: AlgorithmDefinition;
className?: string;
}
export function TreeTraversalVisualization({
algorithm,
className,
}: TreeTraversalVisualizationProps) {
const {
currentStep,
currentStepIndex,
totalSteps,
playback,
controls,
currentPhase,
progress,
} = useVisualization(algorithm);
const { dataState } = currentStep;
const tree = dataState.trees?.[0] ?? null;
const traversalStack = dataState.stacks?.[0] ?? null;
const outputArray = dataState.arrays?.[0] ?? null;
// Find the current node ID based on node states
const currentNodeId = tree?.nodes.find((n) => n.state === 'current')?.id;
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 items-start gap-8">
{/* Left: Binary tree */}
{tree && (
<BinaryTreeView
tree={tree}
currentNodeId={currentNodeId}
/>
)}
{/* Right side: Stack + Output */}
<div className="flex flex-col gap-6">
{/* Traversal stack */}
{traversalStack && (
<StackView stack={traversalStack} />
)}
{/* Output array */}
{outputArray && (
<ArrayView
array={outputArray}
pointers={dataState.pointers}
elementSize="sm"
showIndices={false}
/>
)}
</div>
</div>
</VisualizationContainer>
);
}

View File

@@ -0,0 +1,175 @@
'use client';
import { useMemo } from 'react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
import type { BinaryTreeState, BinaryTreeNodeState } from '@/lib/visualizations/types';
import { TreeNode } from '../primitives/tree-node';
interface BinaryTreeViewProps {
tree: BinaryTreeState;
currentNodeId?: string;
className?: string;
}
const NODE_RADIUS = 24;
const LEVEL_HEIGHT = 72;
const MIN_NODE_SPACING = 56;
const SVG_PADDING = 32;
interface NodePosition {
node: BinaryTreeNodeState;
x: number;
y: number;
parentX?: number;
parentY?: number;
}
function buildNodeMap(nodes: BinaryTreeNodeState[]): Map<string, BinaryTreeNodeState> {
const map = new Map<string, BinaryTreeNodeState>();
for (const node of nodes) {
map.set(node.id, node);
}
return map;
}
function calculateTreeDepth(
nodeId: string | null,
nodeMap: Map<string, BinaryTreeNodeState>
): number {
if (!nodeId) return 0;
const node = nodeMap.get(nodeId);
if (!node) return 0;
const leftDepth = calculateTreeDepth(node.left, nodeMap);
const rightDepth = calculateTreeDepth(node.right, nodeMap);
return 1 + Math.max(leftDepth, rightDepth);
}
function calculatePositions(
tree: BinaryTreeState,
totalWidth: number
): NodePosition[] {
const nodeMap = buildNodeMap(tree.nodes);
const positions: NodePosition[] = [];
function traverse(
nodeId: string | null,
level: number,
left: number,
right: number,
parentX?: number,
parentY?: number
) {
if (!nodeId) return;
const node = nodeMap.get(nodeId);
if (!node) return;
const x = (left + right) / 2;
const y = SVG_PADDING + level * LEVEL_HEIGHT + NODE_RADIUS;
positions.push({ node, x, y, parentX, parentY });
const childWidth = (right - left) / 2;
traverse(node.left, level + 1, left, left + childWidth, x, y);
traverse(node.right, level + 1, left + childWidth, right, x, y);
}
traverse(tree.rootId, 0, 0, totalWidth);
return positions;
}
export function BinaryTreeView({
tree,
currentNodeId,
className,
}: BinaryTreeViewProps) {
const { positions, svgWidth, svgHeight } = useMemo(() => {
const nodeMap = buildNodeMap(tree.nodes);
const depth = calculateTreeDepth(tree.rootId, nodeMap);
const maxNodesAtBottom = Math.pow(2, depth - 1);
const width = Math.max(
maxNodesAtBottom * MIN_NODE_SPACING + SVG_PADDING * 2,
200
);
const height = depth * LEVEL_HEIGHT + SVG_PADDING * 2;
const pos = calculatePositions(tree, width);
return { positions: pos, svgWidth: width, svgHeight: height };
}, [tree]);
return (
<div className={cn('flex flex-col items-center', className)}>
{tree.label && (
<span className="mb-2 text-sm font-medium text-[var(--muted-foreground)]">
{tree.label}
</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 === 'visited' ? 0.5 : 1,
}}
transition={{ duration: 0.2 }}
/>
) : null
)}
{/* Draw nodes */}
{positions.map(({ node, x, y }) => (
<TreeNode
key={node.id}
node={node}
x={x}
y={y}
radius={NODE_RADIUS}
/>
))}
{/* Current node indicator arrow */}
{currentNodeId && (() => {
const currentPos = positions.find((p) => p.node.id === currentNodeId);
if (!currentPos) return null;
const { x, y } = currentPos;
const arrowY = y + NODE_RADIUS + 12;
return (
<g key={`current-indicator-${currentNodeId}`}>
<motion.polygon
points={`${x},${arrowY} ${x - 6},${arrowY + 10} ${x + 6},${arrowY + 10}`}
className="fill-[var(--primary)]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
/>
<motion.text
x={x}
y={arrowY + 24}
textAnchor="middle"
className="fill-[var(--primary)] text-xs font-medium"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
>
curr
</motion.text>
</g>
);
})()}
</svg>
</div>
);
}

View File

@@ -0,0 +1,55 @@
'use client';
import { AnimatePresence } from 'framer-motion';
import { cn } from '@/lib/utils';
import type { QueueState } from '@/lib/visualizations/types';
import { QueueElement } from '../primitives/queue-element';
interface QueueViewProps {
queue: QueueState;
className?: string;
}
export function QueueView({ queue, className }: QueueViewProps) {
const hasElements = queue.elements.length > 0;
return (
<div className={cn('flex flex-col items-center', className)}>
{queue.label && (
<span className="mb-2 whitespace-nowrap text-sm font-medium text-[var(--muted-foreground)]">
{queue.label}
</span>
)}
<div className="relative flex min-w-[88px] flex-row items-center gap-1">
{/* Empty state */}
{!hasElements && (
<div className="flex h-10 w-10 items-center justify-center rounded-lg border-2 border-dashed border-[var(--border)] text-xs text-[var(--muted-foreground)]">
empty
</div>
)}
{/* Queue elements - horizontal row, front on left, rear on right */}
<AnimatePresence mode="popLayout">
{queue.elements.map((element, index) => (
<QueueElement
key={element.id}
element={element}
isFront={index === 0}
isRear={index === queue.elements.length - 1 && queue.elements.length > 1}
/>
))}
</AnimatePresence>
</div>
{/* Direction indicator */}
<div className="mt-4 flex items-center gap-2 text-[10px] text-[var(--muted-foreground)]">
<span>dequeue</span>
<span></span>
<span className="w-8 border-t border-dashed border-[var(--border)]" />
<span></span>
<span>enqueue</span>
</div>
</div>
);
}

View File

@@ -12,14 +12,21 @@ export { CalculationBubble } from "./primitives/calculation-bubble";
export { LinkedListNode } from "./primitives/linked-list-node";
export { LinkedListPointer } from "./primitives/linked-list-pointer";
export { StackElement } from "./primitives/stack-element";
export { QueueElement } from "./primitives/queue-element";
export { TreeNode } from "./primitives/tree-node";
// Data structures
export { ArrayView } from "./data-structures/array-view";
export { LinkedListView } from "./data-structures/linked-list-view";
export { StackView } from "./data-structures/stack-view";
export { QueueView } from "./data-structures/queue-view";
export { BinaryTreeView } from "./data-structures/binary-tree-view";
// Algorithm visualizations
export { MonotonicStackVisualization } from "./algorithms/monotonic-stack";
export { PrefixSumVisualization } from "./algorithms/prefix-sum";
export { TreeTraversalVisualization } from "./algorithms/tree-traversal";
export { BFSVisualization } from "./algorithms/bfs";
export { DFSVisualization } from "./algorithms/dfs";
export { TwoPointersVisualization } from "./algorithms/two-pointers";
export { LinkedListVisualization } from "./algorithms/linked-list";

View File

@@ -0,0 +1,61 @@
'use client';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
import type { QueueElementState } from '@/lib/visualizations/types';
interface QueueElementProps {
element: QueueElementState;
isFront?: boolean;
isRear?: boolean;
className?: string;
}
const STATE_CLASSES = {
normal: 'bg-[var(--surface-variant)] border-[var(--border)] text-[var(--foreground)]',
highlighted: 'bg-[var(--primary)]/20 border-[var(--primary)] text-[var(--primary)]',
enqueued: 'bg-[var(--success)]/20 border-[var(--success)] text-[var(--success)]',
dequeued: 'bg-[var(--error)]/20 border-[var(--error)] text-[var(--error)]',
} as const;
export function QueueElement({
element,
isFront = false,
isRear = false,
className,
}: QueueElementProps) {
return (
<motion.div
layout
initial={{ opacity: 0, x: 20, scale: 0.8 }}
animate={{
opacity: element.state === 'dequeued' ? 0 : 1,
x: 0,
scale: element.state === 'highlighted' ? 1.05 : 1,
}}
exit={{ opacity: 0, x: -20, scale: 0.8 }}
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
}}
className={cn(
'relative flex h-10 w-10 items-center justify-center rounded-lg border-2 font-mono font-medium transition-colors duration-200',
STATE_CLASSES[element.state],
className
)}
>
{element.value}
{isFront && (
<span className="absolute -bottom-5 text-[10px] font-medium text-[var(--muted-foreground)]">
FRONT
</span>
)}
{isRear && (
<span className="absolute -bottom-5 text-[10px] font-medium text-[var(--muted-foreground)]">
REAR
</span>
)}
</motion.div>
);
}

View File

@@ -0,0 +1,83 @@
'use client';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
import type { BinaryTreeNodeState } from '@/lib/visualizations/types';
interface TreeNodeProps {
node: BinaryTreeNodeState;
x: number;
y: number;
radius?: number;
className?: string;
}
const STATE_CLASSES = {
normal: 'fill-[var(--surface-variant)] stroke-[var(--border)]',
current: 'fill-[var(--primary)]/20 stroke-[var(--primary)]',
visiting: 'fill-[var(--info)]/20 stroke-[var(--info)]',
visited: 'fill-[var(--success)]/20 stroke-[var(--success)] opacity-70',
highlighted: 'fill-[var(--primary)]/30 stroke-[var(--primary)]',
} as const;
const TEXT_CLASSES = {
normal: 'fill-[var(--foreground)]',
current: 'fill-[var(--primary)]',
visiting: 'fill-[var(--info)]',
visited: 'fill-[var(--success)]',
highlighted: 'fill-[var(--primary)]',
} as const;
export function TreeNode({
node,
x,
y,
radius = 24,
className,
}: TreeNodeProps) {
const isActive = node.state === 'current' || node.state === 'highlighted';
return (
<motion.g
initial={false}
animate={{
scale: isActive ? 1.1 : 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>
);
}