feat(viz): sprint 1 - array visualisations

This commit is contained in:
2025-08-24 16:09:24 +01:00
parent 21227628fa
commit 68e5e95dda
13 changed files with 2702 additions and 187 deletions

View File

@@ -0,0 +1,103 @@
'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 PrefixSumVisualizationProps {
algorithm: AlgorithmDefinition;
className?: string;
}
export function PrefixSumVisualization({
algorithm,
className,
}: PrefixSumVisualizationProps) {
const {
currentStep,
currentStepIndex,
totalSteps,
playback,
controls,
currentPhase,
progress,
} = useVisualization(algorithm);
const { dataState } = currentStep;
const originalArray = dataState.arrays[0];
const prefixArray = dataState.arrays[1] ?? null;
const calculation = dataState.calculations[0] ?? null;
// Filter pointers for each array
// Pointers for original array: those with index < original array length
// Pointers for prefix array: shown on prefix array if it exists
const originalPointers = prefixArray
? dataState.pointers.filter(
(p) =>
p.name === 'num' ||
p.name === 'curr' ||
p.name === 'i' ||
p.name === 'j'
)
: dataState.pointers;
const prefixPointers = prefixArray
? dataState.pointers.filter(
(p) =>
p.name === 'prefix[i]' ||
p.name === 'prefix[j+1]' ||
(p.name !== 'num' &&
p.name !== 'curr' &&
p.name !== 'i' &&
p.name !== 'j')
)
: [];
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 gap-4">
{/* Fixed height area for calculation bubble */}
<div className="flex h-8 items-center justify-center">
<CalculationBubble calculation={calculation} />
</div>
{/* Original array */}
{originalArray && (
<ArrayView
array={originalArray}
pointers={originalPointers}
elementSize="sm"
/>
)}
{/* Prefix sum array (appears during execution) */}
{prefixArray && (
<ArrayView
array={prefixArray}
pointers={prefixPointers}
elementSize="sm"
/>
)}
</div>
</VisualizationContainer>
);
}

View File

@@ -1,10 +1,10 @@
"use client";
'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";
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;
@@ -59,7 +59,7 @@ export function TwoPointersVisualization({
<ArrayView
array={mainArray}
pointers={dataState.pointers}
elementSize="md"
elementSize="sm"
/>
)}
</div>

View File

@@ -1,8 +1,8 @@
"use client";
'use client';
import { AnimatePresence, motion } from "framer-motion";
import { cn } from "@/lib/utils";
import type { DecisionCallout, VisualizationPhase } from "@/lib/visualizations/types";
import { AnimatePresence, motion } from 'framer-motion';
import { cn } from '@/lib/utils';
import type { DecisionCallout, VisualizationPhase } from '@/lib/visualizations/types';
interface ExplanationPanelProps {
explanation: string;
@@ -12,19 +12,19 @@ interface ExplanationPanelProps {
}
const PHASE_LABELS: Record<VisualizationPhase, string> = {
problem: "Problem",
intuition: "Intuition",
pattern: "Pattern",
code: "Code",
execution: "Execution",
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",
problem: 'bg-error/20 text-error',
intuition: 'bg-warning/20 text-warning',
pattern: 'bg-info/20 text-info',
code: 'bg-primary/20 text-primary',
execution: 'bg-success/20 text-success',
};
export function ExplanationPanel({
@@ -36,14 +36,14 @@ export function ExplanationPanel({
return (
<div
className={cn(
"h-72 overflow-y-auto rounded-lg border border-[var(--border)] bg-[var(--card)] p-4",
'h-28 overflow-y-auto rounded-lg border border-border bg-surface 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",
'rounded-full px-2.5 py-0.5 text-xs font-medium',
PHASE_COLORS[phase]
)}
>
@@ -59,7 +59,7 @@ export function ExplanationPanel({
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
<p className="text-base leading-relaxed text-[var(--foreground)]">
<p className="text-base leading-relaxed text-foreground">
{explanation}
</p>
@@ -67,23 +67,23 @@ export function ExplanationPanel({
<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"
className="mt-4 rounded-lg border border-primary/30 bg-primary/5 p-4"
>
<div className="mb-2 text-sm font-medium text-[var(--primary)]">
<div className="mb-2 text-sm font-medium text-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>
<span className="font-medium text-foreground-muted">Q:</span>
<span className="text-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>
<span className="font-medium text-foreground-muted">A:</span>
<span className="font-mono text-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>
<span className="font-medium text-foreground-muted"></span>
<span className="text-success">{decision.action}</span>
</div>
</div>
</motion.div>

View File

@@ -1,10 +1,10 @@
"use client";
'use client';
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils';
import type {
PlaybackSpeed,
VisualizationControls,
} from "@/lib/visualizations/types";
} from '@/lib/visualizations/types';
interface StepControlsProps {
currentStepIndex: number;
@@ -33,107 +33,81 @@ export function StepControls({
return (
<div
className={cn(
"flex flex-col gap-3 rounded-lg border border-[var(--border)] bg-[var(--card)] p-4",
'flex items-center gap-4 rounded-lg border border-border bg-surface px-4 py-2',
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>
<div className="flex items-center gap-1">
<button
type="button"
onClick={controls.goToFirst}
disabled={isAtStart}
aria-label="Go to first step"
className={cn(
'rounded-md p-1.5 transition-colors',
'hover:bg-surface-variant focus:outline-none focus:ring-2 focus:ring-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.stepBackward}
disabled={isAtStart}
aria-label="Previous step"
className={cn(
'rounded-md p-1.5 transition-colors',
'hover:bg-surface-variant focus:outline-none focus:ring-2 focus:ring-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.togglePlay}
aria-label={isPlaying ? 'Pause' : 'Play'}
className={cn(
'rounded-full bg-primary p-2 text-white transition-colors',
'hover:bg-primary-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-surface'
)}
>
{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.stepForward}
disabled={isAtEnd}
aria-label="Next step"
className={cn(
'rounded-md p-1.5 transition-colors',
'hover:bg-surface-variant focus:outline-none focus:ring-2 focus:ring-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>
<button
type="button"
onClick={controls.goToLast}
disabled={isAtEnd}
aria-label="Go to last step"
className={cn(
'rounded-md p-1.5 transition-colors',
'hover:bg-surface-variant focus:outline-none focus:ring-2 focus:ring-primary',
'disabled:cursor-not-allowed disabled:opacity-40'
)}
>
<LastIcon />
</button>
</div>
<div className="relative h-2 w-full overflow-hidden rounded-full bg-[var(--muted)]">
<div className="relative h-2 flex-1 overflow-hidden rounded-full bg-surface-variant">
<button
type="button"
className="absolute inset-0 w-full cursor-pointer"
@@ -147,11 +121,35 @@ export function StepControls({
}}
>
<div
className="h-full bg-[var(--primary)] transition-all duration-200"
className="h-full bg-primary transition-all duration-200"
style={{ width: `${progress}%` }}
/>
</button>
</div>
<div className="flex items-center gap-2">
<span className="whitespace-nowrap text-xs text-foreground-muted">
{currentStepIndex + 1}/{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-border bg-surface px-1.5 py-0.5 text-xs',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
)}
>
{SPEED_OPTIONS.map((s) => (
<option key={s} value={s}>
{s}x
</option>
))}
</select>
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
'use client';
import { cn } from "@/lib/utils";
import type { ReactNode } from "react";
import { cn } from '@/lib/utils';
import type { ReactNode } from 'react';
import type {
AlgorithmCode,
AlgorithmPattern,
@@ -10,11 +10,11 @@ import type {
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";
} 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;
@@ -58,44 +58,44 @@ export function VisualizationContainer({
return (
<div
className={cn(
"flex flex-col gap-4 rounded-xl border border-[var(--border)] bg-[var(--card)] p-4",
'flex flex-col rounded-xl border border-border bg-background-subtle 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)]">
<header className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-xl font-semibold text-foreground">{title}</h2>
<span className="rounded-full bg-primary/20 px-3 py-1 text-sm font-medium text-primary">
{pattern.name}
</span>
</header>
<div className="grid gap-4 lg:grid-cols-[1fr_1.5fr]">
<div className="flex flex-col gap-4">
<div className="grid gap-3 lg:grid-cols-[1fr_1.5fr]">
<div className="flex flex-col gap-3">
<CodePanel
code={code}
currentLine={currentLine}
highlightLines={highlightLines}
className="max-h-80 min-h-48"
className="h-64"
/>
<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
className="flex min-h-80 items-center justify-center rounded-lg border border-border bg-surface px-8 py-4 lg:h-auto"
role="img"
aria-label="Algorithm visualization"
>
{children}
</div>
</div>
<ExplanationPanel
explanation={explanation}
decision={decision}
phase={phase}
className="mt-3"
/>
<StepControls
currentStepIndex={currentStepIndex}
totalSteps={totalSteps}
@@ -103,6 +103,7 @@ export function VisualizationContainer({
speed={speed}
controls={controls}
progress={progress}
className="mt-3"
/>
</div>
);

View File

@@ -14,4 +14,5 @@ export { CalculationBubble } from "./primitives/calculation-bubble";
export { ArrayView } from "./data-structures/array-view";
// Algorithm visualizations
export { PrefixSumVisualization } from "./algorithms/prefix-sum";
export { TwoPointersVisualization } from "./algorithms/two-pointers";