From 21227628faac1d8be943e70b83f863af26cab200 Mon Sep 17 00:00:00 2001 From: Kai Chappell Date: Sun, 24 Aug 2025 15:30:46 +0100 Subject: [PATCH] feat(viz): two pointers narrative --- frontend/package-lock.json | 43 + frontend/package.json | 1 + frontend/src/app/patterns/[slug]/page.tsx | 15 +- .../algorithms/two-pointers.tsx | 69 ++ .../visualizations-new/core/code-panel.tsx | 111 +++ .../core/explanation-panel.tsx | 95 +++ .../visualizations-new/core/step-controls.tsx | 267 ++++++ .../core/variable-inspector.tsx | 72 ++ .../core/visualization-container.tsx | 109 +++ .../data-structures/array-view.tsx | 82 ++ .../components/visualizations-new/index.ts | 17 + .../primitives/array-element.tsx | 56 ++ .../primitives/calculation-bubble.tsx | 36 + .../visualizations-new/primitives/pointer.tsx | 65 ++ frontend/src/content/algorithms/two-sum.ts | 802 ++++++++++++++++++ frontend/src/lib/visualizations/types.ts | 154 ++++ .../lib/visualizations/use-visualization.ts | 199 +++++ 17 files changed, 2191 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/visualizations-new/algorithms/two-pointers.tsx create mode 100644 frontend/src/components/visualizations-new/core/code-panel.tsx create mode 100644 frontend/src/components/visualizations-new/core/explanation-panel.tsx create mode 100644 frontend/src/components/visualizations-new/core/step-controls.tsx create mode 100644 frontend/src/components/visualizations-new/core/variable-inspector.tsx create mode 100644 frontend/src/components/visualizations-new/core/visualization-container.tsx create mode 100644 frontend/src/components/visualizations-new/data-structures/array-view.tsx create mode 100644 frontend/src/components/visualizations-new/index.ts create mode 100644 frontend/src/components/visualizations-new/primitives/array-element.tsx create mode 100644 frontend/src/components/visualizations-new/primitives/calculation-bubble.tsx create mode 100644 frontend/src/components/visualizations-new/primitives/pointer.tsx create mode 100644 frontend/src/content/algorithms/two-sum.ts create mode 100644 frontend/src/lib/visualizations/types.ts create mode 100644 frontend/src/lib/visualizations/use-visualization.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cabf748..afdab7b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@monaco-editor/react": "^4.7.0", "@tanstack/react-query": "^5.70.0", "@types/react-syntax-highlighter": "^15.5.13", + "framer-motion": "^12.29.2", "lucide-react": "^0.563.0", "next": "^15.3.0", "react": "^19.0.0", @@ -5547,6 +5548,33 @@ "node": ">=0.4.x" } }, + "node_modules/framer-motion": { + "version": "12.29.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.29.2.tgz", + "integrity": "sha512-lSNRzBJk4wuIy0emYQ/nfZ7eWhqud2umPKw2QAQki6uKhZPKm2hRQHeQoHTG9MIvfobb+A/LbEWPJU794ZUKrg==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.29.2", + "motion-utils": "^12.29.2", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -8041,6 +8069,21 @@ "marked": "14.0.0" } }, + "node_modules/motion-dom": { + "version": "12.29.2", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.29.2.tgz", + "integrity": "sha512-/k+NuycVV8pykxyiTCoFzIVLA95Nb1BFIVvfSu9L50/6K6qNeAYtkxXILy/LRutt7AzaYDc2myj0wkCVVYAPPA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.29.2" + } + }, + "node_modules/motion-utils": { + "version": "12.29.2", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz", + "integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5c779ac..2cd67fe 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "@monaco-editor/react": "^4.7.0", "@tanstack/react-query": "^5.70.0", "@types/react-syntax-highlighter": "^15.5.13", + "framer-motion": "^12.29.2", "lucide-react": "^0.563.0", "next": "^15.3.0", "react": "^19.0.0", diff --git a/frontend/src/app/patterns/[slug]/page.tsx b/frontend/src/app/patterns/[slug]/page.tsx index c52da02..6f00380 100644 --- a/frontend/src/app/patterns/[slug]/page.tsx +++ b/frontend/src/app/patterns/[slug]/page.tsx @@ -14,6 +14,8 @@ import { RelatedPatterns, } from "@/components/patterns"; import { PatternVisualization } from "@/components/visualization"; +import { TwoPointersVisualization } from "@/components/visualizations-new"; +import { twoSumAlgorithm } from "@/content/algorithms/two-sum"; interface PageProps { params: Promise<{ slug: string }>; @@ -106,7 +108,16 @@ export default async function PatternDetailPage({ params }: PageProps) { )} {/* Interactive Visualization */} - {pattern.visualization_examples && pattern.visualization_examples.length > 0 && ( + {slug === "two-pointers" ? ( + + + Interactive Visualization + + + + + + ) : pattern.visualization_examples && pattern.visualization_examples.length > 0 ? ( Interactive Visualization @@ -115,7 +126,7 @@ export default async function PatternDetailPage({ params }: PageProps) { - )} + ) : null} {/* Static Visualization - ASCII diagram walkthrough (fallback) */} {pattern.visualization && ( diff --git a/frontend/src/components/visualizations-new/algorithms/two-pointers.tsx b/frontend/src/components/visualizations-new/algorithms/two-pointers.tsx new file mode 100644 index 0000000..63e2684 --- /dev/null +++ b/frontend/src/components/visualizations-new/algorithms/two-pointers.tsx @@ -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 TwoPointersVisualizationProps { + algorithm: AlgorithmDefinition; + className?: string; +} + +export function TwoPointersVisualization({ + algorithm, + className, +}: TwoPointersVisualizationProps) { + 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 ( + +
+ {/* Fixed height area for calculation bubble */} +
+ +
+ {/* Array visualization with pointers */} +
+ {mainArray && ( + + )} +
+
+
+ ); +} diff --git a/frontend/src/components/visualizations-new/core/code-panel.tsx b/frontend/src/components/visualizations-new/core/code-panel.tsx new file mode 100644 index 0000000..e7e8a31 --- /dev/null +++ b/frontend/src/components/visualizations-new/core/code-panel.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; +import { cn } from "@/lib/utils"; +import type { AlgorithmCode } from "@/lib/visualizations/types"; + +interface CodePanelProps { + code: AlgorithmCode; + currentLine?: number; + highlightLines?: number[]; + className?: string; +} + +const LANGUAGE_MAP: Record = { + typescript: "typescript", + javascript: "javascript", + python: "python", +}; + +export function CodePanel({ + code, + currentLine, + highlightLines = [], + className, +}: CodePanelProps) { + const containerRef = useRef(null); + + useEffect(() => { + if (currentLine && containerRef.current) { + const container = containerRef.current; + const lineHeight = 24; + const lineTop = (currentLine - 1) * lineHeight; + const containerHeight = container.clientHeight; + const scrollTarget = lineTop - containerHeight / 2 + lineHeight / 2; + + container.scrollTo({ + top: Math.max(0, scrollTarget), + behavior: "smooth", + }); + } + }, [currentLine]); + + const codeLines = code.code.trim().split("\n"); + + return ( +
+
+ {codeLines.map((line, i) => { + const lineNumber = i + 1; + const isCurrentLine = lineNumber === currentLine; + const isHighlighted = highlightLines.includes(lineNumber); + + return ( +
+ + {lineNumber} + + {isCurrentLine && ( + + )} + {!isCurrentLine && ( +
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/components/visualizations-new/core/explanation-panel.tsx b/frontend/src/components/visualizations-new/core/explanation-panel.tsx new file mode 100644 index 0000000..b4ecae1 --- /dev/null +++ b/frontend/src/components/visualizations-new/core/explanation-panel.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { AnimatePresence, motion } from "framer-motion"; +import { cn } from "@/lib/utils"; +import type { DecisionCallout, VisualizationPhase } from "@/lib/visualizations/types"; + +interface ExplanationPanelProps { + explanation: string; + decision?: DecisionCallout; + phase: VisualizationPhase; + className?: string; +} + +const PHASE_LABELS: Record = { + problem: "Problem", + intuition: "Intuition", + pattern: "Pattern", + code: "Code", + execution: "Execution", +}; + +const PHASE_COLORS: Record = { + problem: "bg-red-500/20 text-red-500", + intuition: "bg-amber-500/20 text-amber-500", + pattern: "bg-blue-500/20 text-blue-500", + code: "bg-[var(--primary)]/20 text-[var(--primary)]", + execution: "bg-green-500/20 text-green-500", +}; + +export function ExplanationPanel({ + explanation, + decision, + phase, + className, +}: ExplanationPanelProps) { + return ( +
+
+ + {PHASE_LABELS[phase]} + +
+ + + +

+ {explanation} +

+ + {decision && ( + +
+ Decision Point +
+
+
+ Q: + {decision.question} +
+
+ A: + {decision.answer} +
+
+ + {decision.action} +
+
+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/visualizations-new/core/step-controls.tsx b/frontend/src/components/visualizations-new/core/step-controls.tsx new file mode 100644 index 0000000..5f1b4d7 --- /dev/null +++ b/frontend/src/components/visualizations-new/core/step-controls.tsx @@ -0,0 +1,267 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import type { + PlaybackSpeed, + VisualizationControls, +} from "@/lib/visualizations/types"; + +interface StepControlsProps { + currentStepIndex: number; + totalSteps: number; + isPlaying: boolean; + speed: PlaybackSpeed; + controls: VisualizationControls; + progress: number; + className?: string; +} + +const SPEED_OPTIONS: PlaybackSpeed[] = [0.5, 1, 2]; + +export function StepControls({ + currentStepIndex, + totalSteps, + isPlaying, + speed, + controls, + progress, + className, +}: StepControlsProps) { + const isAtStart = currentStepIndex === 0; + const isAtEnd = currentStepIndex === totalSteps - 1; + + return ( +
+
+
+ + + + + + + + + +
+ +
+ + Step {currentStepIndex + 1} of {totalSteps} + + + +
+
+ +
+ +
+
+ ); +} + +function PlayIcon() { + return ( + + ); +} + +function PauseIcon() { + return ( + + ); +} + +function PreviousIcon() { + return ( + + ); +} + +function NextIcon() { + return ( + + ); +} + +function FirstIcon() { + return ( + + ); +} + +function LastIcon() { + return ( + + ); +} diff --git a/frontend/src/components/visualizations-new/core/variable-inspector.tsx b/frontend/src/components/visualizations-new/core/variable-inspector.tsx new file mode 100644 index 0000000..0f3a144 --- /dev/null +++ b/frontend/src/components/visualizations-new/core/variable-inspector.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { AnimatePresence, motion } from "framer-motion"; +import { cn } from "@/lib/utils"; +import type { VariableState } from "@/lib/visualizations/types"; + +interface VariableInspectorProps { + variables: VariableState[]; + className?: string; +} + +export function VariableInspector({ + variables, + className, +}: VariableInspectorProps) { + return ( +
+
+ Variables +
+
+ + {variables.length === 0 && ( + + No variables yet + + )} + {variables.map((variable) => ( + + + {variable.name} + + = + + {String(variable.value)} + + {variable.previousValue !== undefined && + variable.previousValue !== variable.value && ( + + {String(variable.previousValue)} + + )} + {variable.derivation && ( + + ({variable.derivation}) + + )} + + ))} + +
+
+ ); +} diff --git a/frontend/src/components/visualizations-new/core/visualization-container.tsx b/frontend/src/components/visualizations-new/core/visualization-container.tsx new file mode 100644 index 0000000..5605c60 --- /dev/null +++ b/frontend/src/components/visualizations-new/core/visualization-container.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import type { ReactNode } from "react"; +import type { + AlgorithmCode, + AlgorithmPattern, + DecisionCallout, + PlaybackSpeed, + VariableState, + VisualizationControls, + VisualizationPhase, +} from "@/lib/visualizations/types"; +import { CodePanel } from "./code-panel"; +import { ExplanationPanel } from "./explanation-panel"; +import { StepControls } from "./step-controls"; +import { VariableInspector } from "./variable-inspector"; + +interface VisualizationContainerProps { + title: string; + pattern: AlgorithmPattern; + code: AlgorithmCode; + currentLine?: number; + highlightLines?: number[]; + explanation: string; + decision?: DecisionCallout; + phase: VisualizationPhase; + variables: VariableState[]; + currentStepIndex: number; + totalSteps: number; + isPlaying: boolean; + speed: PlaybackSpeed; + controls: VisualizationControls; + progress: number; + children: ReactNode; + className?: string; +} + +export function VisualizationContainer({ + title, + pattern, + code, + currentLine, + highlightLines, + explanation, + decision, + phase, + variables, + currentStepIndex, + totalSteps, + isPlaying, + speed, + controls, + progress, + children, + className, +}: VisualizationContainerProps) { + return ( +
+
+

{title}

+ + {pattern.name} + +
+ +
+
+ + +
+ +
+
+ {children} +
+ +
+
+ + +
+ ); +} diff --git a/frontend/src/components/visualizations-new/data-structures/array-view.tsx b/frontend/src/components/visualizations-new/data-structures/array-view.tsx new file mode 100644 index 0000000..6432692 --- /dev/null +++ b/frontend/src/components/visualizations-new/data-structures/array-view.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import type { ArrayState, PointerState } from "@/lib/visualizations/types"; +import { ArrayElement } from "../primitives/array-element"; +import { Pointer } from "../primitives/pointer"; + +interface ArrayViewProps { + array: ArrayState; + pointers: PointerState[]; + showIndices?: boolean; + elementSize?: "sm" | "md" | "lg"; + className?: string; +} + +// Must match SIZE_CLASSES in array-element.tsx (Tailwind units × 4) +const ELEMENT_WIDTHS = { + sm: 40, // w-10 = 40px + md: 56, // w-14 = 56px + lg: 72, // w-18 = 72px +} as const; + +const ELEMENT_GAPS = { + sm: 4, + md: 8, + lg: 12, +} as const; + +export function ArrayView({ + array, + pointers, + showIndices = true, + elementSize = "md", + className, +}: ArrayViewProps) { + const elementWidth = ELEMENT_WIDTHS[elementSize]; + const gap = ELEMENT_GAPS[elementSize]; + + // Calculate total array width for proper pointer alignment + const totalWidth = array.elements.length * elementWidth + (array.elements.length - 1) * gap; + + return ( +
+ {array.label && ( + + {array.label} + + )} + + {/* Container that sizes to array width - ensures pointer/array alignment */} +
+ {/* Pointers row */} +
+ {pointers.map((pointer) => ( + + ))} +
+ + {/* Array elements */} +
+ {array.elements.map((element) => ( + + ))} +
+
+
+ ); +} diff --git a/frontend/src/components/visualizations-new/index.ts b/frontend/src/components/visualizations-new/index.ts new file mode 100644 index 0000000..7a61b43 --- /dev/null +++ b/frontend/src/components/visualizations-new/index.ts @@ -0,0 +1,17 @@ +// Core components +export { VisualizationContainer } from "./core/visualization-container"; +export { StepControls } from "./core/step-controls"; +export { CodePanel } from "./core/code-panel"; +export { ExplanationPanel } from "./core/explanation-panel"; +export { VariableInspector } from "./core/variable-inspector"; + +// Primitives +export { ArrayElement } from "./primitives/array-element"; +export { Pointer } from "./primitives/pointer"; +export { CalculationBubble } from "./primitives/calculation-bubble"; + +// Data structures +export { ArrayView } from "./data-structures/array-view"; + +// Algorithm visualizations +export { TwoPointersVisualization } from "./algorithms/two-pointers"; diff --git a/frontend/src/components/visualizations-new/primitives/array-element.tsx b/frontend/src/components/visualizations-new/primitives/array-element.tsx new file mode 100644 index 0000000..a82ddcb --- /dev/null +++ b/frontend/src/components/visualizations-new/primitives/array-element.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { motion } from "framer-motion"; +import { cn } from "@/lib/utils"; +import type { ArrayElementState } from "@/lib/visualizations/types"; + +interface ArrayElementProps { + element: ArrayElementState; + size?: "sm" | "md" | "lg"; + showIndex?: boolean; + 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", +} as const; + +export function ArrayElement({ + element, + size = "md", + showIndex = true, + className, +}: ArrayElementProps) { + return ( +
+ + {element.value} + + {showIndex && ( + {element.index} + )} +
+ ); +} diff --git a/frontend/src/components/visualizations-new/primitives/calculation-bubble.tsx b/frontend/src/components/visualizations-new/primitives/calculation-bubble.tsx new file mode 100644 index 0000000..fbfc646 --- /dev/null +++ b/frontend/src/components/visualizations-new/primitives/calculation-bubble.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { AnimatePresence, motion } from "framer-motion"; +import { cn } from "@/lib/utils"; +import type { CalculationState } from "@/lib/visualizations/types"; + +interface CalculationBubbleProps { + calculation: CalculationState | null; + className?: string; +} + +export function CalculationBubble({ + calculation, + className, +}: CalculationBubbleProps) { + return ( + + {calculation && ( + + {calculation.expression} + = + {calculation.result} + + )} + + ); +} diff --git a/frontend/src/components/visualizations-new/primitives/pointer.tsx b/frontend/src/components/visualizations-new/primitives/pointer.tsx new file mode 100644 index 0000000..3a8d5bb --- /dev/null +++ b/frontend/src/components/visualizations-new/primitives/pointer.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { motion } from "framer-motion"; +import { cn } from "@/lib/utils"; +import type { PointerState } from "@/lib/visualizations/types"; + +interface PointerProps { + pointer: PointerState; + elementWidth: number; + gap: number; + value?: number; + className?: string; +} + +const COLOR_CLASSES = { + left: "text-blue-500 fill-blue-500", + right: "text-orange-500 fill-orange-500", + mid: "text-purple-500 fill-purple-500", + default: "text-blue-500 fill-blue-500", +} as const; + +export function Pointer({ + pointer, + elementWidth, + gap, + value, + className, +}: PointerProps) { + // Calculate center of element: index * (width + gap) + width / 2 + const offset = pointer.index * (elementWidth + gap) + elementWidth / 2; + + return ( + + {/* Label - centered above the point */} + + {pointer.name} + {pointer.showValue && value !== undefined && ( + = {value} + )} + + {/* Arrow - centered at position */} + + + ); +} diff --git a/frontend/src/content/algorithms/two-sum.ts b/frontend/src/content/algorithms/two-sum.ts new file mode 100644 index 0000000..483d955 --- /dev/null +++ b/frontend/src/content/algorithms/two-sum.ts @@ -0,0 +1,802 @@ +import type { AlgorithmDefinition } from "@/lib/visualizations/types"; + +export const twoSumAlgorithm: AlgorithmDefinition = { + id: "two-sum-sorted", + title: "Two Sum II - Sorted Array", + slug: "two-sum-sorted", + pattern: { + name: "Two Pointers", + description: + "Use two pointers moving toward each other to find a pair that satisfies a condition.", + }, + problemStatement: + "Given a sorted array of integers, find two numbers that add up to a target value. Return their indices.", + intuition: + "Because the array is sorted, we can use two pointers starting at opposite ends. If the sum is too small, we move the left pointer right. If too large, we move the right pointer left.", + code: { + language: "python", + code: `def two_sum(nums: list[int], target: int) -> list[int]: + left = 0 + right = len(nums) - 1 + + while left < right: + total = nums[left] + nums[right] + + if total == target: + return [left, right] + elif total < target: + left += 1 + else: + right -= 1 + + return [] # No solution found`, + }, + initialExample: { + input: { nums: [2, 7, 11, 15], target: 9 }, + expected: [0, 1], + }, + steps: [ + // Phase 1: Problem (2 steps) + { + id: "problem-1", + phase: "problem", + explanation: + "We have a sorted array [2, 7, 11, 15] and need to find two numbers that add up to 9.", + dataState: { + arrays: [ + { + id: "nums", + elements: [ + { value: 2, index: 0, state: "normal" }, + { value: 7, index: 1, state: "normal" }, + { value: 11, index: 2, state: "normal" }, + { value: 15, index: 3, state: "normal" }, + ], + }, + ], + pointers: [], + variables: [{ id: "target", name: "target", value: 9 }], + calculations: [], + }, + }, + { + id: "problem-2", + phase: "problem", + explanation: + "A brute force approach would check every pair, giving O(n²) time complexity. Can we do better using the sorted property?", + dataState: { + arrays: [ + { + id: "nums", + elements: [ + { value: 2, index: 0, state: "highlighted" }, + { value: 7, index: 1, state: "highlighted" }, + { value: 11, index: 2, state: "normal" }, + { value: 15, index: 3, state: "normal" }, + ], + }, + ], + pointers: [], + variables: [{ id: "target", name: "target", value: 9 }], + calculations: [], + }, + }, + + // Phase 2: Intuition (3 steps) + { + id: "intuition-1", + phase: "intuition", + explanation: + "Key insight: In a sorted array, the smallest values are on the left and largest on the right.", + dataState: { + arrays: [ + { + id: "nums", + elements: [ + { value: 2, index: 0, state: "highlighted" }, + { value: 7, index: 1, state: "normal" }, + { value: 11, index: 2, state: "normal" }, + { value: 15, index: 3, state: "highlighted" }, + ], + }, + ], + pointers: [], + variables: [{ id: "target", name: "target", value: 9 }], + calculations: [], + }, + }, + { + id: "intuition-2", + phase: "intuition", + explanation: + "If we start with pointers at both ends, we can adjust based on whether our sum is too small or too large.", + dataState: { + arrays: [ + { + id: "nums", + elements: [ + { value: 2, index: 0, state: "highlighted" }, + { value: 7, index: 1, state: "dimmed" }, + { value: 11, index: 2, state: "dimmed" }, + { value: 15, index: 3, state: "highlighted" }, + ], + }, + ], + pointers: [ + { id: "left", name: "left", index: 0, color: "left" }, + { id: "right", name: "right", index: 3, color: "right" }, + ], + variables: [{ id: "target", name: "target", value: 9 }], + calculations: [], + }, + }, + { + id: "intuition-3", + phase: "intuition", + explanation: + "Sum too small? Move left pointer right to increase the sum. Sum too large? Move right pointer left to decrease it.", + dataState: { + arrays: [ + { + id: "nums", + elements: [ + { value: 2, index: 0, state: "highlighted" }, + { value: 7, index: 1, state: "normal" }, + { value: 11, index: 2, state: "normal" }, + { value: 15, index: 3, state: "highlighted" }, + ], + }, + ], + pointers: [ + { id: "left", name: "left", index: 0, color: "left" }, + { id: "right", name: "right", index: 3, color: "right" }, + ], + variables: [{ id: "target", name: "target", value: 9 }], + calculations: [], + }, + }, + + // Phase 3: Code walkthrough (5 steps) + { + id: "code-1", + phase: "code", + explanation: + "Initialize two pointers: left at the start (index 0), right at the end (index n-1).", + codeLine: 2, + codeHighlightLines: [2, 3], + dataState: { + arrays: [ + { + id: "nums", + elements: [ + { value: 2, index: 0, state: "normal" }, + { value: 7, index: 1, state: "normal" }, + { value: 11, index: 2, state: "normal" }, + { value: 15, index: 3, state: "normal" }, + ], + }, + ], + pointers: [], + variables: [{ id: "target", name: "target", value: 9 }], + calculations: [], + }, + }, + { + id: "code-2", + phase: "code", + explanation: "Continue while the pointers have not crossed each other.", + codeLine: 5, + dataState: { + arrays: [ + { + id: "nums", + elements: [ + { value: 2, index: 0, state: "normal" }, + { value: 7, index: 1, state: "normal" }, + { value: 11, index: 2, state: "normal" }, + { value: 15, index: 3, state: "normal" }, + ], + }, + ], + pointers: [], + variables: [{ id: "target", name: "target", value: 9 }], + calculations: [], + }, + }, + { + id: "code-3", + phase: "code", + explanation: + "Calculate the total of elements at both pointers and compare to target.", + codeLine: 6, + codeHighlightLines: [6, 8], + dataState: { + arrays: [ + { + id: "nums", + elements: [ + { value: 2, index: 0, state: "normal" }, + { value: 7, index: 1, state: "normal" }, + { value: 11, index: 2, state: "normal" }, + { value: 15, index: 3, state: "normal" }, + ], + }, + ], + pointers: [], + variables: [{ id: "target", name: "target", value: 9 }], + calculations: [], + }, + }, + { + id: "code-4", + phase: "code", + explanation: "If total is too small, increment left to try a larger value.", + codeLine: 10, + dataState: { + arrays: [ + { + id: "nums", + elements: [ + { value: 2, index: 0, state: "normal" }, + { value: 7, index: 1, state: "normal" }, + { value: 11, index: 2, state: "normal" }, + { value: 15, index: 3, state: "normal" }, + ], + }, + ], + pointers: [], + variables: [{ id: "target", name: "target", value: 9 }], + calculations: [], + }, + }, + { + id: "code-5", + phase: "code", + explanation: + "If total is too large, decrement right to try a smaller value.", + codeLine: 12, + dataState: { + arrays: [ + { + id: "nums", + elements: [ + { value: 2, index: 0, state: "normal" }, + { value: 7, index: 1, state: "normal" }, + { value: 11, index: 2, state: "normal" }, + { value: 15, index: 3, state: "normal" }, + ], + }, + ], + pointers: [], + variables: [{ id: "target", name: "target", value: 9 }], + calculations: [], + }, + }, + + // Phase 4: Execution (15 steps) + { + id: "exec-1", + phase: "execution", + explanation: "Initialize left = 0 pointing to value 2.", + codeLine: 2, + dataState: { + arrays: [ + { + id: "nums", + elements: [ + { value: 2, index: 0, state: "highlighted" }, + { value: 7, index: 1, state: "normal" }, + { value: 11, index: 2, state: "normal" }, + { value: 15, index: 3, state: "normal" }, + ], + }, + ], + pointers: [{ id: "left", name: "left", index: 0, color: "left", showValue: true }], + variables: [ + { id: "target", name: "target", value: 9 }, + { id: "left", name: "left", value: 0 }, + ], + calculations: [], + }, + }, + { + id: "exec-2", + phase: "execution", + explanation: "Initialize right = 3 pointing to value 15.", + codeLine: 3, + dataState: { + arrays: [ + { + id: "nums", + elements: [ + { value: 2, index: 0, state: "highlighted" }, + { value: 7, index: 1, state: "normal" }, + { value: 11, index: 2, state: "normal" }, + { value: 15, index: 3, state: "highlighted" }, + ], + }, + ], + pointers: [ + { id: "left", name: "left", index: 0, color: "left", showValue: true }, + { id: "right", name: "right", index: 3, color: "right", showValue: true }, + ], + variables: [ + { id: "target", name: "target", value: 9 }, + { id: "left", name: "left", value: 0 }, + { id: "right", name: "right", value: 3 }, + ], + calculations: [], + }, + }, + { + id: "exec-3", + phase: "execution", + explanation: "Check loop condition: left (0) < right (3)? Yes, enter loop.", + codeLine: 5, + decision: { + question: "Is left < right?", + answer: "0 < 3 = true", + action: "Enter the while loop", + }, + dataState: { + arrays: [ + { + id: "nums", + elements: [ + { value: 2, index: 0, state: "highlighted" }, + { value: 7, index: 1, state: "normal" }, + { value: 11, index: 2, state: "normal" }, + { value: 15, index: 3, state: "highlighted" }, + ], + }, + ], + pointers: [ + { id: "left", name: "left", index: 0, color: "left", showValue: true }, + { id: "right", name: "right", index: 3, color: "right", showValue: true }, + ], + variables: [ + { id: "target", name: "target", value: 9 }, + { id: "left", name: "left", value: 0 }, + { id: "right", name: "right", value: 3 }, + ], + calculations: [], + }, + }, + { + id: "exec-4", + phase: "execution", + explanation: "Calculate total = nums[0] + nums[3] = 2 + 15 = 17.", + codeLine: 6, + dataState: { + arrays: [ + { + id: "nums", + elements: [ + { value: 2, index: 0, state: "comparing" }, + { value: 7, index: 1, state: "normal" }, + { value: 11, index: 2, state: "normal" }, + { value: 15, index: 3, state: "comparing" }, + ], + }, + ], + pointers: [ + { id: "left", name: "left", index: 0, color: "left", showValue: true }, + { id: "right", name: "right", index: 3, color: "right", showValue: true }, + ], + variables: [ + { id: "target", name: "target", value: 9 }, + { id: "left", name: "left", value: 0 }, + { id: "right", name: "right", value: 3 }, + { id: "total", name: "total", value: 17, derivation: "2 + 15" }, + ], + calculations: [ + { id: "calc-1", expression: "2 + 15", result: "17", position: "above" }, + ], + }, + }, + { + id: "exec-5", + phase: "execution", + explanation: "Compare: total (17) vs target (9). Total is too large!", + codeLine: 8, + decision: { + question: "Is total == target?", + answer: "17 ≠ 9", + action: "Check if total < target", + }, + dataState: { + arrays: [ + { + id: "nums", + elements: [ + { value: 2, index: 0, state: "comparing" }, + { value: 7, index: 1, state: "normal" }, + { value: 11, index: 2, state: "normal" }, + { value: 15, index: 3, state: "comparing" }, + ], + }, + ], + pointers: [ + { id: "left", name: "left", index: 0, color: "left", showValue: true }, + { id: "right", name: "right", index: 3, color: "right", showValue: true }, + ], + variables: [ + { id: "target", name: "target", value: 9 }, + { id: "left", name: "left", value: 0 }, + { id: "right", name: "right", value: 3 }, + { id: "total", name: "total", value: 17 }, + ], + calculations: [ + { id: "calc-1", expression: "17 > 9", result: "true", position: "above" }, + ], + }, + }, + { + id: "exec-6", + phase: "execution", + explanation: + "Total (17) > target (9), so we need a smaller value. Decrement right pointer.", + codeLine: 12, + decision: { + question: "Is total > target?", + answer: "17 > 9 = true", + action: "Move right pointer left (right--)", + }, + dataState: { + arrays: [ + { + id: "nums", + elements: [ + { value: 2, index: 0, state: "highlighted" }, + { value: 7, index: 1, state: "normal" }, + { value: 11, index: 2, state: "normal" }, + { value: 15, index: 3, state: "dimmed" }, + ], + }, + ], + pointers: [ + { id: "left", name: "left", index: 0, color: "left", showValue: true }, + { id: "right", name: "right", index: 3, color: "right", showValue: true }, + ], + variables: [ + { id: "target", name: "target", value: 9 }, + { id: "left", name: "left", value: 0 }, + { id: "right", name: "right", value: 3 }, + { id: "total", name: "total", value: 17 }, + ], + calculations: [], + }, + }, + { + id: "exec-7", + phase: "execution", + explanation: "Right pointer moves from index 3 to index 2 (value 11).", + codeLine: 12, + dataState: { + arrays: [ + { + id: "nums", + elements: [ + { value: 2, index: 0, state: "highlighted" }, + { value: 7, index: 1, state: "normal" }, + { value: 11, index: 2, state: "highlighted" }, + { value: 15, index: 3, state: "dimmed" }, + ], + }, + ], + pointers: [ + { id: "left", name: "left", index: 0, color: "left", showValue: true }, + { id: "right", name: "right", index: 2, color: "right", showValue: true }, + ], + variables: [ + { id: "target", name: "target", value: 9 }, + { id: "left", name: "left", value: 0 }, + { id: "right", name: "right", value: 2, previousValue: 3 }, + { id: "total", name: "total", value: 17 }, + ], + calculations: [], + }, + }, + { + id: "exec-8", + phase: "execution", + explanation: "Check loop condition: left (0) < right (2)? Yes, continue.", + codeLine: 5, + decision: { + question: "Is left < right?", + answer: "0 < 2 = true", + action: "Continue the while loop", + }, + dataState: { + arrays: [ + { + id: "nums", + elements: [ + { value: 2, index: 0, state: "highlighted" }, + { value: 7, index: 1, state: "normal" }, + { value: 11, index: 2, state: "highlighted" }, + { value: 15, index: 3, state: "dimmed" }, + ], + }, + ], + pointers: [ + { id: "left", name: "left", index: 0, color: "left", showValue: true }, + { id: "right", name: "right", index: 2, color: "right", showValue: true }, + ], + variables: [ + { id: "target", name: "target", value: 9 }, + { id: "left", name: "left", value: 0 }, + { id: "right", name: "right", value: 2 }, + ], + calculations: [], + }, + }, + { + id: "exec-9", + phase: "execution", + explanation: "Calculate total = nums[0] + nums[2] = 2 + 11 = 13.", + codeLine: 6, + dataState: { + arrays: [ + { + id: "nums", + elements: [ + { value: 2, index: 0, state: "comparing" }, + { value: 7, index: 1, state: "normal" }, + { value: 11, index: 2, state: "comparing" }, + { value: 15, index: 3, state: "dimmed" }, + ], + }, + ], + pointers: [ + { id: "left", name: "left", index: 0, color: "left", showValue: true }, + { id: "right", name: "right", index: 2, color: "right", showValue: true }, + ], + variables: [ + { id: "target", name: "target", value: 9 }, + { id: "left", name: "left", value: 0 }, + { id: "right", name: "right", value: 2 }, + { id: "total", name: "total", value: 13, previousValue: 17, derivation: "2 + 11" }, + ], + calculations: [ + { id: "calc-2", expression: "2 + 11", result: "13", position: "above" }, + ], + }, + }, + { + id: "exec-10", + phase: "execution", + explanation: "Compare: total (13) vs target (9). Still too large!", + codeLine: 8, + decision: { + question: "Is total == target?", + answer: "13 ≠ 9", + action: "Check if total > target", + }, + dataState: { + arrays: [ + { + id: "nums", + elements: [ + { value: 2, index: 0, state: "comparing" }, + { value: 7, index: 1, state: "normal" }, + { value: 11, index: 2, state: "comparing" }, + { value: 15, index: 3, state: "dimmed" }, + ], + }, + ], + pointers: [ + { id: "left", name: "left", index: 0, color: "left", showValue: true }, + { id: "right", name: "right", index: 2, color: "right", showValue: true }, + ], + variables: [ + { id: "target", name: "target", value: 9 }, + { id: "left", name: "left", value: 0 }, + { id: "right", name: "right", value: 2 }, + { id: "total", name: "total", value: 13 }, + ], + calculations: [ + { id: "calc-2", expression: "13 > 9", result: "true", position: "above" }, + ], + }, + }, + { + id: "exec-11", + phase: "execution", + explanation: + "Total (13) > target (9). Decrement right pointer again.", + codeLine: 12, + decision: { + question: "Is total > target?", + answer: "13 > 9 = true", + action: "Move right pointer left (right--)", + }, + dataState: { + arrays: [ + { + id: "nums", + elements: [ + { value: 2, index: 0, state: "highlighted" }, + { value: 7, index: 1, state: "normal" }, + { value: 11, index: 2, state: "dimmed" }, + { value: 15, index: 3, state: "dimmed" }, + ], + }, + ], + pointers: [ + { id: "left", name: "left", index: 0, color: "left", showValue: true }, + { id: "right", name: "right", index: 2, color: "right", showValue: true }, + ], + variables: [ + { id: "target", name: "target", value: 9 }, + { id: "left", name: "left", value: 0 }, + { id: "right", name: "right", value: 2 }, + { id: "total", name: "total", value: 13 }, + ], + calculations: [], + }, + }, + { + id: "exec-12", + phase: "execution", + explanation: "Right pointer moves from index 2 to index 1 (value 7).", + codeLine: 12, + dataState: { + arrays: [ + { + id: "nums", + elements: [ + { value: 2, index: 0, state: "highlighted" }, + { value: 7, index: 1, state: "highlighted" }, + { value: 11, index: 2, state: "dimmed" }, + { value: 15, index: 3, state: "dimmed" }, + ], + }, + ], + pointers: [ + { id: "left", name: "left", index: 0, color: "left", showValue: true }, + { id: "right", name: "right", index: 1, color: "right", showValue: true }, + ], + variables: [ + { id: "target", name: "target", value: 9 }, + { id: "left", name: "left", value: 0 }, + { id: "right", name: "right", value: 1, previousValue: 2 }, + { id: "total", name: "total", value: 13 }, + ], + calculations: [], + }, + }, + { + id: "exec-13", + phase: "execution", + explanation: "Check loop condition: left (0) < right (1)? Yes, continue.", + codeLine: 5, + decision: { + question: "Is left < right?", + answer: "0 < 1 = true", + action: "Continue the while loop", + }, + dataState: { + arrays: [ + { + id: "nums", + elements: [ + { value: 2, index: 0, state: "highlighted" }, + { value: 7, index: 1, state: "highlighted" }, + { value: 11, index: 2, state: "dimmed" }, + { value: 15, index: 3, state: "dimmed" }, + ], + }, + ], + pointers: [ + { id: "left", name: "left", index: 0, color: "left", showValue: true }, + { id: "right", name: "right", index: 1, color: "right", showValue: true }, + ], + variables: [ + { id: "target", name: "target", value: 9 }, + { id: "left", name: "left", value: 0 }, + { id: "right", name: "right", value: 1 }, + ], + calculations: [], + }, + }, + { + id: "exec-14", + phase: "execution", + explanation: "Calculate total = nums[0] + nums[1] = 2 + 7 = 9.", + codeLine: 6, + dataState: { + arrays: [ + { + id: "nums", + elements: [ + { value: 2, index: 0, state: "comparing" }, + { value: 7, index: 1, state: "comparing" }, + { value: 11, index: 2, state: "dimmed" }, + { value: 15, index: 3, state: "dimmed" }, + ], + }, + ], + pointers: [ + { id: "left", name: "left", index: 0, color: "left", showValue: true }, + { id: "right", name: "right", index: 1, color: "right", showValue: true }, + ], + variables: [ + { id: "target", name: "target", value: 9 }, + { id: "left", name: "left", value: 0 }, + { id: "right", name: "right", value: 1 }, + { id: "total", name: "total", value: 9, previousValue: 13, derivation: "2 + 7" }, + ], + calculations: [ + { id: "calc-3", expression: "2 + 7", result: "9", position: "above" }, + ], + }, + }, + { + id: "exec-15", + phase: "execution", + explanation: "Compare: total (9) == target (9). Found it!", + codeLine: 8, + decision: { + question: "Is total == target?", + answer: "9 == 9 = true", + action: "Return the indices [0, 1]", + }, + dataState: { + arrays: [ + { + id: "nums", + elements: [ + { value: 2, index: 0, state: "success" }, + { value: 7, index: 1, state: "success" }, + { value: 11, index: 2, state: "dimmed" }, + { value: 15, index: 3, state: "dimmed" }, + ], + }, + ], + pointers: [ + { id: "left", name: "left", index: 0, color: "left", showValue: true }, + { id: "right", name: "right", index: 1, color: "right", showValue: true }, + ], + variables: [ + { id: "target", name: "target", value: 9 }, + { id: "left", name: "left", value: 0 }, + { id: "right", name: "right", value: 1 }, + { id: "total", name: "total", value: 9 }, + ], + calculations: [ + { id: "calc-3", expression: "9 == 9", result: "true", position: "above" }, + ], + }, + }, + { + id: "exec-16", + phase: "execution", + explanation: + "Return [0, 1]. We found the answer in O(n) time using only O(1) extra space!", + codeLine: 9, + dataState: { + arrays: [ + { + id: "nums", + elements: [ + { value: 2, index: 0, state: "success" }, + { value: 7, index: 1, state: "success" }, + { value: 11, index: 2, state: "dimmed" }, + { value: 15, index: 3, state: "dimmed" }, + ], + }, + ], + pointers: [ + { id: "left", name: "left", index: 0, color: "left", showValue: true }, + { id: "right", name: "right", index: 1, color: "right", showValue: true }, + ], + variables: [ + { id: "target", name: "target", value: 9 }, + { id: "result", name: "result", value: "[0, 1]" }, + ], + calculations: [], + }, + }, + ], +}; diff --git a/frontend/src/lib/visualizations/types.ts b/frontend/src/lib/visualizations/types.ts new file mode 100644 index 0000000..2f36b08 --- /dev/null +++ b/frontend/src/lib/visualizations/types.ts @@ -0,0 +1,154 @@ +/** + * Core types for the algorithm visualization system. + * + * The visualization system is designed around a narrative structure: + * Problem -> Intuition -> Pattern -> Code -> Execution + * + * Each step changes exactly one thing to maintain clarity. + */ + +/** Phases of algorithm explanation */ +export type VisualizationPhase = + | "problem" + | "intuition" + | "pattern" + | "code" + | "execution"; + +/** State of an individual array element */ +export interface ArrayElementState { + value: number; + index: number; + state: "normal" | "highlighted" | "dimmed" | "success" | "comparing"; +} + +/** Complete array state */ +export interface ArrayState { + id: string; + elements: ArrayElementState[]; + label?: string; +} + +/** Pointer pointing to an array index */ +export interface PointerState { + id: string; + name: string; + index: number; + color: "left" | "right" | "mid" | "default"; + showValue?: boolean; +} + +/** Tracked variable value */ +export interface VariableState { + id: string; + name: string; + value: string | number; + previousValue?: string | number; + derivation?: string; +} + +/** Decision callout shown during execution */ +export interface DecisionCallout { + question: string; + answer: string; + action: string; +} + +/** Calculation bubble showing arithmetic */ +export interface CalculationState { + id: string; + expression: string; + result: string; + position: "above" | "below" | "inline"; + anchorElementId?: string; +} + +/** Animation descriptor for transitions */ +export interface Animation { + type: "move" | "highlight" | "fade" | "appear" | "disappear" | "calculate"; + targetId: string; + duration?: number; + delay?: number; +} + +/** Complete state of all visual elements at a step */ +export interface DataState { + arrays: ArrayState[]; + pointers: PointerState[]; + variables: VariableState[]; + calculations: CalculationState[]; +} + +/** Single step in the visualization */ +export interface VisualizationStep { + id: string; + phase: VisualizationPhase; + explanation: string; + decision?: DecisionCallout; + codeLine?: number; + codeHighlightLines?: number[]; + dataState: DataState; + animation?: Animation; +} + +/** Code definition for the algorithm */ +export interface AlgorithmCode { + language: "typescript" | "python" | "javascript"; + code: string; +} + +/** Pattern/technique classification */ +export interface AlgorithmPattern { + name: string; + description: string; +} + +/** Complete algorithm definition */ +export interface AlgorithmDefinition { + id: string; + title: string; + slug: string; + pattern: AlgorithmPattern; + problemStatement: string; + intuition: string; + code: AlgorithmCode; + steps: VisualizationStep[]; + initialExample?: { + input: Record; + expected: unknown; + }; +} + +/** Playback speed options */ +export type PlaybackSpeed = 0.5 | 1 | 2; + +/** Visualization playback state */ +export interface PlaybackState { + isPlaying: boolean; + currentStepIndex: number; + speed: PlaybackSpeed; +} + +/** Controls returned by the useVisualization hook */ +export interface VisualizationControls { + play: () => void; + pause: () => void; + togglePlay: () => void; + stepForward: () => void; + stepBackward: () => void; + goToStep: (index: number) => void; + goToFirst: () => void; + goToLast: () => void; + setSpeed: (speed: PlaybackSpeed) => void; +} + +/** Return type of useVisualization hook */ +export interface UseVisualizationReturn { + currentStep: VisualizationStep; + currentStepIndex: number; + totalSteps: number; + playback: PlaybackState; + controls: VisualizationControls; + currentPhase: VisualizationPhase; + progress: number; +} diff --git a/frontend/src/lib/visualizations/use-visualization.ts b/frontend/src/lib/visualizations/use-visualization.ts new file mode 100644 index 0000000..4965316 --- /dev/null +++ b/frontend/src/lib/visualizations/use-visualization.ts @@ -0,0 +1,199 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { + AlgorithmDefinition, + PlaybackSpeed, + PlaybackState, + UseVisualizationReturn, + VisualizationControls, +} from "./types"; + +const SPEED_MULTIPLIERS: Record = { + 0.5: 2, + 1: 1, + 2: 0.5, +}; + +const BASE_STEP_DURATION = 1500; + +export function useVisualization( + algorithm: AlgorithmDefinition +): UseVisualizationReturn { + const [playback, setPlayback] = useState({ + isPlaying: false, + currentStepIndex: 0, + speed: 1, + }); + + const intervalRef = useRef | null>(null); + const totalSteps = algorithm.steps.length; + + const clearAutoAdvance = useCallback(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, []); + + const stepForward = useCallback(() => { + setPlayback((prev) => ({ + ...prev, + currentStepIndex: Math.min(prev.currentStepIndex + 1, totalSteps - 1), + })); + }, [totalSteps]); + + const stepBackward = useCallback(() => { + setPlayback((prev) => ({ + ...prev, + currentStepIndex: Math.max(prev.currentStepIndex - 1, 0), + })); + }, []); + + const goToStep = useCallback( + (index: number) => { + setPlayback((prev) => ({ + ...prev, + currentStepIndex: Math.max(0, Math.min(index, totalSteps - 1)), + })); + }, + [totalSteps] + ); + + const goToFirst = useCallback(() => { + setPlayback((prev) => ({ + ...prev, + currentStepIndex: 0, + })); + }, []); + + const goToLast = useCallback(() => { + setPlayback((prev) => ({ + ...prev, + currentStepIndex: totalSteps - 1, + })); + }, [totalSteps]); + + const pause = useCallback(() => { + clearAutoAdvance(); + setPlayback((prev) => ({ + ...prev, + isPlaying: false, + })); + }, [clearAutoAdvance]); + + const play = useCallback(() => { + setPlayback((prev) => { + if (prev.currentStepIndex >= totalSteps - 1) { + return { ...prev, isPlaying: true, currentStepIndex: 0 }; + } + return { ...prev, isPlaying: true }; + }); + }, [totalSteps]); + + const togglePlay = useCallback(() => { + setPlayback((prev) => { + if (prev.isPlaying) { + clearAutoAdvance(); + return { ...prev, isPlaying: false }; + } + if (prev.currentStepIndex >= totalSteps - 1) { + return { ...prev, isPlaying: true, currentStepIndex: 0 }; + } + return { ...prev, isPlaying: true }; + }); + }, [clearAutoAdvance, totalSteps]); + + const setSpeed = useCallback((speed: PlaybackSpeed) => { + setPlayback((prev) => ({ + ...prev, + speed, + })); + }, []); + + useEffect(() => { + if (playback.isPlaying) { + const interval = + BASE_STEP_DURATION * SPEED_MULTIPLIERS[playback.speed]; + + intervalRef.current = setInterval(() => { + setPlayback((prev) => { + if (prev.currentStepIndex >= totalSteps - 1) { + clearAutoAdvance(); + return { ...prev, isPlaying: false }; + } + return { ...prev, currentStepIndex: prev.currentStepIndex + 1 }; + }); + }, interval); + } else { + clearAutoAdvance(); + } + + return clearAutoAdvance; + }, [playback.isPlaying, playback.speed, totalSteps, clearAutoAdvance]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement + ) { + return; + } + + switch (e.key) { + case " ": + e.preventDefault(); + togglePlay(); + break; + case "ArrowLeft": + e.preventDefault(); + stepBackward(); + break; + case "ArrowRight": + e.preventDefault(); + stepForward(); + break; + case "Home": + e.preventDefault(); + goToFirst(); + break; + case "End": + e.preventDefault(); + goToLast(); + break; + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [togglePlay, stepBackward, stepForward, goToFirst, goToLast]); + + const currentStep = algorithm.steps[playback.currentStepIndex]; + const progress = + totalSteps > 1 + ? (playback.currentStepIndex / (totalSteps - 1)) * 100 + : 100; + + const controls: VisualizationControls = { + play, + pause, + togglePlay, + stepForward, + stepBackward, + goToStep, + goToFirst, + goToLast, + setSpeed, + }; + + return { + currentStep, + currentStepIndex: playback.currentStepIndex, + totalSteps, + playback, + controls, + currentPhase: currentStep.phase, + progress, + }; +}