feat(viz): backtracking, greedy, intervals, matrix

This commit is contained in:
2025-09-03 21:47:33 +01:00
parent d23ac3534d
commit b255ccba32
15 changed files with 3063 additions and 1 deletions
@@ -0,0 +1,68 @@
'use client';
import { useVisualization } from '@/lib/visualizations/use-visualization';
import type { AlgorithmDefinition } from '@/lib/visualizations/types';
import { VisualizationContainer } from '../core/visualization-container';
import { DecisionTreeView } from '../data-structures/decision-tree-view';
import { ArrayView } from '../data-structures/array-view';
interface BacktrackingVisualizationProps {
algorithm: AlgorithmDefinition;
className?: string;
}
export function BacktrackingVisualization({
algorithm,
className,
}: BacktrackingVisualizationProps) {
const {
currentStep,
currentStepIndex,
totalSteps,
playback,
controls,
currentPhase,
progress,
} = useVisualization(algorithm);
const { dataState } = currentStep;
const decisionTree = dataState.decisionTrees?.[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 (if present) */}
{inputArray && (
<ArrayView
array={inputArray}
pointers={dataState.pointers}
elementSize="sm"
/>
)}
{/* Decision tree */}
{decisionTree && (
<DecisionTreeView tree={decisionTree} />
)}
</div>
</VisualizationContainer>
);
}
@@ -0,0 +1,69 @@
'use client';
import { useVisualization } from '@/lib/visualizations/use-visualization';
import type { AlgorithmDefinition } from '@/lib/visualizations/types';
import { VisualizationContainer } from '../core/visualization-container';
import { ArrayView } from '../data-structures/array-view';
import { CalculationBubble } from '../primitives/calculation-bubble';
interface GreedyVisualizationProps {
algorithm: AlgorithmDefinition;
className?: string;
}
export function GreedyVisualization({
algorithm,
className,
}: GreedyVisualizationProps) {
const {
currentStep,
currentStepIndex,
totalSteps,
playback,
controls,
currentPhase,
progress,
} = useVisualization(algorithm);
const { dataState } = currentStep;
const mainArray = dataState.arrays[0];
const calculation = dataState.calculations[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">
{/* Fixed height area for calculation bubble */}
<div className="flex h-8 items-center justify-center">
<CalculationBubble calculation={calculation} />
</div>
{/* Array visualization with pointers */}
<div className="mt-2">
{mainArray && (
<ArrayView
array={mainArray}
pointers={dataState.pointers}
elementSize="sm"
/>
)}
</div>
</div>
</VisualizationContainer>
);
}
@@ -0,0 +1,63 @@
'use client';
import { useVisualization } from '@/lib/visualizations/use-visualization';
import type { AlgorithmDefinition } from '@/lib/visualizations/types';
import { VisualizationContainer } from '../core/visualization-container';
import { IntervalView } from '../data-structures/interval-view';
interface IntervalsVisualizationProps {
algorithm: AlgorithmDefinition;
className?: string;
}
export function IntervalsVisualization({
algorithm,
className,
}: IntervalsVisualizationProps) {
const {
currentStep,
currentStepIndex,
totalSteps,
playback,
controls,
currentPhase,
progress,
} = useVisualization(algorithm);
const { dataState } = currentStep;
const inputIntervals = dataState.intervals?.[0] ?? null;
const resultIntervals = dataState.intervals?.[1] ?? 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 intervals */}
{inputIntervals && (
<IntervalView intervalList={inputIntervals} />
)}
{/* Result intervals (shown when building merged result) */}
{resultIntervals && resultIntervals.intervals.length > 0 && (
<IntervalView intervalList={resultIntervals} />
)}
</div>
</VisualizationContainer>
);
}
@@ -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 { GridView } from '../data-structures/grid-view';
import { CalculationBubble } from '../primitives/calculation-bubble';
interface MatrixTraversalVisualizationProps {
algorithm: AlgorithmDefinition;
className?: string;
}
export function MatrixTraversalVisualization({
algorithm,
className,
}: MatrixTraversalVisualizationProps) {
const {
currentStep,
currentStepIndex,
totalSteps,
playback,
controls,
currentPhase,
progress,
} = useVisualization(algorithm);
const { dataState } = currentStep;
const grid = dataState.grids?.[0] ?? null;
const calculation = dataState.calculations[0] ?? null;
const gridPointers = dataState.gridPointers ?? [];
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-4">
{/* Calculation bubble */}
<div className="flex h-8 items-center justify-center">
<CalculationBubble calculation={calculation} />
</div>
{/* Island Grid */}
{grid && (
<GridView
grid={grid}
pointers={gridPointers}
cellSize="md"
showColLabels={true}
showRowLabels={true}
/>
)}
</div>
</VisualizationContainer>
);
}
@@ -0,0 +1,170 @@
'use client';
import { useMemo } from 'react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
import type { DecisionTreeState, DecisionNodeState } from '@/lib/visualizations/types';
import { DecisionNode } from '../primitives/decision-node';
interface DecisionTreeViewProps {
tree: DecisionTreeState;
className?: string;
}
const NODE_WIDTH = 64;
const NODE_HEIGHT = 32;
const LEVEL_HEIGHT = 80;
const MIN_NODE_SPACING = 72;
const SVG_PADDING = 40;
interface NodePosition {
node: DecisionNodeState;
x: number;
y: number;
parentX?: number;
parentY?: number;
isLeftChild?: boolean;
}
function buildNodeMap(nodes: DecisionNodeState[]): Map<string, DecisionNodeState> {
const map = new Map<string, DecisionNodeState>();
for (const node of nodes) {
map.set(node.id, node);
}
return map;
}
function calculateTreeDepth(
nodeId: string | null,
nodeMap: Map<string, DecisionNodeState>
): 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: DecisionTreeState,
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,
isLeftChild?: boolean
) {
if (!nodeId) return;
const node = nodeMap.get(nodeId);
if (!node) return;
const x = (left + right) / 2;
const y = SVG_PADDING + level * LEVEL_HEIGHT + NODE_HEIGHT / 2;
positions.push({ node, x, y, parentX, parentY, isLeftChild });
const childWidth = (right - left) / 2;
traverse(node.left, level + 1, left, left + childWidth, x, y, true);
traverse(node.right, level + 1, left + childWidth, right, x, y, false);
}
traverse(tree.rootId, 0, 0, totalWidth);
return positions;
}
export function DecisionTreeView({
tree,
className,
}: DecisionTreeViewProps) {
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,
300
);
const height = depth * LEVEL_HEIGHT + SVG_PADDING * 2;
const pos = calculatePositions(tree, width);
return { positions: pos, svgWidth: width, svgHeight: height };
}, [tree]);
const currentPath = new Set(tree.currentPath ?? []);
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, isLeftChild }) =>
parentX !== undefined && parentY !== undefined ? (
<g key={`edge-${node.id}`}>
{/* Edge line */}
<motion.line
x1={parentX}
y1={parentY + NODE_HEIGHT / 2}
x2={x}
y2={y - NODE_HEIGHT / 2}
className={cn(
'stroke-[var(--border)]',
currentPath.has(node.id) && 'stroke-[var(--primary)]'
)}
strokeWidth={currentPath.has(node.id) ? 2.5 : 2}
initial={false}
animate={{
opacity: node.state === 'complete' ? 0.6 : 1,
}}
transition={{ duration: 0.2 }}
/>
{/* Branch label */}
<text
x={(parentX + x) / 2 + (isLeftChild ? -8 : 8)}
y={(parentY + NODE_HEIGHT / 2 + y - NODE_HEIGHT / 2) / 2}
textAnchor="middle"
dominantBaseline="central"
className={cn(
'text-[10px] font-medium',
currentPath.has(node.id)
? 'fill-[var(--primary)]'
: 'fill-[var(--muted-foreground)]'
)}
>
{isLeftChild ? '+' : ''}
</text>
</g>
) : null
)}
{/* Draw nodes */}
{positions.map(({ node, x, y }) => (
<DecisionNode
key={node.id}
node={node}
x={x}
y={y}
width={NODE_WIDTH}
height={NODE_HEIGHT}
isInPath={currentPath.has(node.id)}
/>
))}
</svg>
</div>
);
}
@@ -0,0 +1,115 @@
'use client';
import { cn } from '@/lib/utils';
import type { IntervalListState } from '@/lib/visualizations/types';
import { IntervalBar } from '../primitives/interval-bar';
import { AnimatePresence } from 'framer-motion';
interface IntervalViewProps {
intervalList: IntervalListState;
className?: string;
}
const PIXELS_PER_UNIT = 20;
const BAR_HEIGHT = 28;
const ROW_GAP = 8;
export function IntervalView({
intervalList,
className,
}: IntervalViewProps) {
// Calculate timeline range
const minValue = intervalList.minValue ?? 0;
const maxValue = intervalList.maxValue ?? Math.max(
...intervalList.intervals.map((i) => i.end),
20
);
const timelineWidth = (maxValue - minValue) * PIXELS_PER_UNIT;
// Generate tick marks for the number line
const tickStep = maxValue <= 10 ? 1 : maxValue <= 20 ? 2 : 5;
const ticks: number[] = [];
for (let i = minValue; i <= maxValue; i += tickStep) {
ticks.push(i);
}
// Calculate row assignments to prevent overlapping intervals
const rows: IntervalListState['intervals'][] = [];
for (const interval of intervalList.intervals) {
let placed = false;
for (const row of rows) {
const overlaps = row.some(
(existing) => !(interval.end <= existing.start || interval.start >= existing.end)
);
if (!overlaps) {
row.push(interval);
placed = true;
break;
}
}
if (!placed) {
rows.push([interval]);
}
}
const intervalsHeight = rows.length * (BAR_HEIGHT + ROW_GAP) - ROW_GAP;
return (
<div className={cn('flex flex-col items-center', className)}>
{intervalList.label && (
<span className="mb-2 text-sm font-medium text-[var(--muted-foreground)]">
{intervalList.label}
</span>
)}
<div className="relative" style={{ width: `${timelineWidth}px` }}>
{/* Intervals area */}
<div
className="relative mb-2"
style={{ height: `${Math.max(intervalsHeight, BAR_HEIGHT)}px` }}
>
<AnimatePresence mode="popLayout">
{rows.map((row, rowIndex) =>
row.map((interval) => (
<div
key={interval.id}
style={{ position: 'absolute', top: `${rowIndex * (BAR_HEIGHT + ROW_GAP)}px` }}
>
<IntervalBar
interval={interval}
pixelsPerUnit={PIXELS_PER_UNIT}
minValue={minValue}
height={BAR_HEIGHT}
/>
</div>
))
)}
</AnimatePresence>
</div>
{/* Number line */}
<div className="relative h-6">
{/* Horizontal line */}
<div
className="absolute top-0 h-px bg-[var(--border)]"
style={{ width: `${timelineWidth}px` }}
/>
{/* Tick marks and labels */}
{ticks.map((tick) => {
const x = (tick - minValue) * PIXELS_PER_UNIT;
return (
<div key={tick} className="absolute" style={{ left: `${x}px` }}>
<div className="h-2 w-px bg-[var(--border)]" />
<span className="absolute -translate-x-1/2 mt-1 text-xs text-[var(--muted-foreground)]">
{tick}
</span>
</div>
);
})}
</div>
</div>
</div>
);
}
@@ -17,6 +17,7 @@ export { TreeNode } from "./primitives/tree-node";
export { GridCell } from "./primitives/grid-cell";
export { DecisionNode } from "./primitives/decision-node";
export { HeapNode } from "./primitives/heap-node";
export { IntervalBar } from "./primitives/interval-bar";
// Data structures
export { ArrayView } from "./data-structures/array-view";
@@ -27,6 +28,7 @@ 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";
export { IntervalView } from "./data-structures/interval-view";
// Algorithm visualizations
export { MonotonicStackVisualization } from "./algorithms/monotonic-stack";
@@ -39,3 +41,6 @@ export { LinkedListVisualization } from "./algorithms/linked-list";
export { CoinChangeVisualization } from "./algorithms/coin-change";
export { BacktrackingVisualization } from "./algorithms/backtracking";
export { HeapVisualization } from "./algorithms/heap";
export { GreedyVisualization } from "./algorithms/greedy";
export { IntervalsVisualization } from "./algorithms/intervals";
export { MatrixTraversalVisualization } from "./algorithms/matrix-traversal";
@@ -0,0 +1,109 @@
'use client';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
import type { DecisionNodeState } from '@/lib/visualizations/types';
interface DecisionNodeProps {
node: DecisionNodeState;
x: number;
y: number;
width?: number;
height?: number;
isInPath?: boolean;
className?: string;
}
const STATE_CLASSES = {
normal: 'fill-[var(--surface-variant)] stroke-[var(--border)]',
exploring: 'fill-[var(--info)]/20 stroke-[var(--info)]',
complete: 'fill-[var(--success)]/20 stroke-[var(--success)]',
backtracking: 'fill-[var(--warning)]/20 stroke-[var(--warning)]',
current: 'fill-[var(--primary)]/20 stroke-[var(--primary)]',
} as const;
const TEXT_CLASSES = {
normal: 'fill-[var(--foreground)]',
exploring: 'fill-[var(--info)]',
complete: 'fill-[var(--success)]',
backtracking: 'fill-[var(--warning)]',
current: 'fill-[var(--primary)]',
} as const;
export function DecisionNode({
node,
x,
y,
width = 64,
height = 32,
isInPath = false,
className,
}: DecisionNodeProps) {
const isActive = node.state === 'current' || node.state === 'exploring';
const showDecision = node.decision && node.state === 'current';
return (
<motion.g
initial={false}
animate={{
scale: isActive ? 1.05 : 1,
}}
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
}}
style={{ transformOrigin: `${x}px ${y}px` }}
className={cn(className)}
>
{/* Decision label above node */}
{showDecision && (
<motion.text
x={x}
y={y - height / 2 - 12}
textAnchor="middle"
className="fill-[var(--primary)] text-xs font-medium"
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
>
{node.decision}
</motion.text>
)}
{/* Node rectangle (rounded) */}
<motion.rect
x={x - width / 2}
y={y - height / 2}
width={width}
height={height}
rx={6}
ry={6}
strokeWidth={isInPath ? 2.5 : 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 }}
/>
{/* Value text */}
<text
x={x}
y={y}
textAnchor="middle"
dominantBaseline="central"
className={cn(
'pointer-events-none select-none font-mono text-xs font-medium',
TEXT_CLASSES[node.state]
)}
>
{node.value}
</text>
</motion.g>
);
}
@@ -0,0 +1,58 @@
'use client';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
import type { IntervalState } from '@/lib/visualizations/types';
interface IntervalBarProps {
interval: IntervalState;
pixelsPerUnit: number;
minValue: number;
height?: number;
className?: string;
}
const STATE_CLASSES = {
normal: 'bg-[var(--muted)] border-[var(--border)] text-[var(--foreground)]',
highlighted: 'bg-[var(--primary)]/30 border-[var(--primary)] text-[var(--primary)]',
merging: 'bg-amber-500/30 border-amber-500 text-amber-500',
merged: 'bg-green-500/30 border-green-500 text-green-500',
dimmed: 'bg-[var(--muted)]/30 border-[var(--border)]/50 text-[var(--muted-foreground)] opacity-40',
} as const;
export function IntervalBar({
interval,
pixelsPerUnit,
minValue,
height = 32,
className,
}: IntervalBarProps) {
const width = (interval.end - interval.start) * pixelsPerUnit;
const left = (interval.start - minValue) * pixelsPerUnit;
const label = interval.label ?? `[${interval.start},${interval.end}]`;
return (
<motion.div
layout
initial={{ opacity: 0, scale: 0.9 }}
animate={{
opacity: 1,
scale: interval.state === 'highlighted' || interval.state === 'merging' ? 1.02 : 1,
}}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.2 }}
className={cn(
'absolute flex items-center justify-center rounded border-2 font-mono text-xs font-medium transition-colors duration-200',
STATE_CLASSES[interval.state],
className
)}
style={{
left: `${left}px`,
width: `${Math.max(width, 40)}px`,
height: `${height}px`,
}}
>
{label}
</motion.div>
);
}