viz primitives for data structures
This commit is contained in:
94
frontend/src/components/visualization/code-pane.tsx
Normal file
94
frontend/src/components/visualization/code-pane.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user