feat(viz): dp coin change

This commit is contained in:
2025-09-01 21:29:23 +01:00
parent ed03d3251e
commit cf0c2153db
7 changed files with 1436 additions and 1 deletions

View File

@@ -0,0 +1,81 @@
'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 { GridView } from '../data-structures/grid-view';
import { CalculationBubble } from '../primitives/calculation-bubble';
interface CoinChangeVisualizationProps {
algorithm: AlgorithmDefinition;
className?: string;
}
export function CoinChangeVisualization({
algorithm,
className,
}: CoinChangeVisualizationProps) {
const {
currentStep,
currentStepIndex,
totalSteps,
playback,
controls,
currentPhase,
progress,
} = useVisualization(algorithm);
const { dataState } = currentStep;
const coinsArray = dataState.arrays.find((a) => a.id === 'coins') ?? null;
const dpGrid = 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>
{/* Coins array */}
{coinsArray && (
<ArrayView
array={coinsArray}
pointers={[]}
elementSize="sm"
/>
)}
{/* DP Table grid */}
{dpGrid && (
<GridView
grid={dpGrid}
pointers={gridPointers}
cellSize="sm"
showColLabels={true}
/>
)}
</div>
</VisualizationContainer>
);
}

View File

@@ -0,0 +1,149 @@
"use client";
import { cn } from "@/lib/utils";
import type { GridState, GridPointerState } from "@/lib/visualizations/types";
import { GridCell } from "../primitives/grid-cell";
interface GridViewProps {
grid: GridState;
pointers?: GridPointerState[];
cellSize?: "sm" | "md" | "lg";
showColLabels?: boolean;
showRowLabels?: boolean;
className?: string;
}
const CELL_WIDTHS = {
sm: 40,
md: 56,
lg: 72,
} as const;
const CELL_GAPS = {
sm: 2,
md: 4,
lg: 6,
} as const;
const POINTER_COLORS = {
current: "border-blue-500 text-blue-500",
dependency: "border-amber-500 text-amber-500",
result: "border-green-500 text-green-500",
default: "border-[var(--primary)] text-[var(--primary)]",
} as const;
export function GridView({
grid,
pointers = [],
cellSize = "sm",
showColLabels = true,
showRowLabels = false,
className,
}: GridViewProps) {
const cellWidth = CELL_WIDTHS[cellSize];
const gap = CELL_GAPS[cellSize];
// For 1D grids (single row), flatten for easier rendering
const is1D = grid.cells.length === 1;
// Calculate pointer positions for highlighting
const pointerPositions = new Map<string, GridPointerState>();
for (const pointer of pointers) {
const key = `${pointer.row}-${pointer.col}`;
pointerPositions.set(key, pointer);
}
return (
<div className={cn("flex flex-col items-center", className)}>
{grid.label && (
<span className="mb-2 text-sm font-medium text-[var(--muted-foreground)]">
{grid.label}
</span>
)}
{/* Column labels */}
{showColLabels && grid.colLabels && (
<div
className="flex mb-1"
style={{
gap: `${gap}px`,
marginLeft: showRowLabels ? `${cellWidth + gap}px` : 0,
}}
>
{grid.colLabels.map((label, idx) => (
<div
key={idx}
className="flex items-center justify-center text-xs text-[var(--muted-foreground)]"
style={{ width: `${cellWidth}px` }}
>
{label}
</div>
))}
</div>
)}
{/* Grid rows */}
{is1D ? (
// 1D grid (single row)
<div className="relative">
<div className="flex" style={{ gap: `${gap}px` }}>
{grid.cells[0].map((cell, colIdx) => {
const pointer = pointerPositions.get(`0-${colIdx}`);
return (
<div key={cell.id} className="relative">
{pointer && (
<div
className={cn(
"absolute -top-6 left-1/2 -translate-x-1/2 text-xs font-medium whitespace-nowrap",
POINTER_COLORS[pointer.color]
)}
>
{pointer.name}
</div>
)}
<GridCell cell={cell} size={cellSize} />
</div>
);
})}
</div>
</div>
) : (
// 2D grid
<div className="flex flex-col" style={{ gap: `${gap}px` }}>
{grid.cells.map((row, rowIdx) => (
<div key={rowIdx} className="flex items-center" style={{ gap: `${gap}px` }}>
{/* Row label */}
{showRowLabels && grid.rowLabels && (
<div
className="flex items-center justify-center text-xs text-[var(--muted-foreground)]"
style={{ width: `${cellWidth}px` }}
>
{grid.rowLabels[rowIdx]}
</div>
)}
{/* Row cells */}
{row.map((cell, colIdx) => {
const pointer = pointerPositions.get(`${rowIdx}-${colIdx}`);
return (
<div key={cell.id} className="relative">
{pointer && (
<div
className={cn(
"absolute -top-5 left-1/2 -translate-x-1/2 text-xs font-medium whitespace-nowrap",
POINTER_COLORS[pointer.color]
)}
>
{pointer.name}
</div>
)}
<GridCell cell={cell} size={cellSize} />
</div>
);
})}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -14,6 +14,7 @@ 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";
export { GridCell } from "./primitives/grid-cell";
// Data structures
export { ArrayView } from "./data-structures/array-view";
@@ -21,6 +22,7 @@ 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";
export { GridView } from "./data-structures/grid-view";
// Algorithm visualizations
export { MonotonicStackVisualization } from "./algorithms/monotonic-stack";
@@ -30,3 +32,4 @@ export { BFSVisualization } from "./algorithms/bfs";
export { DFSVisualization } from "./algorithms/dfs";
export { TwoPointersVisualization } from "./algorithms/two-pointers";
export { LinkedListVisualization } from "./algorithms/linked-list";
export { CoinChangeVisualization } from "./algorithms/coin-change";

View File

@@ -0,0 +1,54 @@
"use client";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
import type { GridCellState } from "@/lib/visualizations/types";
interface GridCellProps {
cell: GridCellState;
size?: "sm" | "md" | "lg";
className?: string;
}
const SIZE_CLASSES = {
sm: "w-10 h-10 text-sm",
md: "w-14 h-14 text-base",
lg: "w-18 h-18 text-lg",
} as const;
const STATE_CLASSES = {
normal: "bg-[var(--muted)] border-[var(--border)] text-[var(--foreground)]",
highlighted: "bg-[var(--primary)]/20 border-[var(--primary)] text-[var(--primary)]",
dimmed: "bg-[var(--muted)]/50 border-[var(--border)]/50 text-[var(--muted-foreground)] opacity-30",
success: "bg-green-500/20 border-green-500 text-green-500",
comparing: "bg-amber-500/20 border-amber-500 text-amber-500",
computing: "bg-blue-500/20 border-blue-500 text-blue-500",
} as const;
export function GridCell({
cell,
size = "md",
className,
}: GridCellProps) {
const isComputing = cell.state === "computing";
return (
<motion.div
layout
initial={false}
animate={{
scale: cell.state === "highlighted" || cell.state === "computing" ? 1.05 : 1,
}}
transition={{ duration: 0.2 }}
className={cn(
"flex items-center justify-center rounded-lg border-2 font-mono font-medium transition-colors duration-200",
SIZE_CLASSES[size],
STATE_CLASSES[cell.state],
isComputing && "animate-pulse",
className
)}
>
{cell.value === Infinity ? "∞" : cell.value}
</motion.div>
);
}