feat(viz): interactive algorithm viz system
This commit is contained in:
222
frontend/src/components/visualization/visualization-context.tsx
Normal file
222
frontend/src/components/visualization/visualization-context.tsx
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user