From e5ebe7b188cec280a962f4171e17cb1cf49fe448 Mon Sep 17 00:00:00 2001 From: Kai Chappell Date: Sat, 23 Aug 2025 20:28:22 +0100 Subject: [PATCH] feat(viz): interactive algorithm viz system --- .../005_add_visualization_examples.py | 33 +++ backend/data/patterns/two-pointers.yaml | 113 ++++++++ backend/scripts/load_data.py | 3 + frontend/src/app/globals.css | 40 +++ frontend/src/app/patterns/[slug]/page.tsx | 15 +- .../src/components/visualization/index.ts | 11 + .../visualization/pattern-visualization.tsx | 67 +++++ .../visualization/step-controller.tsx | 174 ++++++++++++ .../visualization/step-description.tsx | 21 ++ .../visualization/structure-renderer.tsx | 88 ++++++ .../visualization/variables-pane.tsx | 43 +++ .../visualization/visualization-container.tsx | 51 ++++ .../visualization/visualization-context.tsx | 222 +++++++++++++++ .../visualizers/array-visualizer.tsx | 263 ++++++++++++++++++ .../visualization/visualizers/index.ts | 1 + frontend/src/types/index.ts | 17 ++ frontend/src/types/visualization.ts | 160 +++++++++++ 17 files changed, 1321 insertions(+), 1 deletion(-) create mode 100644 backend/alembic/versions/005_add_visualization_examples.py create mode 100644 frontend/src/components/visualization/index.ts create mode 100644 frontend/src/components/visualization/pattern-visualization.tsx create mode 100644 frontend/src/components/visualization/step-controller.tsx create mode 100644 frontend/src/components/visualization/step-description.tsx create mode 100644 frontend/src/components/visualization/structure-renderer.tsx create mode 100644 frontend/src/components/visualization/variables-pane.tsx create mode 100644 frontend/src/components/visualization/visualization-container.tsx create mode 100644 frontend/src/components/visualization/visualization-context.tsx create mode 100644 frontend/src/components/visualization/visualizers/array-visualizer.tsx create mode 100644 frontend/src/components/visualization/visualizers/index.ts create mode 100644 frontend/src/types/visualization.ts diff --git a/backend/alembic/versions/005_add_visualization_examples.py b/backend/alembic/versions/005_add_visualization_examples.py new file mode 100644 index 0000000..0e0cfa0 --- /dev/null +++ b/backend/alembic/versions/005_add_visualization_examples.py @@ -0,0 +1,33 @@ +"""add visualization examples to patterns + +Revision ID: 005 +Revises: 004 +Create Date: 2025-05-10 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "005" +down_revision: str | None = "004" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "patterns", + sa.Column( + "visualization_examples", + postgresql.JSONB(astext_type=sa.Text()), + nullable=True, + ), + ) + + +def downgrade() -> None: + op.drop_column("patterns", "visualization_examples") diff --git a/backend/data/patterns/two-pointers.yaml b/backend/data/patterns/two-pointers.yaml index 61504a5..a631f2e 100644 --- a/backend/data/patterns/two-pointers.yaml +++ b/backend/data/patterns/two-pointers.yaml @@ -164,3 +164,116 @@ related_patterns: - binary-search prerequisite_patterns: [] + +visualization_examples: + - id: "find-pair-sum" + title: "Find pair with sum = 10" + input: + array: [1, 2, 4, 6, 8, 10] + target: 10 + code: | + left, right = 0, len(arr) - 1 + while left < right: + curr = arr[left] + arr[right] + if curr == target: + return [left, right] + elif curr < target: + left += 1 + else: + right -= 1 + steps: + - id: "1" + description: "Initialize pointers at both ends of the sorted array. Left starts at index 0 (value 1), right starts at index 5 (value 10)." + structures: + array: + type: array + values: + - { value: 1, state: active, annotations: ["L"] } + - { value: 2, state: default } + - { value: 4, state: default } + - { value: 6, state: default } + - { value: 8, state: default } + - { value: 10, state: active, annotations: ["R"] } + pointers: { left: 0, right: 5 } + variables: { left: 0, right: 5, target: 10 } + codeHighlight: { startLine: 1, endLine: 1 } + + - id: "2" + description: "Calculate current sum: 1 + 10 = 11. This is greater than target (10), so we need a smaller sum. Move right pointer left." + structures: + array: + type: array + values: + - { value: 1, state: comparing, annotations: ["L"] } + - { value: 2, state: default } + - { value: 4, state: default } + - { value: 6, state: default } + - { value: 8, state: default } + - { value: 10, state: comparing, annotations: ["R"] } + pointers: { left: 0, right: 5 } + variables: { left: 0, right: 5, curr: 11, target: 10 } + codeHighlight: { startLine: 3, endLine: 8 } + + - id: "3" + description: "Right pointer moved to index 4 (value 8). Now checking new sum." + structures: + array: + type: array + values: + - { value: 1, state: active, annotations: ["L"] } + - { value: 2, state: default } + - { value: 4, state: default } + - { value: 6, state: default } + - { value: 8, state: active, annotations: ["R"] } + - { value: 10, state: visited } + pointers: { left: 0, right: 4 } + variables: { left: 0, right: 4, target: 10 } + codeHighlight: { startLine: 8, endLine: 8 } + + - id: "4" + description: "Calculate current sum: 1 + 8 = 9. This is less than target (10), so we need a larger sum. Move left pointer right." + structures: + array: + type: array + values: + - { value: 1, state: comparing, annotations: ["L"] } + - { value: 2, state: default } + - { value: 4, state: default } + - { value: 6, state: default } + - { value: 8, state: comparing, annotations: ["R"] } + - { value: 10, state: visited } + pointers: { left: 0, right: 4 } + variables: { left: 0, right: 4, curr: 9, target: 10 } + codeHighlight: { startLine: 3, endLine: 7 } + + - id: "5" + description: "Left pointer moved to index 1 (value 2). Now checking new sum." + structures: + array: + type: array + values: + - { value: 1, state: visited } + - { value: 2, state: active, annotations: ["L"] } + - { value: 4, state: default } + - { value: 6, state: default } + - { value: 8, state: active, annotations: ["R"] } + - { value: 10, state: visited } + pointers: { left: 1, right: 4 } + variables: { left: 1, right: 4, target: 10 } + codeHighlight: { startLine: 7, endLine: 7 } + + - id: "6" + description: "Calculate current sum: 2 + 8 = 10. This equals the target! We found our pair at indices [1, 4]." + structures: + array: + type: array + values: + - { value: 1, state: visited } + - { value: 2, state: found, annotations: ["L"] } + - { value: 4, state: default } + - { value: 6, state: default } + - { value: 8, state: found, annotations: ["R"] } + - { value: 10, state: visited } + pointers: { left: 1, right: 4 } + variables: { left: 1, right: 4, curr: 10, target: 10 } + codeHighlight: { startLine: 4, endLine: 5 } diff --git a/backend/scripts/load_data.py b/backend/scripts/load_data.py index c513489..93dd4e0 100644 --- a/backend/scripts/load_data.py +++ b/backend/scripts/load_data.py @@ -130,6 +130,9 @@ async def _upsert_pattern(session: AsyncSession, item: dict[str, Any]) -> Patter # Difficulty level pattern.difficulty_level = item.get("difficulty_level") + # Interactive visualization examples + pattern.visualization_examples = item.get("visualization_examples") + return pattern diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 3a4db4b..75cc62e 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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; +} diff --git a/frontend/src/app/patterns/[slug]/page.tsx b/frontend/src/app/patterns/[slug]/page.tsx index 1454a7b..c52da02 100644 --- a/frontend/src/app/patterns/[slug]/page.tsx +++ b/frontend/src/app/patterns/[slug]/page.tsx @@ -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) { )} - {/* Visualization - ASCII diagram walkthrough */} + {/* Interactive Visualization */} + {pattern.visualization_examples && pattern.visualization_examples.length > 0 && ( + + + Interactive Visualization + + + + + + )} + + {/* Static Visualization - ASCII diagram walkthrough (fallback) */} {pattern.visualization && ( diff --git a/frontend/src/components/visualization/index.ts b/frontend/src/components/visualization/index.ts new file mode 100644 index 0000000..113ec83 --- /dev/null +++ b/frontend/src/components/visualization/index.ts @@ -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"; diff --git a/frontend/src/components/visualization/pattern-visualization.tsx b/frontend/src/components/visualization/pattern-visualization.tsx new file mode 100644 index 0000000..f535fe1 --- /dev/null +++ b/frontend/src/components/visualization/pattern-visualization.tsx @@ -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 ( +
+ {/* Example selector (if multiple) */} + {examples.length > 1 && ( +
+ {examples.map((example, index) => ( + + ))} +
+ )} + + {/* Visualization */} + +
+ ); +} diff --git a/frontend/src/components/visualization/step-controller.tsx b/frontend/src/components/visualization/step-controller.tsx new file mode 100644 index 0000000..8905572 --- /dev/null +++ b/frontend/src/components/visualization/step-controller.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { useVisualization } from "./visualization-context"; +import type { SpeedPreset } from "@/types/visualization"; + +const speedLabels: Record = { + 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 ( +
+ {/* Step counter */} +
+ + Step {stepIndex + 1} of {totalSteps} + +
+ + {/* Navigation controls */} +
+ {/* Previous */} + + + {/* Play/Pause */} + + + {/* Next */} + +
+ + {/* Speed control */} + + + {/* Progress bar */} +
+
+
+
+
+ + {/* Keyboard hints */} +
+ + ← + + + → + + step + + Space + + play +
+
+ ); +} diff --git a/frontend/src/components/visualization/step-description.tsx b/frontend/src/components/visualization/step-description.tsx new file mode 100644 index 0000000..84f11e7 --- /dev/null +++ b/frontend/src/components/visualization/step-description.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { useVisualization } from "./visualization-context"; + +export function StepDescription() { + const { currentStep } = useVisualization(); + + if (!currentStep) { + return null; + } + + return ( +
+

{currentStep.description}

+
+ ); +} diff --git a/frontend/src/components/visualization/structure-renderer.tsx b/frontend/src/components/visualization/structure-renderer.tsx new file mode 100644 index 0000000..52e4c1b --- /dev/null +++ b/frontend/src/components/visualization/structure-renderer.tsx @@ -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 ; + case "linkedlist": + // Phase 3: LinkedListVisualizer + return ( +
+ LinkedList visualization coming soon +
+ ); + case "stack": + // Phase 3: StackVisualizer + return ( +
+ Stack visualization coming soon +
+ ); + case "tree": + // Phase 3: TreeVisualizer + return ( +
+ Tree visualization coming soon +
+ ); + case "graph": + // Phase 5: GraphVisualizer + return ( +
+ Graph visualization coming soon +
+ ); + case "matrix": + // Phase 5: MatrixVisualizer + return ( +
+ Matrix visualization coming soon +
+ ); + default: + return ( +
+ Unknown data structure type +
+ ); + } +} + +export function StructureRenderer() { + const { currentStep } = useVisualization(); + + if (!currentStep?.structures) { + return ( +
+ No data structures to display +
+ ); + } + + const structureEntries = Object.entries(currentStep.structures); + + if (structureEntries.length === 0) { + return ( +
+ No data structures to display +
+ ); + } + + return ( +
+ {structureEntries.map(([name, data]) => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/visualization/variables-pane.tsx b/frontend/src/components/visualization/variables-pane.tsx new file mode 100644 index 0000000..bb33644 --- /dev/null +++ b/frontend/src/components/visualization/variables-pane.tsx @@ -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 ( +
+

+ Variables +

+
+ {Object.entries(currentStep.variables).map(([name, value]) => ( +
+ + {name} + + = + {formatValue(value)} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/visualization/visualization-container.tsx b/frontend/src/components/visualization/visualization-container.tsx new file mode 100644 index 0000000..5e00034 --- /dev/null +++ b/frontend/src/components/visualization/visualization-container.tsx @@ -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 ( +
+ No visualization steps available +
+ ); + } + + return ( + +
+ {/* Title */} +
+

{data.title}

+
+ + {/* Step description */} + + + {/* Data structure visualization */} +
+ +
+ + {/* Variables */} + + + {/* Controls */} + +
+
+ ); +} diff --git a/frontend/src/components/visualization/visualization-context.tsx b/frontend/src/components/visualization/visualization-context.tsx new file mode 100644 index 0000000..181555d --- /dev/null +++ b/frontend/src/components/visualization/visualization-context.tsx @@ -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 = { + 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( + 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("normal"); + const intervalRef = useRef | 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( + () => ({ + 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 ( + + {children} + + ); +} + +export function useVisualization(): VisualizationContextValue { + const context = useContext(VisualizationContext); + if (!context) { + throw new Error( + "useVisualization must be used within a VisualizationProvider" + ); + } + return context; +} diff --git a/frontend/src/components/visualization/visualizers/array-visualizer.tsx b/frontend/src/components/visualization/visualizers/array-visualizer.tsx new file mode 100644 index 0000000..45e38ec --- /dev/null +++ b/frontend/src/components/visualization/visualizers/array-visualizer.tsx @@ -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 = {}; + for (const [label, index] of Object.entries(pointers)) { + if (!grouped[index]) { + grouped[index] = []; + } + grouped[index].push(label); + } + return grouped; + }, [pointers]); + + return ( +
+ {name && ( + + {name} + + )} +
+ + {/* Array cells */} + + {values.map((element, index) => { + const x = dimensions.startX + index * (CELL_WIDTH + CELL_GAP); + const y = dimensions.startY; + + return ( + + {/* Cell background */} + + + {/* Cell border */} + + + {/* Value */} + + {String(element.value)} + + + {/* Annotations (above cell) */} + {element.annotations && element.annotations.length > 0 && ( + + {element.annotations.join(", ")} + + )} + + ); + })} + + + {/* Pointers (below cells, before indices) */} + {dimensions.hasPointers && ( + + {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 ( + + {/* Arrow */} + + + {/* Label(s) */} + + {labels.join(", ")} + + + ); + })} + + )} + + {/* Index labels (rendered last to appear on top) */} + + {values.map((_, index) => { + const x = dimensions.startX + index * (CELL_WIDTH + CELL_GAP); + const y = dimensions.startY; + + return ( + + {index} + + ); + })} + + + {/* Arrow marker definition */} + + + + + + +
+
+ ); +} diff --git a/frontend/src/components/visualization/visualizers/index.ts b/frontend/src/components/visualization/visualizers/index.ts new file mode 100644 index 0000000..2c5c8df --- /dev/null +++ b/frontend/src/components/visualization/visualizers/index.ts @@ -0,0 +1 @@ +export { ArrayVisualizer } from "./array-visualizer"; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index afe3402..56a5a20 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -57,6 +57,22 @@ export interface LearningProgression { challenge: LearningQuestion[]; } +export interface VisualizationExampleStep { + id: string; + description: string; + structures: Record; + variables?: Record; + codeHighlight?: { startLine: number; endLine: number }; +} + +export interface VisualizationExample { + id: string; + title: string; + input?: Record; + 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 { diff --git a/frontend/src/types/visualization.ts b/frontend/src/types/visualization.ts new file mode 100644 index 0000000..9c892e6 --- /dev/null +++ b/frontend/src/types/visualization.ts @@ -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; +} + +// Linked List Data Structure +export interface LinkedListNode { + value: unknown; + state: ElementState; + annotations?: string[]; +} + +export interface LinkedListState { + type: "linkedlist"; + nodes: LinkedListNode[]; + pointers?: Record; +} + +// 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; +} + +// 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; +} + +// 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; + variables?: Record; + 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; + 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;