Files
codetutor/frontend/src/components/visualization/visualizers/histogram-visualizer.tsx
T

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>
);
}