feat(viz): two pointers narrative
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user