feat(viz): two pointers narrative

This commit is contained in:
2025-08-24 15:30:46 +01:00
parent de95910e70
commit 3caa628d59
17 changed files with 2191 additions and 2 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 && (

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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";

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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: [],
},
},
],
};

View 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;
}

View 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,
};
}