feat(viz): two pointers narrative
This commit is contained in:
43
frontend/package-lock.json
generated
43
frontend/package-lock.json
generated
@@ -12,6 +12,7 @@
|
|||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@tanstack/react-query": "^5.70.0",
|
"@tanstack/react-query": "^5.70.0",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
|
"framer-motion": "^12.29.2",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"next": "^15.3.0",
|
"next": "^15.3.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
@@ -5547,6 +5548,33 @@
|
|||||||
"node": ">=0.4.x"
|
"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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -8041,6 +8069,21 @@
|
|||||||
"marked": "14.0.0"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@tanstack/react-query": "^5.70.0",
|
"@tanstack/react-query": "^5.70.0",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
|
"framer-motion": "^12.29.2",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"next": "^15.3.0",
|
"next": "^15.3.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
RelatedPatterns,
|
RelatedPatterns,
|
||||||
} from "@/components/patterns";
|
} from "@/components/patterns";
|
||||||
import { PatternVisualization } from "@/components/visualization";
|
import { PatternVisualization } from "@/components/visualization";
|
||||||
|
import { TwoPointersVisualization } from "@/components/visualizations-new";
|
||||||
|
import { twoSumAlgorithm } from "@/content/algorithms/two-sum";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
@@ -106,7 +108,16 @@ export default async function PatternDetailPage({ params }: PageProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Interactive Visualization */}
|
{/* Interactive Visualization */}
|
||||||
{pattern.visualization_examples && pattern.visualization_examples.length > 0 && (
|
{slug === "two-pointers" ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Interactive Visualization</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<TwoPointersVisualization algorithm={twoSumAlgorithm} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : pattern.visualization_examples && pattern.visualization_examples.length > 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Interactive Visualization</CardTitle>
|
<CardTitle>Interactive Visualization</CardTitle>
|
||||||
@@ -115,7 +126,7 @@ export default async function PatternDetailPage({ params }: PageProps) {
|
|||||||
<PatternVisualization examples={pattern.visualization_examples} />
|
<PatternVisualization examples={pattern.visualization_examples} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{/* Static Visualization - ASCII diagram walkthrough (fallback) */}
|
{/* Static Visualization - ASCII diagram walkthrough (fallback) */}
|
||||||
{pattern.visualization && (
|
{pattern.visualization && (
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<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="md"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</VisualizationContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
frontend/src/components/visualizations-new/core/code-panel.tsx
Normal file
111
frontend/src/components/visualizations-new/core/code-panel.tsx
Normal file
@@ -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<AlgorithmCode["language"], string> = {
|
||||||
|
typescript: "typescript",
|
||||||
|
javascript: "javascript",
|
||||||
|
python: "python",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CodePanel({
|
||||||
|
code,
|
||||||
|
currentLine,
|
||||||
|
highlightLines = [],
|
||||||
|
className,
|
||||||
|
}: CodePanelProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={cn(
|
||||||
|
"overflow-auto rounded-lg border border-[var(--border)] bg-[#282c34]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="relative min-w-max">
|
||||||
|
{codeLines.map((line, i) => {
|
||||||
|
const lineNumber = i + 1;
|
||||||
|
const isCurrentLine = lineNumber === currentLine;
|
||||||
|
const isHighlighted = highlightLines.includes(lineNumber);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
"flex transition-colors duration-200",
|
||||||
|
isCurrentLine && "bg-[var(--primary)]/20",
|
||||||
|
isHighlighted && !isCurrentLine && "bg-[var(--primary)]/10"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="inline-block w-10 flex-shrink-0 select-none py-0.5 pr-3 text-right font-mono text-xs text-[var(--muted-foreground)]/60">
|
||||||
|
{lineNumber}
|
||||||
|
</span>
|
||||||
|
{isCurrentLine && (
|
||||||
|
<span
|
||||||
|
className="inline-block w-4 flex-shrink-0 py-0.5 font-mono text-xs text-[var(--primary)]"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!isCurrentLine && (
|
||||||
|
<span
|
||||||
|
className="inline-block w-4 flex-shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<SyntaxHighlighter
|
||||||
|
language={LANGUAGE_MAP[code.language]}
|
||||||
|
style={oneDark}
|
||||||
|
customStyle={{
|
||||||
|
margin: 0,
|
||||||
|
padding: "2px 8px 2px 0",
|
||||||
|
background: "transparent",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.5",
|
||||||
|
}}
|
||||||
|
codeTagProps={{
|
||||||
|
style: {
|
||||||
|
fontFamily: "var(--font-mono, monospace)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{line || " "}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<VisualizationPhase, string> = {
|
||||||
|
problem: "Problem",
|
||||||
|
intuition: "Intuition",
|
||||||
|
pattern: "Pattern",
|
||||||
|
code: "Code",
|
||||||
|
execution: "Execution",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PHASE_COLORS: Record<VisualizationPhase, string> = {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-72 overflow-y-auto rounded-lg border border-[var(--border)] bg-[var(--card)] p-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-2.5 py-0.5 text-xs font-medium",
|
||||||
|
PHASE_COLORS[phase]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{PHASE_LABELS[phase]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={explanation}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<p className="text-base leading-relaxed text-[var(--foreground)]">
|
||||||
|
{explanation}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{decision && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="mt-4 rounded-lg border border-[var(--primary)]/30 bg-[var(--primary)]/5 p-4"
|
||||||
|
>
|
||||||
|
<div className="mb-2 text-sm font-medium text-[var(--primary)]">
|
||||||
|
Decision Point
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="font-medium text-[var(--muted-foreground)]">Q:</span>
|
||||||
|
<span className="text-[var(--foreground)]">{decision.question}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="font-medium text-[var(--muted-foreground)]">A:</span>
|
||||||
|
<span className="font-mono text-[var(--primary)]">{decision.answer}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="font-medium text-[var(--muted-foreground)]">→</span>
|
||||||
|
<span className="text-green-500">{decision.action}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-3 rounded-lg border border-[var(--border)] bg-[var(--card)] p-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={controls.goToFirst}
|
||||||
|
disabled={isAtStart}
|
||||||
|
aria-label="Go to first step"
|
||||||
|
className={cn(
|
||||||
|
"rounded-md p-2 transition-colors",
|
||||||
|
"hover:bg-[var(--muted)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FirstIcon />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={controls.stepBackward}
|
||||||
|
disabled={isAtStart}
|
||||||
|
aria-label="Previous step"
|
||||||
|
className={cn(
|
||||||
|
"rounded-md p-2 transition-colors",
|
||||||
|
"hover:bg-[var(--muted)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<PreviousIcon />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={controls.togglePlay}
|
||||||
|
aria-label={isPlaying ? "Pause" : "Play"}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full bg-[var(--primary)] p-3 text-white transition-colors",
|
||||||
|
"hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:ring-offset-2"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isPlaying ? <PauseIcon /> : <PlayIcon />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={controls.stepForward}
|
||||||
|
disabled={isAtEnd}
|
||||||
|
aria-label="Next step"
|
||||||
|
className={cn(
|
||||||
|
"rounded-md p-2 transition-colors",
|
||||||
|
"hover:bg-[var(--muted)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<NextIcon />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={controls.goToLast}
|
||||||
|
disabled={isAtEnd}
|
||||||
|
aria-label="Go to last step"
|
||||||
|
className={cn(
|
||||||
|
"rounded-md p-2 transition-colors",
|
||||||
|
"hover:bg-[var(--muted)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<LastIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
Step {currentStepIndex + 1} of {totalSteps}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={speed}
|
||||||
|
onChange={(e) =>
|
||||||
|
controls.setSpeed(parseFloat(e.target.value) as PlaybackSpeed)
|
||||||
|
}
|
||||||
|
aria-label="Playback speed"
|
||||||
|
className={cn(
|
||||||
|
"rounded-md border border-[var(--border)] bg-[var(--card)] px-2 py-1 text-sm",
|
||||||
|
"focus:border-[var(--primary)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{SPEED_OPTIONS.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{s}x
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative h-2 w-full overflow-hidden rounded-full bg-[var(--muted)]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute inset-0 w-full cursor-pointer"
|
||||||
|
aria-label="Jump to step"
|
||||||
|
onClick={(e) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const percent = x / rect.width;
|
||||||
|
const step = Math.round(percent * (totalSteps - 1));
|
||||||
|
controls.goToStep(step);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full bg-[var(--primary)] transition-all duration-200"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className="h-5 w-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M4.5 5.653c0-1.426 1.529-2.33 2.779-1.643l11.54 6.348c1.295.712 1.295 2.573 0 3.285L7.28 19.991c-1.25.687-2.779-.217-2.779-1.643V5.653z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PauseIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className="h-5 w-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M6.75 5.25a.75.75 0 01.75-.75H9a.75.75 0 01.75.75v13.5a.75.75 0 01-.75.75H7.5a.75.75 0 01-.75-.75V5.25zm7.5 0A.75.75 0 0115 4.5h1.5a.75.75 0 01.75.75v13.5a.75.75 0 01-.75.75H15a.75.75 0 01-.75-.75V5.25z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviousIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className="h-5 w-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M7.72 12.53a.75.75 0 010-1.06l7.5-7.5a.75.75 0 111.06 1.06L9.31 12l6.97 6.97a.75.75 0 11-1.06 1.06l-7.5-7.5z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NextIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className="h-5 w-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M16.28 11.47a.75.75 0 010 1.06l-7.5 7.5a.75.75 0 01-1.06-1.06L14.69 12 7.72 5.03a.75.75 0 011.06-1.06l7.5 7.5z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FirstIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className="h-5 w-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M5.25 5.25a.75.75 0 01.75.75v12a.75.75 0 01-1.5 0V6a.75.75 0 01.75-.75zm6.22.53a.75.75 0 011.06 0l6 6a.75.75 0 010 1.06l-6 6a.75.75 0 01-1.06-1.06L16.19 12 11.47 6.84a.75.75 0 010-1.06z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
transform="scale(-1, 1) translate(-24, 0)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LastIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className="h-5 w-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M18.75 5.25a.75.75 0 01.75.75v12a.75.75 0 01-1.5 0V6a.75.75 0 01.75-.75zm-6.22.53a.75.75 0 00-1.06 0l-6 6a.75.75 0 000 1.06l6 6a.75.75 0 001.06-1.06L7.81 12l4.72-5.16a.75.75 0 000-1.06z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
transform="scale(-1, 1) translate(-24, 0)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-40 overflow-y-auto rounded-lg border border-[var(--border)] bg-[var(--card)] p-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mb-3 text-xs font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||||
|
Variables
|
||||||
|
</div>
|
||||||
|
<div className="flex min-h-10 flex-wrap gap-3">
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{variables.length === 0 && (
|
||||||
|
<span className="text-sm text-[var(--muted-foreground)]/50">
|
||||||
|
No variables yet
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{variables.map((variable) => (
|
||||||
|
<motion.div
|
||||||
|
key={variable.id}
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9 }}
|
||||||
|
className="flex items-center gap-2 rounded-md border border-[var(--border)] bg-[var(--muted)] px-3 py-2"
|
||||||
|
>
|
||||||
|
<span className="font-mono text-sm text-[var(--muted-foreground)]">
|
||||||
|
{variable.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[var(--muted-foreground)]">=</span>
|
||||||
|
<motion.span
|
||||||
|
key={`${variable.id}-${variable.value}`}
|
||||||
|
initial={{ scale: 1.2, color: "var(--primary)" }}
|
||||||
|
animate={{ scale: 1, color: "var(--foreground)" }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="font-mono text-sm font-medium"
|
||||||
|
>
|
||||||
|
{String(variable.value)}
|
||||||
|
</motion.span>
|
||||||
|
{variable.previousValue !== undefined &&
|
||||||
|
variable.previousValue !== variable.value && (
|
||||||
|
<span className="ml-1 text-xs text-[var(--muted-foreground)] line-through">
|
||||||
|
{String(variable.previousValue)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{variable.derivation && (
|
||||||
|
<span className="ml-1 text-xs text-[var(--muted-foreground)]">
|
||||||
|
({variable.derivation})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-4 rounded-xl border border-[var(--border)] bg-[var(--card)] p-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<header className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h2 className="text-xl font-semibold text-[var(--foreground)]">{title}</h2>
|
||||||
|
<span className="rounded-full bg-[var(--primary)]/20 px-3 py-1 text-sm font-medium text-[var(--primary)]">
|
||||||
|
{pattern.name}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[1fr_1.5fr]">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<CodePanel
|
||||||
|
code={code}
|
||||||
|
currentLine={currentLine}
|
||||||
|
highlightLines={highlightLines}
|
||||||
|
className="max-h-80 min-h-48"
|
||||||
|
/>
|
||||||
|
<VariableInspector variables={variables} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
className="flex h-64 items-center justify-center rounded-lg border border-[var(--border)] bg-[var(--background)] p-4"
|
||||||
|
role="img"
|
||||||
|
aria-label="Algorithm visualization"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<ExplanationPanel
|
||||||
|
explanation={explanation}
|
||||||
|
decision={decision}
|
||||||
|
phase={phase}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StepControls
|
||||||
|
currentStepIndex={currentStepIndex}
|
||||||
|
totalSteps={totalSteps}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
speed={speed}
|
||||||
|
controls={controls}
|
||||||
|
progress={progress}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className={cn("flex flex-col items-center", className)}>
|
||||||
|
{array.label && (
|
||||||
|
<span className="mb-2 text-sm font-medium text-[var(--muted-foreground)]">
|
||||||
|
{array.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Container that sizes to array width - ensures pointer/array alignment */}
|
||||||
|
<div className="relative" style={{ width: totalWidth }}>
|
||||||
|
{/* Pointers row */}
|
||||||
|
<div className="relative mb-1 h-10">
|
||||||
|
{pointers.map((pointer) => (
|
||||||
|
<Pointer
|
||||||
|
key={pointer.id}
|
||||||
|
pointer={pointer}
|
||||||
|
elementWidth={elementWidth}
|
||||||
|
gap={gap}
|
||||||
|
value={array.elements[pointer.index]?.value}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Array elements */}
|
||||||
|
<div
|
||||||
|
className="flex"
|
||||||
|
style={{ gap: `${gap}px` }}
|
||||||
|
>
|
||||||
|
{array.elements.map((element) => (
|
||||||
|
<ArrayElement
|
||||||
|
key={`${array.id}-${element.index}`}
|
||||||
|
element={element}
|
||||||
|
size={elementSize}
|
||||||
|
showIndex={showIndices}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
frontend/src/components/visualizations-new/index.ts
Normal file
17
frontend/src/components/visualizations-new/index.ts
Normal file
@@ -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";
|
||||||
@@ -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 (
|
||||||
|
<div className={cn("flex flex-col items-center gap-1", className)}>
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
initial={false}
|
||||||
|
animate={{
|
||||||
|
scale: element.state === "highlighted" ? 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[element.state]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{element.value}
|
||||||
|
</motion.div>
|
||||||
|
{showIndex && (
|
||||||
|
<span className="text-xs text-[var(--muted-foreground)]">{element.index}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<AnimatePresence>
|
||||||
|
{calculation && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.8, y: 10 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.8, y: -10 }}
|
||||||
|
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 rounded-lg border border-[var(--primary)]/40 bg-[var(--primary)]/10 px-3 py-2 font-mono text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-[var(--muted-foreground)]">{calculation.expression}</span>
|
||||||
|
<span className="text-[var(--primary)]">=</span>
|
||||||
|
<span className="font-semibold text-[var(--primary)]">{calculation.result}</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<motion.div
|
||||||
|
initial={false}
|
||||||
|
animate={{ left: offset }}
|
||||||
|
transition={{
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 30,
|
||||||
|
}}
|
||||||
|
className={cn("absolute top-0 h-full", className)}
|
||||||
|
>
|
||||||
|
{/* Label - centered above the point */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"absolute left-0 top-0 -translate-x-1/2 whitespace-nowrap rounded-full px-2 py-0.5 text-xs font-medium",
|
||||||
|
COLOR_CLASSES[pointer.color]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{pointer.name}
|
||||||
|
{pointer.showValue && value !== undefined && (
|
||||||
|
<span className="ml-1 font-mono">= {value}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{/* Arrow - centered at position */}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className={cn("absolute left-0 bottom-0 -translate-x-1/2 h-4 w-4", COLOR_CLASSES[pointer.color])}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M12 22L4 14h6V2h4v12h6L12 22z" />
|
||||||
|
</svg>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
802
frontend/src/content/algorithms/two-sum.ts
Normal file
802
frontend/src/content/algorithms/two-sum.ts
Normal file
@@ -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: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
154
frontend/src/lib/visualizations/types.ts
Normal file
154
frontend/src/lib/visualizations/types.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
199
frontend/src/lib/visualizations/use-visualization.ts
Normal file
199
frontend/src/lib/visualizations/use-visualization.ts
Normal file
@@ -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<PlaybackSpeed, number> = {
|
||||||
|
0.5: 2,
|
||||||
|
1: 1,
|
||||||
|
2: 0.5,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BASE_STEP_DURATION = 1500;
|
||||||
|
|
||||||
|
export function useVisualization(
|
||||||
|
algorithm: AlgorithmDefinition
|
||||||
|
): UseVisualizationReturn {
|
||||||
|
const [playback, setPlayback] = useState<PlaybackState>({
|
||||||
|
isPlaying: false,
|
||||||
|
currentStepIndex: 0,
|
||||||
|
speed: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval> | 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user