feat(viz): trie visualisation
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user