feat(viz): interactive algorithm viz system
This commit is contained in:
@@ -61,6 +61,15 @@
|
||||
/* Badge colors - Optimal (green) */
|
||||
--badge-optimal: #16a34a;
|
||||
--badge-optimal-bg: #dcfce7;
|
||||
|
||||
/* Visualization colors */
|
||||
--viz-default: #f3f4f6;
|
||||
--viz-active: #3b82f6;
|
||||
--viz-comparing: #f59e0b;
|
||||
--viz-found: #22c55e;
|
||||
--viz-visited: #9ca3af;
|
||||
--viz-swapping: #8b5cf6;
|
||||
--viz-transition: 300ms;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@@ -125,6 +134,14 @@
|
||||
/* Badge colors - Optimal (green, dark mode) */
|
||||
--badge-optimal: #4ade80;
|
||||
--badge-optimal-bg: rgba(34, 197, 94, 0.2);
|
||||
|
||||
/* Visualization colors (dark mode) */
|
||||
--viz-default: #374151;
|
||||
--viz-active: #3b82f6;
|
||||
--viz-comparing: #f59e0b;
|
||||
--viz-found: #22c55e;
|
||||
--viz-visited: #6b7280;
|
||||
--viz-swapping: #8b5cf6;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,3 +156,26 @@ body {
|
||||
.prose-content {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* Visualization element transitions */
|
||||
.viz-element {
|
||||
transition:
|
||||
background-color var(--viz-transition) ease,
|
||||
transform var(--viz-transition) ease,
|
||||
opacity var(--viz-transition) ease;
|
||||
}
|
||||
|
||||
.viz-cell-bg {
|
||||
transition:
|
||||
fill var(--viz-transition) ease;
|
||||
}
|
||||
|
||||
.viz-value {
|
||||
transition:
|
||||
fill var(--viz-transition) ease;
|
||||
}
|
||||
|
||||
.viz-pointer {
|
||||
transition:
|
||||
transform var(--viz-transition) ease;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
RecognitionSignals,
|
||||
RelatedPatterns,
|
||||
} from "@/components/patterns";
|
||||
import { PatternVisualization } from "@/components/visualization";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
@@ -104,7 +105,19 @@ export default async function PatternDetailPage({ params }: PageProps) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Visualization - ASCII diagram walkthrough */}
|
||||
{/* Interactive Visualization */}
|
||||
{pattern.visualization_examples && pattern.visualization_examples.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Interactive Visualization</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PatternVisualization examples={pattern.visualization_examples} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Static Visualization - ASCII diagram walkthrough (fallback) */}
|
||||
{pattern.visualization && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
11
frontend/src/components/visualization/index.ts
Normal file
11
frontend/src/components/visualization/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { VisualizationContainer } from "./visualization-container";
|
||||
export {
|
||||
VisualizationProvider,
|
||||
useVisualization,
|
||||
} from "./visualization-context";
|
||||
export { StepController } from "./step-controller";
|
||||
export { StepDescription } from "./step-description";
|
||||
export { VariablesPane } from "./variables-pane";
|
||||
export { StructureRenderer } from "./structure-renderer";
|
||||
export { PatternVisualization } from "./pattern-visualization";
|
||||
export { ArrayVisualizer } from "./visualizers";
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { VisualizationContainer } from "./visualization-container";
|
||||
import type { VisualizationExample } from "@/types";
|
||||
import type { VisualizationData, VisualizationStep } from "@/types/visualization";
|
||||
|
||||
interface PatternVisualizationProps {
|
||||
examples: VisualizationExample[];
|
||||
}
|
||||
|
||||
function transformToVisualizationData(
|
||||
example: VisualizationExample
|
||||
): VisualizationData {
|
||||
const steps: VisualizationStep[] = example.steps.map((step) => ({
|
||||
id: step.id,
|
||||
description: step.description,
|
||||
structures: step.structures as VisualizationStep["structures"],
|
||||
variables: step.variables,
|
||||
codeHighlight: step.codeHighlight,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: example.id,
|
||||
title: example.title,
|
||||
code: example.code,
|
||||
steps,
|
||||
totalSteps: steps.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function PatternVisualization({ examples }: PatternVisualizationProps) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
if (!examples || examples.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedExample = examples[selectedIndex];
|
||||
const visualizationData = transformToVisualizationData(selectedExample);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Example selector (if multiple) */}
|
||||
{examples.length > 1 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{examples.map((example, index) => (
|
||||
<button
|
||||
key={example.id}
|
||||
onClick={() => setSelectedIndex(index)}
|
||||
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
index === selectedIndex
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
{example.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Visualization */}
|
||||
<VisualizationContainer data={visualizationData} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
174
frontend/src/components/visualization/step-controller.tsx
Normal file
174
frontend/src/components/visualization/step-controller.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { useVisualization } from "./visualization-context";
|
||||
import type { SpeedPreset } from "@/types/visualization";
|
||||
|
||||
const speedLabels: Record<SpeedPreset, string> = {
|
||||
slow: "0.5x",
|
||||
normal: "1x",
|
||||
fast: "2x",
|
||||
};
|
||||
|
||||
const speedOrder: SpeedPreset[] = ["slow", "normal", "fast"];
|
||||
|
||||
export function StepController() {
|
||||
const {
|
||||
stepIndex,
|
||||
totalSteps,
|
||||
isPlaying,
|
||||
speed,
|
||||
nextStep,
|
||||
prevStep,
|
||||
togglePlayback,
|
||||
setSpeed,
|
||||
canGoNext,
|
||||
canGoPrev,
|
||||
} = useVisualization();
|
||||
|
||||
const cycleSpeed = () => {
|
||||
const currentIndex = speedOrder.indexOf(speed);
|
||||
const nextIndex = (currentIndex + 1) % speedOrder.length;
|
||||
setSpeed(speedOrder[nextIndex]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between gap-4 rounded-lg border border-border bg-card p-3"
|
||||
role="toolbar"
|
||||
aria-label="Visualization controls"
|
||||
>
|
||||
{/* Step counter */}
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="font-medium">
|
||||
Step {stepIndex + 1} of {totalSteps}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Navigation controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Previous */}
|
||||
<button
|
||||
onClick={prevStep}
|
||||
disabled={!canGoPrev}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md border border-border bg-background transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label="Previous step"
|
||||
title="Previous step (←)"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Play/Pause */}
|
||||
<button
|
||||
onClick={togglePlayback}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md border border-border bg-primary text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
title={isPlaying ? "Pause (Space)" : "Play (Space)"}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="6" y="4" width="4" height="16" />
|
||||
<rect x="14" y="4" width="4" height="16" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Next */}
|
||||
<button
|
||||
onClick={nextStep}
|
||||
disabled={!canGoNext}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md border border-border bg-background transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label="Next step"
|
||||
title="Next step (→)"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Speed control */}
|
||||
<button
|
||||
onClick={cycleSpeed}
|
||||
className="flex h-8 min-w-[48px] items-center justify-center rounded-md border border-border bg-background px-2 text-sm font-medium transition-colors hover:bg-muted"
|
||||
aria-label={`Playback speed: ${speedLabels[speed]}`}
|
||||
title="Cycle playback speed"
|
||||
>
|
||||
{speedLabels[speed]}
|
||||
</button>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div
|
||||
className="hidden flex-1 sm:block"
|
||||
role="progressbar"
|
||||
aria-valuenow={stepIndex + 1}
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={totalSteps}
|
||||
aria-label={`Step ${stepIndex + 1} of ${totalSteps}`}
|
||||
>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${((stepIndex + 1) / totalSteps) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyboard hints */}
|
||||
<div className="hidden items-center gap-1 text-xs text-muted-foreground lg:flex">
|
||||
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5">
|
||||
←
|
||||
</kbd>
|
||||
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5">
|
||||
→
|
||||
</kbd>
|
||||
<span className="mx-1">step</span>
|
||||
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5">
|
||||
Space
|
||||
</kbd>
|
||||
<span>play</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/visualization/step-description.tsx
Normal file
21
frontend/src/components/visualization/step-description.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useVisualization } from "./visualization-context";
|
||||
|
||||
export function StepDescription() {
|
||||
const { currentStep } = useVisualization();
|
||||
|
||||
if (!currentStep) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border border-border bg-muted/50 p-4"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<p className="text-sm leading-relaxed">{currentStep.description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
frontend/src/components/visualization/structure-renderer.tsx
Normal file
88
frontend/src/components/visualization/structure-renderer.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useVisualization } from "./visualization-context";
|
||||
import { ArrayVisualizer } from "./visualizers/array-visualizer";
|
||||
import type { DataStructureState } from "@/types/visualization";
|
||||
|
||||
interface StructureVisualizerProps {
|
||||
name: string;
|
||||
data: DataStructureState;
|
||||
}
|
||||
|
||||
function StructureVisualizer({ name, data }: StructureVisualizerProps) {
|
||||
switch (data.type) {
|
||||
case "array":
|
||||
return <ArrayVisualizer data={data} name={name} />;
|
||||
case "linkedlist":
|
||||
// Phase 3: LinkedListVisualizer
|
||||
return (
|
||||
<div className="text-muted-foreground">
|
||||
LinkedList visualization coming soon
|
||||
</div>
|
||||
);
|
||||
case "stack":
|
||||
// Phase 3: StackVisualizer
|
||||
return (
|
||||
<div className="text-muted-foreground">
|
||||
Stack visualization coming soon
|
||||
</div>
|
||||
);
|
||||
case "tree":
|
||||
// Phase 3: TreeVisualizer
|
||||
return (
|
||||
<div className="text-muted-foreground">
|
||||
Tree visualization coming soon
|
||||
</div>
|
||||
);
|
||||
case "graph":
|
||||
// Phase 5: GraphVisualizer
|
||||
return (
|
||||
<div className="text-muted-foreground">
|
||||
Graph visualization coming soon
|
||||
</div>
|
||||
);
|
||||
case "matrix":
|
||||
// Phase 5: MatrixVisualizer
|
||||
return (
|
||||
<div className="text-muted-foreground">
|
||||
Matrix visualization coming soon
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="text-muted-foreground">
|
||||
Unknown data structure type
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function StructureRenderer() {
|
||||
const { currentStep } = useVisualization();
|
||||
|
||||
if (!currentStep?.structures) {
|
||||
return (
|
||||
<div className="text-center text-muted-foreground">
|
||||
No data structures to display
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const structureEntries = Object.entries(currentStep.structures);
|
||||
|
||||
if (structureEntries.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-muted-foreground">
|
||||
No data structures to display
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{structureEntries.map(([name, data]) => (
|
||||
<StructureVisualizer key={name} name={name} data={data} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
frontend/src/components/visualization/variables-pane.tsx
Normal file
43
frontend/src/components/visualization/variables-pane.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useVisualization } from "./visualization-context";
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (value === null) return "null";
|
||||
if (value === undefined) return "undefined";
|
||||
if (typeof value === "string") return `"${value}"`;
|
||||
if (typeof value === "boolean") return value ? "true" : "false";
|
||||
if (Array.isArray(value)) return `[${value.map(formatValue).join(", ")}]`;
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function VariablesPane() {
|
||||
const { currentStep } = useVisualization();
|
||||
|
||||
if (!currentStep?.variables || Object.keys(currentStep.variables).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-3">
|
||||
<h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Variables
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{Object.entries(currentStep.variables).map(([name, value]) => (
|
||||
<div
|
||||
key={name}
|
||||
className="flex items-center gap-2 rounded-md bg-muted px-2 py-1"
|
||||
>
|
||||
<span className="font-mono text-sm font-medium text-primary">
|
||||
{name}
|
||||
</span>
|
||||
<span className="text-muted-foreground">=</span>
|
||||
<span className="font-mono text-sm">{formatValue(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { VisualizationProvider } from "./visualization-context";
|
||||
import { StepController } from "./step-controller";
|
||||
import { StepDescription } from "./step-description";
|
||||
import { VariablesPane } from "./variables-pane";
|
||||
import { StructureRenderer } from "./structure-renderer";
|
||||
import type { VisualizationData } from "@/types/visualization";
|
||||
|
||||
interface VisualizationContainerProps {
|
||||
data: VisualizationData;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function VisualizationContainer({
|
||||
data,
|
||||
className = "",
|
||||
}: VisualizationContainerProps) {
|
||||
if (!data.steps || data.steps.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-muted/50 p-8 text-center text-muted-foreground">
|
||||
No visualization steps available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VisualizationProvider data={data}>
|
||||
<div className={`flex flex-col gap-4 ${className}`}>
|
||||
{/* Title */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">{data.title}</h3>
|
||||
</div>
|
||||
|
||||
{/* Step description */}
|
||||
<StepDescription />
|
||||
|
||||
{/* Data structure visualization */}
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<StructureRenderer />
|
||||
</div>
|
||||
|
||||
{/* Variables */}
|
||||
<VariablesPane />
|
||||
|
||||
{/* Controls */}
|
||||
<StepController />
|
||||
</div>
|
||||
</VisualizationProvider>
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { ArrayState, ElementState } from "@/types/visualization";
|
||||
|
||||
interface ArrayVisualizerProps {
|
||||
data: ArrayState;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
const CELL_WIDTH = 56;
|
||||
const CELL_HEIGHT = 56;
|
||||
const CELL_GAP = 4;
|
||||
const INDEX_AREA_HEIGHT = 20;
|
||||
const POINTER_HEIGHT = 24;
|
||||
const ANNOTATION_HEIGHT = 20;
|
||||
const SVG_PADDING = 16;
|
||||
|
||||
function getStateColor(state: ElementState): string {
|
||||
switch (state) {
|
||||
case "active":
|
||||
return "var(--viz-active)";
|
||||
case "comparing":
|
||||
return "var(--viz-comparing)";
|
||||
case "found":
|
||||
return "var(--viz-found)";
|
||||
case "visited":
|
||||
return "var(--viz-visited)";
|
||||
case "swapping":
|
||||
return "var(--viz-swapping)";
|
||||
default:
|
||||
return "var(--viz-default)";
|
||||
}
|
||||
}
|
||||
|
||||
function getTextColor(state: ElementState): string {
|
||||
switch (state) {
|
||||
case "active":
|
||||
case "found":
|
||||
case "swapping":
|
||||
return "white";
|
||||
case "comparing":
|
||||
return "var(--foreground)";
|
||||
case "visited":
|
||||
return "var(--muted-foreground)";
|
||||
default:
|
||||
return "var(--foreground)";
|
||||
}
|
||||
}
|
||||
|
||||
export function ArrayVisualizer({ data, name }: ArrayVisualizerProps) {
|
||||
const { values, pointers = {} } = data;
|
||||
|
||||
const dimensions = useMemo(() => {
|
||||
const hasPointers = Object.keys(pointers).length > 0;
|
||||
const hasAnnotations = values.some(
|
||||
(v) => v.annotations && v.annotations.length > 0
|
||||
);
|
||||
|
||||
const contentWidth =
|
||||
values.length * CELL_WIDTH + (values.length - 1) * CELL_GAP;
|
||||
const width = contentWidth + SVG_PADDING * 2;
|
||||
|
||||
let height = CELL_HEIGHT + INDEX_AREA_HEIGHT + SVG_PADDING * 2;
|
||||
if (hasPointers) height += POINTER_HEIGHT + 16;
|
||||
if (hasAnnotations) height += ANNOTATION_HEIGHT;
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
contentWidth,
|
||||
hasPointers,
|
||||
hasAnnotations,
|
||||
startX: SVG_PADDING,
|
||||
startY: hasAnnotations
|
||||
? SVG_PADDING + ANNOTATION_HEIGHT
|
||||
: SVG_PADDING + 8,
|
||||
};
|
||||
}, [values, pointers]);
|
||||
|
||||
// Group pointers by position for stacking
|
||||
const pointersByPosition = useMemo(() => {
|
||||
const grouped: Record<number, string[]> = {};
|
||||
for (const [label, index] of Object.entries(pointers)) {
|
||||
if (!grouped[index]) {
|
||||
grouped[index] = [];
|
||||
}
|
||||
grouped[index].push(label);
|
||||
}
|
||||
return grouped;
|
||||
}, [pointers]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{name && (
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{name}
|
||||
</span>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<svg
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
|
||||
className="viz-array"
|
||||
role="img"
|
||||
aria-label={`Array visualization with ${values.length} elements`}
|
||||
>
|
||||
{/* Array cells */}
|
||||
<g>
|
||||
{values.map((element, index) => {
|
||||
const x = dimensions.startX + index * (CELL_WIDTH + CELL_GAP);
|
||||
const y = dimensions.startY;
|
||||
|
||||
return (
|
||||
<g
|
||||
key={index}
|
||||
className="viz-element"
|
||||
data-state={element.state}
|
||||
>
|
||||
{/* Cell background */}
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={CELL_WIDTH}
|
||||
height={CELL_HEIGHT}
|
||||
rx={6}
|
||||
fill={getStateColor(element.state)}
|
||||
className="viz-cell-bg"
|
||||
/>
|
||||
|
||||
{/* Cell border */}
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={CELL_WIDTH}
|
||||
height={CELL_HEIGHT}
|
||||
rx={6}
|
||||
fill="none"
|
||||
stroke="var(--border)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
|
||||
{/* Value */}
|
||||
<text
|
||||
x={x + CELL_WIDTH / 2}
|
||||
y={y + CELL_HEIGHT / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
className="viz-value"
|
||||
fill={getTextColor(element.state)}
|
||||
fontSize={18}
|
||||
fontWeight={500}
|
||||
fontFamily="var(--font-mono, monospace)"
|
||||
>
|
||||
{String(element.value)}
|
||||
</text>
|
||||
|
||||
{/* Annotations (above cell) */}
|
||||
{element.annotations && element.annotations.length > 0 && (
|
||||
<text
|
||||
x={x + CELL_WIDTH / 2}
|
||||
y={y - 6}
|
||||
textAnchor="middle"
|
||||
className="viz-annotation"
|
||||
fill="var(--primary)"
|
||||
fontSize={12}
|
||||
fontWeight={600}
|
||||
>
|
||||
{element.annotations.join(", ")}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
{/* Pointers (below cells, before indices) */}
|
||||
{dimensions.hasPointers && (
|
||||
<g className="viz-pointers">
|
||||
{Object.entries(pointersByPosition).map(([posStr, labels]) => {
|
||||
const position = parseInt(posStr, 10);
|
||||
if (position < 0 || position >= values.length) return null;
|
||||
|
||||
const x =
|
||||
dimensions.startX +
|
||||
position * (CELL_WIDTH + CELL_GAP) +
|
||||
CELL_WIDTH / 2;
|
||||
const y =
|
||||
dimensions.startY + CELL_HEIGHT + INDEX_AREA_HEIGHT + POINTER_HEIGHT;
|
||||
|
||||
return (
|
||||
<g key={posStr} className="viz-pointer">
|
||||
{/* Arrow */}
|
||||
<line
|
||||
x1={x}
|
||||
y1={y}
|
||||
x2={x}
|
||||
y2={y - 10}
|
||||
stroke="var(--primary)"
|
||||
strokeWidth={2}
|
||||
markerEnd="url(#arrowhead)"
|
||||
/>
|
||||
|
||||
{/* Label(s) */}
|
||||
<text
|
||||
x={x}
|
||||
y={y + 12}
|
||||
textAnchor="middle"
|
||||
fill="var(--primary)"
|
||||
fontSize={12}
|
||||
fontWeight={600}
|
||||
fontFamily="var(--font-mono, monospace)"
|
||||
>
|
||||
{labels.join(", ")}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Index labels (rendered last to appear on top) */}
|
||||
<g className="viz-indices">
|
||||
{values.map((_, index) => {
|
||||
const x = dimensions.startX + index * (CELL_WIDTH + CELL_GAP);
|
||||
const y = dimensions.startY;
|
||||
|
||||
return (
|
||||
<text
|
||||
key={index}
|
||||
x={x + CELL_WIDTH / 2}
|
||||
y={y + CELL_HEIGHT + INDEX_AREA_HEIGHT / 2 + 4}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
className="viz-index"
|
||||
fill="var(--muted-foreground)"
|
||||
fontSize={11}
|
||||
>
|
||||
{index}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
{/* Arrow marker definition */}
|
||||
<defs>
|
||||
<marker
|
||||
id="arrowhead"
|
||||
markerWidth="8"
|
||||
markerHeight="6"
|
||||
refX="0"
|
||||
refY="3"
|
||||
orient="auto"
|
||||
>
|
||||
<polygon points="0 0, 8 3, 0 6" fill="var(--primary)" />
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ArrayVisualizer } from "./array-visualizer";
|
||||
@@ -57,6 +57,22 @@ export interface LearningProgression {
|
||||
challenge: LearningQuestion[];
|
||||
}
|
||||
|
||||
export interface VisualizationExampleStep {
|
||||
id: string;
|
||||
description: string;
|
||||
structures: Record<string, unknown>;
|
||||
variables?: Record<string, unknown>;
|
||||
codeHighlight?: { startLine: number; endLine: number };
|
||||
}
|
||||
|
||||
export interface VisualizationExample {
|
||||
id: string;
|
||||
title: string;
|
||||
input?: Record<string, unknown>;
|
||||
code: string;
|
||||
steps: VisualizationExampleStep[];
|
||||
}
|
||||
|
||||
export interface PatternTutorial extends Pattern {
|
||||
metaphor: string | null;
|
||||
core_concept: string | null;
|
||||
@@ -69,6 +85,7 @@ export interface PatternTutorial extends Pattern {
|
||||
prerequisite_patterns: RelatedPattern[] | null;
|
||||
difficulty_level: number | null;
|
||||
learning_progression: LearningProgression | null;
|
||||
visualization_examples: VisualizationExample[] | null;
|
||||
}
|
||||
|
||||
export interface QuestionListItem {
|
||||
|
||||
160
frontend/src/types/visualization.ts
Normal file
160
frontend/src/types/visualization.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
// Visualization System Types
|
||||
|
||||
export type ElementState =
|
||||
| "default"
|
||||
| "active"
|
||||
| "comparing"
|
||||
| "found"
|
||||
| "visited"
|
||||
| "swapping";
|
||||
|
||||
// Array Data Structure
|
||||
export interface ArrayElement {
|
||||
value: unknown;
|
||||
state: ElementState;
|
||||
annotations?: string[];
|
||||
}
|
||||
|
||||
export interface ArrayState {
|
||||
type: "array";
|
||||
values: ArrayElement[];
|
||||
pointers?: Record<string, number>;
|
||||
}
|
||||
|
||||
// Linked List Data Structure
|
||||
export interface LinkedListNode {
|
||||
value: unknown;
|
||||
state: ElementState;
|
||||
annotations?: string[];
|
||||
}
|
||||
|
||||
export interface LinkedListState {
|
||||
type: "linkedlist";
|
||||
nodes: LinkedListNode[];
|
||||
pointers?: Record<string, number>;
|
||||
}
|
||||
|
||||
// Stack Data Structure
|
||||
export interface StackState {
|
||||
type: "stack";
|
||||
values: Array<{
|
||||
value: unknown;
|
||||
state: ElementState;
|
||||
annotations?: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
// Tree Data Structure
|
||||
export interface TreeNode {
|
||||
value: unknown;
|
||||
state: ElementState;
|
||||
annotations?: string[];
|
||||
left?: TreeNode | null;
|
||||
right?: TreeNode | null;
|
||||
}
|
||||
|
||||
export interface TreeState {
|
||||
type: "tree";
|
||||
root: TreeNode | null;
|
||||
pointers?: Record<string, string>;
|
||||
}
|
||||
|
||||
// Graph Data Structure
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
value: unknown;
|
||||
state: ElementState;
|
||||
annotations?: string[];
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
from: string;
|
||||
to: string;
|
||||
weight?: number;
|
||||
state: ElementState;
|
||||
}
|
||||
|
||||
export interface GraphState {
|
||||
type: "graph";
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
directed?: boolean;
|
||||
}
|
||||
|
||||
// Matrix Data Structure
|
||||
export interface MatrixCell {
|
||||
value: unknown;
|
||||
state: ElementState;
|
||||
annotations?: string[];
|
||||
}
|
||||
|
||||
export interface MatrixState {
|
||||
type: "matrix";
|
||||
rows: MatrixCell[][];
|
||||
pointers?: Record<string, { row: number; col: number }>;
|
||||
}
|
||||
|
||||
// Union type for all data structures
|
||||
export type DataStructureState =
|
||||
| ArrayState
|
||||
| LinkedListState
|
||||
| StackState
|
||||
| TreeState
|
||||
| GraphState
|
||||
| MatrixState;
|
||||
|
||||
// Code highlighting range
|
||||
export interface CodeHighlight {
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
}
|
||||
|
||||
// Transition configuration
|
||||
export interface TransitionConfig {
|
||||
duration?: number;
|
||||
emphasis?: string;
|
||||
}
|
||||
|
||||
// Core visualization step
|
||||
export interface VisualizationStep {
|
||||
id: string;
|
||||
description: string;
|
||||
structures: Record<string, DataStructureState>;
|
||||
variables?: Record<string, unknown>;
|
||||
codeHighlight?: CodeHighlight;
|
||||
transition?: TransitionConfig;
|
||||
}
|
||||
|
||||
// Complete visualization data
|
||||
export interface VisualizationData {
|
||||
id: string;
|
||||
title: string;
|
||||
code: string;
|
||||
steps: VisualizationStep[];
|
||||
totalSteps: number;
|
||||
}
|
||||
|
||||
// Visualization example from API (stored in pattern YAML)
|
||||
export interface VisualizationExample {
|
||||
id: string;
|
||||
title: string;
|
||||
input?: Record<string, unknown>;
|
||||
code: string;
|
||||
steps: VisualizationStep[];
|
||||
}
|
||||
|
||||
// Playback state for context
|
||||
export interface PlaybackState {
|
||||
currentStep: number;
|
||||
isPlaying: boolean;
|
||||
speed: number;
|
||||
}
|
||||
|
||||
// Speed presets (ms per step)
|
||||
export const SPEED_PRESETS = {
|
||||
slow: 2000,
|
||||
normal: 1000,
|
||||
fast: 500,
|
||||
} as const;
|
||||
|
||||
export type SpeedPreset = keyof typeof SPEED_PRESETS;
|
||||
Reference in New Issue
Block a user