feat(viz): two pointers narrative

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

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