viz primitives for data structures

This commit is contained in:
2025-09-12 15:26:17 +01:00
parent 4654ff7637
commit e9adb38be0
9 changed files with 1297 additions and 22 deletions

View File

@@ -0,0 +1,94 @@
"use client";
import { useMemo } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { useVisualization } from "./visualization-context";
interface CodePaneProps {
code: string;
language?: string;
}
export function CodePane({ code, language = "python" }: CodePaneProps) {
const { currentStep } = useVisualization();
const highlightedLines = useMemo(() => {
if (!currentStep?.codeHighlight) return new Set<number>();
const { startLine, endLine } = currentStep.codeHighlight;
const lines = new Set<number>();
for (let i = startLine; i <= endLine; i++) {
lines.add(i);
}
return lines;
}, [currentStep]);
const languageMap: Record<string, string> = {
python: "python",
javascript: "javascript",
typescript: "typescript",
java: "java",
cpp: "cpp",
c: "c",
go: "go",
rust: "rust",
};
const prismLanguage = languageMap[language.toLowerCase()] || language;
const customStyle = useMemo(
() => ({
margin: 0,
padding: "0.75rem 0",
fontSize: "0.8125rem",
borderRadius: "0.5rem",
background: "var(--code-bg, #282c34)",
}),
[]
);
return (
<div className="rounded-lg overflow-hidden border border-border">
<div className="bg-muted/50 px-3 py-1.5 border-b border-border">
<span className="text-xs font-medium text-muted-foreground capitalize">
{language}
</span>
</div>
<SyntaxHighlighter
language={prismLanguage}
style={oneDark}
customStyle={customStyle}
showLineNumbers
wrapLines
lineNumberStyle={(lineNumber) => ({
minWidth: "2.5em",
paddingRight: "1em",
textAlign: "right",
userSelect: "none",
color: highlightedLines.has(lineNumber)
? "var(--primary)"
: "var(--muted-foreground)",
fontWeight: highlightedLines.has(lineNumber) ? 600 : 400,
})}
lineProps={(lineNumber) => {
const isHighlighted = highlightedLines.has(lineNumber);
return {
style: {
display: "block",
padding: "0 0.75rem",
backgroundColor: isHighlighted
? "rgba(var(--primary-rgb, 59, 130, 246), 0.15)"
: "transparent",
borderLeft: isHighlighted
? "3px solid var(--primary)"
: "3px solid transparent",
transition: "background-color 0.2s ease, border-color 0.2s ease",
},
};
}}
>
{code.trim()}
</SyntaxHighlighter>
</div>
);
}

View File

@@ -8,4 +8,11 @@ export { StepDescription } from "./step-description";
export { VariablesPane } from "./variables-pane"; export { VariablesPane } from "./variables-pane";
export { StructureRenderer } from "./structure-renderer"; export { StructureRenderer } from "./structure-renderer";
export { PatternVisualization } from "./pattern-visualization"; export { PatternVisualization } from "./pattern-visualization";
export { ArrayVisualizer } from "./visualizers"; export { CodePane } from "./code-pane";
export {
ArrayVisualizer,
HistogramVisualizer,
LinkedListVisualizer,
StackVisualizer,
TreeVisualizer,
} from "./visualizers";

View File

@@ -2,6 +2,10 @@
import { useVisualization } from "./visualization-context"; import { useVisualization } from "./visualization-context";
import { ArrayVisualizer } from "./visualizers/array-visualizer"; import { ArrayVisualizer } from "./visualizers/array-visualizer";
import { HistogramVisualizer } from "./visualizers/histogram-visualizer";
import { LinkedListVisualizer } from "./visualizers/linkedlist-visualizer";
import { StackVisualizer } from "./visualizers/stack-visualizer";
import { TreeVisualizer } from "./visualizers/tree-visualizer";
import type { DataStructureState } from "@/types/visualization"; import type { DataStructureState } from "@/types/visualization";
interface StructureVisualizerProps { interface StructureVisualizerProps {
@@ -13,36 +17,21 @@ function StructureVisualizer({ name, data }: StructureVisualizerProps) {
switch (data.type) { switch (data.type) {
case "array": case "array":
return <ArrayVisualizer data={data} name={name} />; return <ArrayVisualizer data={data} name={name} />;
case "histogram":
return <HistogramVisualizer data={data} name={name} />;
case "linkedlist": case "linkedlist":
// Phase 3: LinkedListVisualizer return <LinkedListVisualizer data={data} name={name} />;
return (
<div className="text-muted-foreground">
LinkedList visualization coming soon
</div>
);
case "stack": case "stack":
// Phase 3: StackVisualizer return <StackVisualizer data={data} name={name} />;
return (
<div className="text-muted-foreground">
Stack visualization coming soon
</div>
);
case "tree": case "tree":
// Phase 3: TreeVisualizer return <TreeVisualizer data={data} name={name} />;
return (
<div className="text-muted-foreground">
Tree visualization coming soon
</div>
);
case "graph": case "graph":
// Phase 5: GraphVisualizer
return ( return (
<div className="text-muted-foreground"> <div className="text-muted-foreground">
Graph visualization coming soon Graph visualization coming soon
</div> </div>
); );
case "matrix": case "matrix":
// Phase 5: MatrixVisualizer
return ( return (
<div className="text-muted-foreground"> <div className="text-muted-foreground">
Matrix visualization coming soon Matrix visualization coming soon

View File

@@ -0,0 +1,384 @@
"use client";
import { useMemo } from "react";
import type { HistogramState, ElementState } from "@/types/visualization";
interface HistogramVisualizerProps {
data: HistogramState;
name?: string;
}
const BAR_WIDTH = 48;
const BAR_GAP = 4;
const MAX_BAR_HEIGHT = 180;
const INDEX_AREA_HEIGHT = 20;
const POINTER_HEIGHT = 24;
const ANNOTATION_HEIGHT = 20;
const AREA_LABEL_HEIGHT = 24;
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 HistogramVisualizer({ data, name }: HistogramVisualizerProps) {
const { bars, rectangle, pointers = {}, maxArea } = data;
const dimensions = useMemo(() => {
const hasPointers = Object.keys(pointers).length > 0;
const hasAnnotations = bars.some(
(b) => b.annotations && b.annotations.length > 0
);
const hasRectangle = !!rectangle;
// Find max value to scale bars
const maxValue = Math.max(...bars.map((b) => b.value), 1);
const contentWidth = bars.length * BAR_WIDTH + (bars.length - 1) * BAR_GAP;
const width = contentWidth + SVG_PADDING * 2;
// Calculate height
let height = MAX_BAR_HEIGHT + INDEX_AREA_HEIGHT + SVG_PADDING * 2;
if (hasPointers) height += POINTER_HEIGHT + 8;
if (hasAnnotations) height += ANNOTATION_HEIGHT;
if (hasRectangle) height += AREA_LABEL_HEIGHT;
// Calculate where bars start (from bottom, going up)
const barBaseY = hasAnnotations
? SVG_PADDING + ANNOTATION_HEIGHT + MAX_BAR_HEIGHT
: SVG_PADDING + MAX_BAR_HEIGHT;
return {
width,
height,
contentWidth,
maxValue,
hasPointers,
hasAnnotations,
hasRectangle,
startX: SVG_PADDING,
barBaseY,
};
}, [bars, pointers, rectangle]);
// 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]);
// Calculate bar height from value
const getBarHeight = (value: number): number => {
return (value / dimensions.maxValue) * MAX_BAR_HEIGHT;
};
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-histogram"
role="img"
aria-label={`Histogram visualization with ${bars.length} bars`}
>
{/* Rectangle overlay (rendered behind bars) */}
{rectangle && (
<g className="viz-rectangle">
{/* Semi-transparent rectangle */}
<rect
x={
dimensions.startX +
rectangle.startIndex * (BAR_WIDTH + BAR_GAP)
}
y={
dimensions.barBaseY -
getBarHeight(rectangle.height)
}
width={
(rectangle.endIndex - rectangle.startIndex + 1) * BAR_WIDTH +
(rectangle.endIndex - rectangle.startIndex) * BAR_GAP
}
height={getBarHeight(rectangle.height)}
fill={
rectangle.state === "found"
? "var(--viz-found)"
: "var(--viz-comparing)"
}
opacity={0.3}
rx={4}
/>
{/* Rectangle border */}
<rect
x={
dimensions.startX +
rectangle.startIndex * (BAR_WIDTH + BAR_GAP)
}
y={
dimensions.barBaseY -
getBarHeight(rectangle.height)
}
width={
(rectangle.endIndex - rectangle.startIndex + 1) * BAR_WIDTH +
(rectangle.endIndex - rectangle.startIndex) * BAR_GAP
}
height={getBarHeight(rectangle.height)}
fill="none"
stroke={
rectangle.state === "found"
? "var(--viz-found)"
: "var(--viz-comparing)"
}
strokeWidth={2}
strokeDasharray={rectangle.state === "found" ? "none" : "4 2"}
rx={4}
/>
{/* Area label - positioned inside the rectangle */}
{rectangle.label && (
<text
x={
dimensions.startX +
rectangle.startIndex * (BAR_WIDTH + BAR_GAP) +
((rectangle.endIndex - rectangle.startIndex + 1) *
BAR_WIDTH +
(rectangle.endIndex - rectangle.startIndex) * BAR_GAP) /
2
}
y={
dimensions.barBaseY -
getBarHeight(rectangle.height) / 2
}
textAnchor="middle"
dominantBaseline="middle"
fill="white"
fontSize={14}
fontWeight={700}
fontFamily="var(--font-mono, monospace)"
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.8)" }}
>
{rectangle.label}
</text>
)}
</g>
)}
{/* Histogram bars */}
<g>
{bars.map((bar, index) => {
const x = dimensions.startX + index * (BAR_WIDTH + BAR_GAP);
const barHeight = getBarHeight(bar.value);
const y = dimensions.barBaseY - barHeight;
return (
<g
key={index}
className="viz-element"
data-state={bar.state}
>
{/* Bar background */}
<rect
x={x}
y={y}
width={BAR_WIDTH}
height={barHeight}
rx={4}
fill={getStateColor(bar.state)}
className="viz-bar-bg"
/>
{/* Bar border */}
<rect
x={x}
y={y}
width={BAR_WIDTH}
height={barHeight}
rx={4}
fill="none"
stroke="var(--border)"
strokeWidth={1}
/>
{/* Value label (inside bar or above if too short) */}
<text
x={x + BAR_WIDTH / 2}
y={barHeight > 30 ? y + 20 : y - 6}
textAnchor="middle"
dominantBaseline="middle"
className="viz-value"
fill={barHeight > 30 ? getTextColor(bar.state) : "var(--foreground)"}
fontSize={14}
fontWeight={500}
fontFamily="var(--font-mono, monospace)"
>
{bar.value}
</text>
{/* Annotations (above bar) */}
{bar.annotations && bar.annotations.length > 0 && (
<text
x={x + BAR_WIDTH / 2}
y={dimensions.barBaseY - MAX_BAR_HEIGHT - 8}
textAnchor="middle"
className="viz-annotation"
fill="var(--primary)"
fontSize={12}
fontWeight={600}
>
{bar.annotations.join(", ")}
</text>
)}
</g>
);
})}
</g>
{/* Baseline */}
<line
x1={dimensions.startX - 4}
y1={dimensions.barBaseY}
x2={dimensions.startX + dimensions.contentWidth + 4}
y2={dimensions.barBaseY}
stroke="var(--border)"
strokeWidth={2}
/>
{/* Pointers (below baseline) */}
{dimensions.hasPointers && (
<g className="viz-pointers">
{Object.entries(pointersByPosition).map(([posStr, labels]) => {
const position = parseInt(posStr, 10);
if (position < 0 || position >= bars.length) return null;
const x =
dimensions.startX +
position * (BAR_WIDTH + BAR_GAP) +
BAR_WIDTH / 2;
const y =
dimensions.barBaseY + 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(#histogram-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 */}
<g className="viz-indices">
{bars.map((_, index) => {
const x = dimensions.startX + index * (BAR_WIDTH + BAR_GAP);
return (
<text
key={index}
x={x + BAR_WIDTH / 2}
y={dimensions.barBaseY + INDEX_AREA_HEIGHT / 2 + 4}
textAnchor="middle"
dominantBaseline="middle"
className="viz-index"
fill="var(--muted-foreground)"
fontSize={11}
>
{index}
</text>
);
})}
</g>
{/* Max area display */}
{maxArea !== undefined && (
<text
x={dimensions.width - SVG_PADDING}
y={SVG_PADDING + 12}
textAnchor="end"
fill="var(--primary)"
fontSize={14}
fontWeight={600}
fontFamily="var(--font-mono, monospace)"
>
Max Area: {maxArea}
</text>
)}
{/* Arrow marker definition */}
<defs>
<marker
id="histogram-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>
);
}

View File

@@ -1 +1,5 @@
export { ArrayVisualizer } from "./array-visualizer"; export { ArrayVisualizer } from "./array-visualizer";
export { HistogramVisualizer } from "./histogram-visualizer";
export { LinkedListVisualizer } from "./linkedlist-visualizer";
export { StackVisualizer } from "./stack-visualizer";
export { TreeVisualizer } from "./tree-visualizer";

View File

@@ -0,0 +1,282 @@
"use client";
import { useMemo } from "react";
import type { LinkedListState, ElementState } from "@/types/visualization";
interface LinkedListVisualizerProps {
data: LinkedListState;
name?: string;
}
const NODE_WIDTH = 56;
const NODE_HEIGHT = 56;
const ARROW_WIDTH = 40;
const POINTER_HEIGHT = 24;
const ANNOTATION_HEIGHT = 20;
const SVG_PADDING = 16;
const NULL_WIDTH = 40;
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 LinkedListVisualizer({ data, name }: LinkedListVisualizerProps) {
const { nodes, pointers = {} } = data;
const dimensions = useMemo(() => {
const hasPointers = Object.keys(pointers).length > 0;
const hasAnnotations = nodes.some(
(n) => n.annotations && n.annotations.length > 0
);
const nodeCount = nodes.length;
const contentWidth =
nodeCount * NODE_WIDTH +
(nodeCount > 0 ? nodeCount * ARROW_WIDTH : 0) +
NULL_WIDTH;
const width = contentWidth + SVG_PADDING * 2;
let height = NODE_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,
};
}, [nodes, pointers]);
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]);
const getNodeCenterX = (index: number) =>
dimensions.startX + index * (NODE_WIDTH + ARROW_WIDTH) + NODE_WIDTH / 2;
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-linkedlist"
role="img"
aria-label={`Linked list visualization with ${nodes.length} nodes`}
>
<defs>
<marker
id="linkedlist-arrowhead"
markerWidth="10"
markerHeight="7"
refX="9"
refY="3.5"
orient="auto"
>
<polygon
points="0 0, 10 3.5, 0 7"
fill="var(--muted-foreground)"
/>
</marker>
<marker
id="pointer-arrowhead"
markerWidth="8"
markerHeight="6"
refX="0"
refY="3"
orient="auto"
>
<polygon points="0 0, 8 3, 0 6" fill="var(--primary)" />
</marker>
</defs>
{/* Nodes and arrows */}
<g>
{nodes.map((node, index) => {
const x = dimensions.startX + index * (NODE_WIDTH + ARROW_WIDTH);
const y = dimensions.startY;
return (
<g key={index} className="viz-element" data-state={node.state}>
{/* Node box */}
<rect
x={x}
y={y}
width={NODE_WIDTH}
height={NODE_HEIGHT}
rx={6}
fill={getStateColor(node.state)}
className="viz-node-bg"
/>
{/* Node border */}
<rect
x={x}
y={y}
width={NODE_WIDTH}
height={NODE_HEIGHT}
rx={6}
fill="none"
stroke="var(--border)"
strokeWidth={1}
/>
{/* Value */}
<text
x={x + NODE_WIDTH / 2}
y={y + NODE_HEIGHT / 2}
textAnchor="middle"
dominantBaseline="middle"
className="viz-value"
fill={getTextColor(node.state)}
fontSize={18}
fontWeight={500}
fontFamily="var(--font-mono, monospace)"
>
{String(node.value)}
</text>
{/* Annotations (above node) */}
{node.annotations && node.annotations.length > 0 && (
<text
x={x + NODE_WIDTH / 2}
y={y - 6}
textAnchor="middle"
className="viz-annotation"
fill="var(--primary)"
fontSize={12}
fontWeight={600}
>
{node.annotations.join(", ")}
</text>
)}
{/* Arrow to next node */}
<line
x1={x + NODE_WIDTH + 4}
y1={y + NODE_HEIGHT / 2}
x2={x + NODE_WIDTH + ARROW_WIDTH - 4}
y2={y + NODE_HEIGHT / 2}
stroke="var(--muted-foreground)"
strokeWidth={2}
markerEnd="url(#linkedlist-arrowhead)"
/>
</g>
);
})}
{/* Null terminator */}
{nodes.length > 0 && (
<g className="viz-null">
<text
x={
dimensions.startX +
nodes.length * (NODE_WIDTH + ARROW_WIDTH) +
NULL_WIDTH / 2 -
8
}
y={dimensions.startY + NODE_HEIGHT / 2}
textAnchor="middle"
dominantBaseline="middle"
fill="var(--muted-foreground)"
fontSize={14}
fontFamily="var(--font-mono, monospace)"
>
null
</text>
</g>
)}
</g>
{/* Pointers */}
{dimensions.hasPointers && (
<g className="viz-pointers">
{Object.entries(pointersByPosition).map(([posStr, labels]) => {
const position = parseInt(posStr, 10);
if (position < 0 || position >= nodes.length) return null;
const x = getNodeCenterX(position);
const y = dimensions.startY + NODE_HEIGHT + POINTER_HEIGHT;
return (
<g key={posStr} className="viz-pointer">
{/* Arrow pointing up to node */}
<line
x1={x}
y1={y}
x2={x}
y2={y - 10}
stroke="var(--primary)"
strokeWidth={2}
markerEnd="url(#pointer-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>
)}
</svg>
</div>
</div>
);
}

View File

@@ -0,0 +1,202 @@
"use client";
import { useMemo } from "react";
import type { StackState, ElementState } from "@/types/visualization";
interface StackVisualizerProps {
data: StackState;
name?: string;
}
const CELL_WIDTH = 56;
const CELL_HEIGHT = 48;
const CELL_GAP = 4;
const CONTAINER_PADDING = 8;
const WALL_THICKNESS = 3;
const MIN_CONTAINER_HEIGHT = 200;
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 StackVisualizer({ data, name }: StackVisualizerProps) {
const { values } = data;
const dimensions = useMemo(() => {
const stackContentHeight =
values.length * CELL_HEIGHT + (values.length > 0 ? (values.length - 1) * CELL_GAP : 0);
const containerInnerHeight = Math.max(MIN_CONTAINER_HEIGHT, stackContentHeight + CONTAINER_PADDING * 2);
const containerInnerWidth = CELL_WIDTH + CONTAINER_PADDING * 2;
const width = containerInnerWidth + WALL_THICKNESS * 2 + SVG_PADDING * 2;
const height = containerInnerHeight + WALL_THICKNESS + SVG_PADDING * 2;
return {
width,
height,
containerInnerWidth,
containerInnerHeight,
containerX: SVG_PADDING,
containerY: SVG_PADDING,
stackContentHeight,
};
}, [values]);
// Bottom of container interior (where first element sits)
const containerBottom = dimensions.containerY + dimensions.containerInnerHeight;
const containerLeft = dimensions.containerX + WALL_THICKNESS;
return (
<div className="flex flex-col gap-2">
{name && (
<span className="text-sm font-medium text-muted-foreground">
{name}
</span>
)}
<div className="overflow-auto">
<svg
width={dimensions.width}
height={dimensions.height}
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
className="viz-stack"
role="img"
aria-label={`Stack visualization with ${values.length} elements`}
>
{/* Container walls - left, right, and bottom */}
<g className="viz-container">
{/* Left wall */}
<line
x1={dimensions.containerX + WALL_THICKNESS / 2}
y1={dimensions.containerY}
x2={dimensions.containerX + WALL_THICKNESS / 2}
y2={containerBottom + WALL_THICKNESS}
stroke="var(--primary)"
strokeWidth={WALL_THICKNESS}
strokeLinecap="round"
/>
{/* Right wall */}
<line
x1={dimensions.containerX + WALL_THICKNESS + dimensions.containerInnerWidth + WALL_THICKNESS / 2}
y1={dimensions.containerY}
x2={dimensions.containerX + WALL_THICKNESS + dimensions.containerInnerWidth + WALL_THICKNESS / 2}
y2={containerBottom + WALL_THICKNESS}
stroke="var(--primary)"
strokeWidth={WALL_THICKNESS}
strokeLinecap="round"
/>
{/* Bottom wall */}
<line
x1={dimensions.containerX}
y1={containerBottom + WALL_THICKNESS / 2}
x2={dimensions.containerX + WALL_THICKNESS * 2 + dimensions.containerInnerWidth}
y2={containerBottom + WALL_THICKNESS / 2}
stroke="var(--primary)"
strokeWidth={WALL_THICKNESS}
strokeLinecap="round"
/>
</g>
{/* Empty stack indicator */}
{values.length === 0 && (
<text
x={containerLeft + dimensions.containerInnerWidth / 2}
y={containerBottom - dimensions.containerInnerHeight / 2}
textAnchor="middle"
dominantBaseline="middle"
fill="var(--muted-foreground)"
fontSize={12}
fontStyle="italic"
>
empty
</text>
)}
{/* Stack elements (bottom to top) */}
<g>
{values.map((element, index) => {
// Elements stack from bottom up
const x = containerLeft + CONTAINER_PADDING;
const y = containerBottom - CONTAINER_PADDING - CELL_HEIGHT - index * (CELL_HEIGHT + CELL_GAP);
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={4}
fill={getStateColor(element.state)}
className="viz-cell-bg"
/>
{/* Cell border */}
<rect
x={x}
y={y}
width={CELL_WIDTH}
height={CELL_HEIGHT}
rx={4}
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>
</g>
);
})}
</g>
</svg>
</div>
</div>
);
}

View File

@@ -0,0 +1,289 @@
"use client";
import { useMemo } from "react";
import type { TreeState, TreeNode, ElementState } from "@/types/visualization";
interface TreeVisualizerProps {
data: TreeState;
name?: string;
}
interface PositionedNode {
node: TreeNode;
x: number;
y: number;
depth: number;
parentX?: number;
parentY?: number;
}
const NODE_RADIUS = 24;
const LEVEL_HEIGHT = 72;
const MIN_NODE_SPACING = 56;
const SVG_PADDING = 24;
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)";
}
}
function getTreeDepth(node: TreeNode | null): number {
if (!node) return 0;
return 1 + Math.max(getTreeDepth(node.left ?? null), getTreeDepth(node.right ?? null));
}
function getNodeCount(node: TreeNode | null): number {
if (!node) return 0;
return 1 + getNodeCount(node.left ?? null) + getNodeCount(node.right ?? null);
}
function calculatePositions(
root: TreeNode | null,
treeWidth: number
): PositionedNode[] {
if (!root) return [];
const positions: PositionedNode[] = [];
function traverse(
node: TreeNode | null,
level: number,
left: number,
right: number,
parentX?: number,
parentY?: number
): void {
if (!node) return;
const x = (left + right) / 2;
const y = SVG_PADDING + level * LEVEL_HEIGHT + NODE_RADIUS;
positions.push({
node,
x,
y,
depth: level,
parentX,
parentY,
});
const childWidth = (right - left) / 2;
traverse(node.left ?? null, level + 1, left, left + childWidth, x, y);
traverse(node.right ?? null, level + 1, left + childWidth, right, x, y);
}
traverse(root, 0, 0, treeWidth);
return positions;
}
export function TreeVisualizer({ data, name }: TreeVisualizerProps) {
const { root, pointers = {} } = data;
const { positions, dimensions } = useMemo(() => {
if (!root) {
return {
positions: [],
dimensions: { width: 200, height: 100 },
};
}
const depth = getTreeDepth(root);
const nodeCount = getNodeCount(root);
const maxNodesAtLevel = Math.pow(2, depth - 1);
const treeWidth = Math.max(
maxNodesAtLevel * MIN_NODE_SPACING,
nodeCount * MIN_NODE_SPACING
);
const width = treeWidth + SVG_PADDING * 2;
const height = depth * LEVEL_HEIGHT + SVG_PADDING * 2;
const positions = calculatePositions(root, treeWidth);
positions.forEach((pos) => {
pos.x += SVG_PADDING;
});
return {
positions,
dimensions: { width, height },
};
}, [root]);
const nodePointers = useMemo(() => {
const result: Record<number, string[]> = {};
for (const [label, nodeValue] of Object.entries(pointers)) {
const index = positions.findIndex(
(p) => String(p.node.value) === nodeValue
);
if (index >= 0) {
if (!result[index]) {
result[index] = [];
}
result[index].push(label);
}
}
return result;
}, [pointers, positions]);
if (!root) {
return (
<div className="flex flex-col gap-2">
{name && (
<span className="text-sm font-medium text-muted-foreground">
{name}
</span>
)}
<div className="text-muted-foreground text-center p-4">
Empty tree
</div>
</div>
);
}
return (
<div className="flex flex-col gap-2">
{name && (
<span className="text-sm font-medium text-muted-foreground">
{name}
</span>
)}
<div className="overflow-auto">
<svg
width={dimensions.width}
height={dimensions.height}
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
className="viz-tree"
role="img"
aria-label={`Binary tree visualization with ${positions.length} nodes`}
>
{/* Edges (drawn first, behind nodes) */}
<g className="viz-edges">
{positions.map((pos, index) => {
if (pos.parentX === undefined || pos.parentY === undefined) {
return null;
}
return (
<line
key={`edge-${index}`}
x1={pos.parentX}
y1={pos.parentY + NODE_RADIUS}
x2={pos.x}
y2={pos.y - NODE_RADIUS}
stroke="var(--border)"
strokeWidth={2}
/>
);
})}
</g>
{/* Nodes */}
<g className="viz-nodes">
{positions.map((pos, index) => {
const pointerLabels = nodePointers[index];
return (
<g
key={index}
className="viz-element"
data-state={pos.node.state}
>
{/* Node circle */}
<circle
cx={pos.x}
cy={pos.y}
r={NODE_RADIUS}
fill={getStateColor(pos.node.state)}
className="viz-node-bg"
/>
{/* Node border */}
<circle
cx={pos.x}
cy={pos.y}
r={NODE_RADIUS}
fill="none"
stroke="var(--border)"
strokeWidth={1}
/>
{/* Value */}
<text
x={pos.x}
y={pos.y}
textAnchor="middle"
dominantBaseline="middle"
className="viz-value"
fill={getTextColor(pos.node.state)}
fontSize={16}
fontWeight={500}
fontFamily="var(--font-mono, monospace)"
>
{String(pos.node.value)}
</text>
{/* Annotations */}
{pos.node.annotations && pos.node.annotations.length > 0 && (
<text
x={pos.x}
y={pos.y - NODE_RADIUS - 8}
textAnchor="middle"
className="viz-annotation"
fill="var(--primary)"
fontSize={11}
fontWeight={600}
>
{pos.node.annotations.join(", ")}
</text>
)}
{/* Pointer labels */}
{pointerLabels && pointerLabels.length > 0 && (
<text
x={pos.x}
y={pos.y + NODE_RADIUS + 16}
textAnchor="middle"
fill="var(--primary)"
fontSize={11}
fontWeight={600}
fontFamily="var(--font-mono, monospace)"
>
{pointerLabels.join(", ")}
</text>
)}
</g>
);
})}
</g>
</svg>
</div>
</div>
);
}

View File

@@ -94,6 +94,29 @@ export interface MatrixState {
pointers?: Record<string, { row: number; col: number }>; pointers?: Record<string, { row: number; col: number }>;
} }
// Histogram Data Structure (for bar chart visualizations)
export interface HistogramBar {
value: number;
state: ElementState;
annotations?: string[];
}
export interface HistogramRectangle {
startIndex: number;
endIndex: number;
height: number;
state?: ElementState;
label?: string;
}
export interface HistogramState {
type: "histogram";
bars: HistogramBar[];
rectangle?: HistogramRectangle;
pointers?: Record<string, number>;
maxArea?: number;
}
// Union type for all data structures // Union type for all data structures
export type DataStructureState = export type DataStructureState =
| ArrayState | ArrayState
@@ -101,7 +124,8 @@ export type DataStructureState =
| StackState | StackState
| TreeState | TreeState
| GraphState | GraphState
| MatrixState; | MatrixState
| HistogramState;
// Code highlighting range // Code highlighting range
export interface CodeHighlight { export interface CodeHighlight {