feat(viz): interactive algorithm viz system

This commit is contained in:
2025-08-23 20:28:22 +01:00
parent 8b13a22397
commit de95910e70
17 changed files with 1321 additions and 1 deletions

View File

@@ -0,0 +1,222 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import type {
SpeedPreset,
VisualizationData,
VisualizationStep,
} from "@/types/visualization";
const speedValues: Record<SpeedPreset, number> = {
slow: 2000,
normal: 1000,
fast: 500,
};
interface VisualizationContextValue {
// Data
data: VisualizationData | null;
currentStep: VisualizationStep | null;
stepIndex: number;
totalSteps: number;
// Playback state
isPlaying: boolean;
speed: SpeedPreset;
// Navigation actions
goToStep: (index: number) => void;
nextStep: () => void;
prevStep: () => void;
play: () => void;
pause: () => void;
togglePlayback: () => void;
setSpeed: (speed: SpeedPreset) => void;
// State queries
canGoNext: boolean;
canGoPrev: boolean;
}
const VisualizationContext = createContext<VisualizationContextValue | null>(
null
);
interface VisualizationProviderProps {
data: VisualizationData;
initialStep?: number;
autoPlay?: boolean;
children: ReactNode;
}
export function VisualizationProvider({
data,
initialStep = 0,
autoPlay = false,
children,
}: VisualizationProviderProps) {
const [stepIndex, setStepIndex] = useState(initialStep);
const [isPlaying, setIsPlaying] = useState(autoPlay);
const [speed, setSpeed] = useState<SpeedPreset>("normal");
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const totalSteps = data.steps.length;
const currentStep = data.steps[stepIndex] ?? null;
const canGoNext = stepIndex < totalSteps - 1;
const canGoPrev = stepIndex > 0;
const goToStep = useCallback(
(index: number) => {
const clampedIndex = Math.max(0, Math.min(index, totalSteps - 1));
setStepIndex(clampedIndex);
},
[totalSteps]
);
const nextStep = useCallback(() => {
if (canGoNext) {
setStepIndex((prev) => prev + 1);
} else {
setIsPlaying(false);
}
}, [canGoNext]);
const prevStep = useCallback(() => {
if (canGoPrev) {
setStepIndex((prev) => prev - 1);
}
}, [canGoPrev]);
const play = useCallback(() => {
if (stepIndex >= totalSteps - 1) {
setStepIndex(0);
}
setIsPlaying(true);
}, [stepIndex, totalSteps]);
const pause = useCallback(() => {
setIsPlaying(false);
}, []);
const togglePlayback = useCallback(() => {
if (isPlaying) {
pause();
} else {
play();
}
}, [isPlaying, play, pause]);
// Auto-advance when playing
useEffect(() => {
if (isPlaying) {
intervalRef.current = setInterval(() => {
setStepIndex((prev) => {
if (prev >= totalSteps - 1) {
setIsPlaying(false);
return prev;
}
return prev + 1;
});
}, speedValues[speed]);
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [isPlaying, speed, totalSteps]);
// Keyboard navigation
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Don't capture if user is typing in an input
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement
) {
return;
}
switch (event.key) {
case "ArrowLeft":
event.preventDefault();
prevStep();
break;
case "ArrowRight":
event.preventDefault();
nextStep();
break;
case " ":
event.preventDefault();
togglePlayback();
break;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [prevStep, nextStep, togglePlayback]);
const value = useMemo<VisualizationContextValue>(
() => ({
data,
currentStep,
stepIndex,
totalSteps,
isPlaying,
speed,
goToStep,
nextStep,
prevStep,
play,
pause,
togglePlayback,
setSpeed,
canGoNext,
canGoPrev,
}),
[
data,
currentStep,
stepIndex,
totalSteps,
isPlaying,
speed,
goToStep,
nextStep,
prevStep,
play,
pause,
togglePlayback,
canGoNext,
canGoPrev,
]
);
return (
<VisualizationContext.Provider value={value}>
{children}
</VisualizationContext.Provider>
);
}
export function useVisualization(): VisualizationContextValue {
const context = useContext(VisualizationContext);
if (!context) {
throw new Error(
"useVisualization must be used within a VisualizationProvider"
);
}
return context;
}