feat(viz): two pointers narrative
This commit is contained in:
154
frontend/src/lib/visualizations/types.ts
Normal file
154
frontend/src/lib/visualizations/types.ts
Normal 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;
|
||||
}
|
||||
199
frontend/src/lib/visualizations/use-visualization.ts
Normal file
199
frontend/src/lib/visualizations/use-visualization.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user