feat(viz): interactive algorithm viz system
This commit is contained in:
33
backend/alembic/versions/005_add_visualization_examples.py
Normal file
33
backend/alembic/versions/005_add_visualization_examples.py
Normal file
@@ -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")
|
||||||
@@ -164,3 +164,116 @@ related_patterns:
|
|||||||
- binary-search
|
- binary-search
|
||||||
|
|
||||||
prerequisite_patterns: []
|
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 }
|
||||||
|
|||||||
@@ -130,6 +130,9 @@ async def _upsert_pattern(session: AsyncSession, item: dict[str, Any]) -> Patter
|
|||||||
# Difficulty level
|
# Difficulty level
|
||||||
pattern.difficulty_level = item.get("difficulty_level")
|
pattern.difficulty_level = item.get("difficulty_level")
|
||||||
|
|
||||||
|
# Interactive visualization examples
|
||||||
|
pattern.visualization_examples = item.get("visualization_examples")
|
||||||
|
|
||||||
return pattern
|
return pattern
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,15 @@
|
|||||||
/* Badge colors - Optimal (green) */
|
/* Badge colors - Optimal (green) */
|
||||||
--badge-optimal: #16a34a;
|
--badge-optimal: #16a34a;
|
||||||
--badge-optimal-bg: #dcfce7;
|
--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) {
|
@media (prefers-color-scheme: dark) {
|
||||||
@@ -125,6 +134,14 @@
|
|||||||
/* Badge colors - Optimal (green, dark mode) */
|
/* Badge colors - Optimal (green, dark mode) */
|
||||||
--badge-optimal: #4ade80;
|
--badge-optimal: #4ade80;
|
||||||
--badge-optimal-bg: rgba(34, 197, 94, 0.2);
|
--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 {
|
.prose-content {
|
||||||
line-height: 1.7;
|
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,
|
RecognitionSignals,
|
||||||
RelatedPatterns,
|
RelatedPatterns,
|
||||||
} from "@/components/patterns";
|
} from "@/components/patterns";
|
||||||
|
import { PatternVisualization } from "@/components/visualization";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
@@ -104,7 +105,19 @@ export default async function PatternDetailPage({ params }: PageProps) {
|
|||||||
</Card>
|
</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 && (
|
{pattern.visualization && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<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[];
|
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 {
|
export interface PatternTutorial extends Pattern {
|
||||||
metaphor: string | null;
|
metaphor: string | null;
|
||||||
core_concept: string | null;
|
core_concept: string | null;
|
||||||
@@ -69,6 +85,7 @@ export interface PatternTutorial extends Pattern {
|
|||||||
prerequisite_patterns: RelatedPattern[] | null;
|
prerequisite_patterns: RelatedPattern[] | null;
|
||||||
difficulty_level: number | null;
|
difficulty_level: number | null;
|
||||||
learning_progression: LearningProgression | null;
|
learning_progression: LearningProgression | null;
|
||||||
|
visualization_examples: VisualizationExample[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuestionListItem {
|
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