feat(viz): two pointers narrative

This commit is contained in:
2025-08-24 15:30:46 +01:00
parent 1031af4e3b
commit 2e4de5cc31
17 changed files with 2191 additions and 2 deletions
@@ -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>
);
}