feat(viz): trie visualisation

This commit is contained in:
2025-09-12 15:08:18 +01:00
parent 987574f3cb
commit 4c8f1d92c6
4 changed files with 1114 additions and 0 deletions
@@ -0,0 +1,54 @@
'use client';
import { useVisualization } from '@/lib/visualizations/use-visualization';
import type { AlgorithmDefinition } from '@/lib/visualizations/types';
import { VisualizationContainer } from '../core/visualization-container';
import { TrieView } from '../data-structures/trie-view';
interface TrieVisualizationProps {
algorithm: AlgorithmDefinition;
className?: string;
}
export function TrieVisualization({
algorithm,
className,
}: TrieVisualizationProps) {
const {
currentStep,
currentStepIndex,
totalSteps,
playback,
controls,
currentPhase,
progress,
} = useVisualization(algorithm);
const { dataState } = currentStep;
const trie = dataState.tries?.[0] ?? null;
return (
<VisualizationContainer
title={algorithm.title}
pattern={algorithm.pattern}
code={algorithm.code}
currentLine={currentStep.codeLine}
highlightLines={currentStep.codeHighlightLines}
explanation={currentStep.explanation}
decision={currentStep.decision}
phase={currentPhase}
variables={dataState.variables}
currentStepIndex={currentStepIndex}
totalSteps={totalSteps}
isPlaying={playback.isPlaying}
speed={playback.speed}
controls={controls}
progress={progress}
className={className}
>
<div className="flex items-start justify-center">
{trie && <TrieView trie={trie} />}
</div>
</VisualizationContainer>
);
}
@@ -0,0 +1,252 @@
'use client';
import { useMemo } from 'react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
import type { TrieState, TrieNodeState } from '@/lib/visualizations/types';
import { TrieNode } from '../primitives/trie-node';
interface TrieViewProps {
trie: TrieState;
className?: string;
}
const NODE_RADIUS = 20;
const LEVEL_HEIGHT = 64;
const MIN_NODE_SPACING = 48;
const SVG_PADDING = 40;
interface NodePosition {
node: TrieNodeState;
x: number;
y: number;
parentX?: number;
parentY?: number;
}
function buildNodeMap(nodes: TrieNodeState[]): Map<string, TrieNodeState> {
const map = new Map<string, TrieNodeState>();
for (const node of nodes) {
map.set(node.id, node);
}
return map;
}
/**
* Calculate the width needed for a subtree rooted at the given node.
* Leaf nodes need MIN_NODE_SPACING, internal nodes need sum of children widths.
*/
function calculateSubtreeWidth(
nodeId: string,
nodeMap: Map<string, TrieNodeState>
): number {
const node = nodeMap.get(nodeId);
if (!node) return MIN_NODE_SPACING;
if (node.children.length === 0) {
return MIN_NODE_SPACING;
}
let totalWidth = 0;
for (const childId of node.children) {
totalWidth += calculateSubtreeWidth(childId, nodeMap);
}
return Math.max(totalWidth, MIN_NODE_SPACING);
}
/**
* Calculate the maximum depth of the trie.
*/
function calculateTrieDepth(
nodeId: string,
nodeMap: Map<string, TrieNodeState>
): number {
const node = nodeMap.get(nodeId);
if (!node) return 0;
if (node.children.length === 0) {
return 1;
}
let maxChildDepth = 0;
for (const childId of node.children) {
const childDepth = calculateTrieDepth(childId, nodeMap);
maxChildDepth = Math.max(maxChildDepth, childDepth);
}
return 1 + maxChildDepth;
}
/**
* Calculate positions for all nodes using N-ary tree layout.
* Each node is centered above its children.
*/
function calculatePositions(
trie: TrieState,
totalWidth: number
): NodePosition[] {
const nodeMap = buildNodeMap(trie.nodes);
const positions: NodePosition[] = [];
// Pre-calculate subtree widths for all nodes
const subtreeWidths = new Map<string, number>();
for (const node of trie.nodes) {
subtreeWidths.set(node.id, calculateSubtreeWidth(node.id, nodeMap));
}
function traverse(
nodeId: string,
level: number,
left: number,
right: number,
parentX?: number,
parentY?: number
) {
const node = nodeMap.get(nodeId);
if (!node) return;
const x = (left + right) / 2;
const y = SVG_PADDING + level * LEVEL_HEIGHT + NODE_RADIUS;
positions.push({ node, x, y, parentX, parentY });
if (node.children.length > 0) {
// Calculate total children width
const childrenWidth = node.children.reduce(
(sum, childId) => sum + (subtreeWidths.get(childId) ?? MIN_NODE_SPACING),
0
);
// Start position for first child
let childLeft = x - childrenWidth / 2;
for (const childId of node.children) {
const childWidth = subtreeWidths.get(childId) ?? MIN_NODE_SPACING;
traverse(
childId,
level + 1,
childLeft,
childLeft + childWidth,
x,
y
);
childLeft += childWidth;
}
}
}
traverse(trie.rootId, 0, 0, totalWidth);
return positions;
}
export function TrieView({ trie, className }: TrieViewProps) {
const { positions, svgWidth, svgHeight } = useMemo(() => {
const nodeMap = buildNodeMap(trie.nodes);
const depth = calculateTrieDepth(trie.rootId, nodeMap);
const rootWidth = calculateSubtreeWidth(trie.rootId, nodeMap);
const width = Math.max(rootWidth + SVG_PADDING * 2, 200);
const height = depth * LEVEL_HEIGHT + SVG_PADDING * 2;
const pos = calculatePositions(trie, width);
return { positions: pos, svgWidth: width, svgHeight: height };
}, [trie]);
// Build a set of highlighted path nodes for edge styling
const pathNodeIds = new Set(trie.currentPath ?? []);
return (
<div className={cn('flex flex-col items-center', className)}>
{trie.label && (
<span className="mb-2 text-sm font-medium text-[var(--muted-foreground)]">
{trie.label}
</span>
)}
<svg
width={svgWidth}
height={svgHeight}
viewBox={`0 0 ${svgWidth} ${svgHeight}`}
className="overflow-visible"
>
{/* Draw edges first (behind nodes) */}
{positions.map(({ node, x, y, parentX, parentY }) => {
if (parentX === undefined || parentY === undefined) return null;
// Determine if this edge is on the current path
const isOnPath = pathNodeIds.has(node.id);
// Calculate midpoint for character label
const midX = (parentX + x) / 2;
const midY = (parentY + NODE_RADIUS + y - NODE_RADIUS) / 2;
return (
<g key={`edge-${node.id}`}>
<motion.line
x1={parentX}
y1={parentY + NODE_RADIUS}
x2={x}
y2={y - NODE_RADIUS}
className={cn(
'stroke-[var(--border)]',
isOnPath && 'stroke-[var(--primary)]'
)}
strokeWidth={isOnPath ? 2.5 : 2}
initial={false}
animate={{
opacity: node.state === 'notfound' ? 0.3 : 1,
}}
transition={{ duration: 0.2 }}
/>
{/* Edge character label */}
<motion.text
x={midX}
y={midY}
textAnchor="middle"
dominantBaseline="central"
className={cn(
'pointer-events-none select-none font-mono text-xs font-medium',
isOnPath ? 'fill-[var(--primary)]' : 'fill-[var(--muted-foreground)]'
)}
initial={false}
animate={{
opacity: node.state === 'notfound' ? 0.3 : 1,
}}
transition={{ duration: 0.2 }}
>
{node.char}
</motion.text>
</g>
);
})}
{/* Draw nodes */}
{positions.map(({ node, x, y }) => (
<TrieNode
key={node.id}
node={node}
x={x}
y={y}
radius={NODE_RADIUS}
/>
))}
{/* Current operation indicator */}
{trie.searchWord && (
<g>
<text
x={svgWidth / 2}
y={svgHeight - 10}
textAnchor="middle"
className="fill-[var(--muted-foreground)] text-xs"
>
{trie.searchIndex !== undefined
? `"${trie.searchWord}" [${trie.searchIndex}/${trie.searchWord.length}]`
: `"${trie.searchWord}"`}
</text>
</g>
)}
</svg>
</div>
);
}
@@ -0,0 +1,107 @@
'use client';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
import type { TrieNodeState } from '@/lib/visualizations/types';
interface TrieNodeProps {
node: TrieNodeState;
x: number;
y: number;
radius?: number;
className?: string;
}
const STATE_CLASSES = {
normal: 'fill-[var(--surface-variant)] stroke-[var(--border)]',
current: 'fill-[var(--primary)]/20 stroke-[var(--primary)]',
found: 'fill-[var(--success)]/20 stroke-[var(--success)]',
notfound: 'fill-[var(--error)]/20 stroke-[var(--error)]',
highlighted: 'fill-[var(--primary)]/30 stroke-[var(--primary)]',
creating: 'fill-[var(--info)]/20 stroke-[var(--info)]',
} as const;
const TEXT_CLASSES = {
normal: 'fill-[var(--foreground)]',
current: 'fill-[var(--primary)]',
found: 'fill-[var(--success)]',
notfound: 'fill-[var(--error)]',
highlighted: 'fill-[var(--primary)]',
creating: 'fill-[var(--info)]',
} as const;
export function TrieNode({
node,
x,
y,
radius = 20,
className,
}: TrieNodeProps) {
const isActive = node.state === 'current' || node.state === 'highlighted' || node.state === 'creating';
const isRoot = node.char === '';
return (
<motion.g
initial={false}
animate={{
scale: isActive ? 1.1 : 1,
}}
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
}}
style={{ transformOrigin: `${x}px ${y}px` }}
className={cn(className)}
>
{/* Main circle */}
<motion.circle
cx={x}
cy={y}
r={radius}
strokeWidth={2}
className={cn(
'transition-colors duration-200',
STATE_CLASSES[node.state]
)}
initial={false}
animate={{
filter: isActive ? 'drop-shadow(0 0 8px var(--primary))' : 'none',
}}
transition={{ duration: 0.2 }}
/>
{/* End-of-word indicator: inner circle */}
{node.isEndOfWord && (
<motion.circle
cx={x}
cy={y}
r={radius - 5}
strokeWidth={2}
fill="none"
className={cn(
'transition-colors duration-200',
STATE_CLASSES[node.state]
)}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.2 }}
/>
)}
{/* Character label */}
<text
x={x}
y={y}
textAnchor="middle"
dominantBaseline="central"
className={cn(
'pointer-events-none select-none font-mono text-sm font-medium',
TEXT_CLASSES[node.state]
)}
>
{isRoot ? '\u2205' : node.char}
</text>
</motion.g>
);
}