385 lines
12 KiB
TypeScript
385 lines
12 KiB
TypeScript
"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>
|
|
);
|
|
}
|