feat(viz): interactive algorithm viz system
This commit is contained in:
@@ -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";
|
||||
Reference in New Issue
Block a user