feat(viz): union-find
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 { UnionFindView } from '../data-structures/union-find-view';
|
||||
|
||||
interface UnionFindVisualizationProps {
|
||||
algorithm: AlgorithmDefinition;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function UnionFindVisualization({
|
||||
algorithm,
|
||||
className,
|
||||
}: UnionFindVisualizationProps) {
|
||||
const {
|
||||
currentStep,
|
||||
currentStepIndex,
|
||||
totalSteps,
|
||||
playback,
|
||||
controls,
|
||||
currentPhase,
|
||||
progress,
|
||||
} = useVisualization(algorithm);
|
||||
|
||||
const { dataState } = currentStep;
|
||||
const unionFind = dataState.unionFind?.[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 min-h-[200px]">
|
||||
{unionFind && <UnionFindView unionFind={unionFind} />}
|
||||
</div>
|
||||
</VisualizationContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { UnionFindState, UnionFindNodeState } from '@/lib/visualizations/types';
|
||||
import { UnionFindNode } from '../primitives/union-find-node';
|
||||
|
||||
interface UnionFindViewProps {
|
||||
unionFind: UnionFindState;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const NODE_RADIUS = 24;
|
||||
const LEVEL_HEIGHT = 72;
|
||||
const MIN_NODE_SPACING = 64;
|
||||
const SVG_PADDING = 48;
|
||||
const TREE_GAP = 40;
|
||||
|
||||
interface NodePosition {
|
||||
node: UnionFindNodeState;
|
||||
x: number;
|
||||
y: number;
|
||||
parentX?: number;
|
||||
parentY?: number;
|
||||
}
|
||||
|
||||
interface TreeInfo {
|
||||
rootId: string;
|
||||
nodes: Set<string>;
|
||||
depth: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
function buildNodeMap(nodes: UnionFindNodeState[]): Map<string, UnionFindNodeState> {
|
||||
const map = new Map<string, UnionFindNodeState>();
|
||||
for (const node of nodes) {
|
||||
map.set(node.id, node);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build children map: for each node, which nodes have it as parent?
|
||||
*/
|
||||
function buildChildrenMap(nodes: UnionFindNodeState[]): Map<string, string[]> {
|
||||
const childrenMap = new Map<string, string[]>();
|
||||
for (const node of nodes) {
|
||||
childrenMap.set(node.id, []);
|
||||
}
|
||||
for (const node of nodes) {
|
||||
if (node.parentId !== null) {
|
||||
const children = childrenMap.get(node.parentId);
|
||||
if (children) {
|
||||
children.push(node.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return childrenMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify roots (nodes where parentId is null).
|
||||
*/
|
||||
function findRoots(nodes: UnionFindNodeState[]): string[] {
|
||||
return nodes.filter(n => n.parentId === null).map(n => n.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the depth of a tree rooted at the given node.
|
||||
*/
|
||||
function calculateTreeDepth(
|
||||
nodeId: string,
|
||||
childrenMap: Map<string, string[]>
|
||||
): number {
|
||||
const children = childrenMap.get(nodeId) ?? [];
|
||||
if (children.length === 0) {
|
||||
return 1;
|
||||
}
|
||||
let maxChildDepth = 0;
|
||||
for (const childId of children) {
|
||||
const childDepth = calculateTreeDepth(childId, childrenMap);
|
||||
maxChildDepth = Math.max(maxChildDepth, childDepth);
|
||||
}
|
||||
return 1 + maxChildDepth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the width needed for a subtree rooted at the given node.
|
||||
*/
|
||||
function calculateSubtreeWidth(
|
||||
nodeId: string,
|
||||
childrenMap: Map<string, string[]>
|
||||
): number {
|
||||
const children = childrenMap.get(nodeId) ?? [];
|
||||
if (children.length === 0) {
|
||||
return MIN_NODE_SPACING;
|
||||
}
|
||||
let totalWidth = 0;
|
||||
for (const childId of children) {
|
||||
totalWidth += calculateSubtreeWidth(childId, childrenMap);
|
||||
}
|
||||
return Math.max(totalWidth, MIN_NODE_SPACING);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all node IDs in a tree rooted at rootId.
|
||||
*/
|
||||
function collectTreeNodes(
|
||||
rootId: string,
|
||||
childrenMap: Map<string, string[]>,
|
||||
collected: Set<string>
|
||||
): void {
|
||||
collected.add(rootId);
|
||||
const children = childrenMap.get(rootId) ?? [];
|
||||
for (const childId of children) {
|
||||
collectTreeNodes(childId, childrenMap, collected);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build tree info for each root in the forest.
|
||||
*/
|
||||
function buildTreeInfos(
|
||||
roots: string[],
|
||||
childrenMap: Map<string, string[]>
|
||||
): TreeInfo[] {
|
||||
return roots.map(rootId => {
|
||||
const nodes = new Set<string>();
|
||||
collectTreeNodes(rootId, childrenMap, nodes);
|
||||
const depth = calculateTreeDepth(rootId, childrenMap);
|
||||
const width = calculateSubtreeWidth(rootId, childrenMap);
|
||||
return { rootId, nodes, depth, width };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate positions for all nodes in a forest layout.
|
||||
* Roots are at the TOP, children are BELOW.
|
||||
* Parent pointers go UPWARD from child to parent.
|
||||
*/
|
||||
function calculatePositions(
|
||||
unionFind: UnionFindState
|
||||
): { positions: NodePosition[]; svgWidth: number; svgHeight: number } {
|
||||
const nodeMap = buildNodeMap(unionFind.nodes);
|
||||
const childrenMap = buildChildrenMap(unionFind.nodes);
|
||||
const roots = findRoots(unionFind.nodes);
|
||||
const treeInfos = buildTreeInfos(roots, childrenMap);
|
||||
|
||||
// Calculate total width needed
|
||||
const totalWidth = treeInfos.reduce((sum, t) => sum + t.width, 0) +
|
||||
(treeInfos.length - 1) * TREE_GAP +
|
||||
SVG_PADDING * 2;
|
||||
|
||||
// Calculate max depth for height
|
||||
const maxDepth = Math.max(1, ...treeInfos.map(t => t.depth));
|
||||
const svgHeight = maxDepth * LEVEL_HEIGHT + SVG_PADDING * 2;
|
||||
|
||||
// Pre-calculate subtree widths
|
||||
const subtreeWidths = new Map<string, number>();
|
||||
for (const node of unionFind.nodes) {
|
||||
subtreeWidths.set(node.id, calculateSubtreeWidth(node.id, childrenMap));
|
||||
}
|
||||
|
||||
const positions: NodePosition[] = [];
|
||||
let currentX = SVG_PADDING;
|
||||
|
||||
for (const treeInfo of treeInfos) {
|
||||
const treeWidth = treeInfo.width;
|
||||
|
||||
// Traverse tree and position nodes
|
||||
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 });
|
||||
|
||||
const children = childrenMap.get(nodeId) ?? [];
|
||||
if (children.length > 0) {
|
||||
const childrenWidth = children.reduce(
|
||||
(sum, childId) => sum + (subtreeWidths.get(childId) ?? MIN_NODE_SPACING),
|
||||
0
|
||||
);
|
||||
|
||||
let childLeft = x - childrenWidth / 2;
|
||||
|
||||
for (const childId of children) {
|
||||
const childWidth = subtreeWidths.get(childId) ?? MIN_NODE_SPACING;
|
||||
traverse(
|
||||
childId,
|
||||
level + 1,
|
||||
childLeft,
|
||||
childLeft + childWidth,
|
||||
x,
|
||||
y
|
||||
);
|
||||
childLeft += childWidth;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(treeInfo.rootId, 0, currentX, currentX + treeWidth);
|
||||
currentX += treeWidth + TREE_GAP;
|
||||
}
|
||||
|
||||
return { positions, svgWidth: Math.max(totalWidth, 200), svgHeight };
|
||||
}
|
||||
|
||||
export function UnionFindView({ unionFind, className }: UnionFindViewProps) {
|
||||
const { positions, svgWidth, svgHeight } = useMemo(
|
||||
() => calculatePositions(unionFind),
|
||||
[unionFind]
|
||||
);
|
||||
|
||||
// Build a set of highlighted path nodes for edge styling
|
||||
const pathNodeIds = new Set(unionFind.findPath ?? []);
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center', className)}>
|
||||
{unionFind.label && (
|
||||
<span className="mb-2 text-sm font-medium text-[var(--muted-foreground)]">
|
||||
{unionFind.label}
|
||||
</span>
|
||||
)}
|
||||
<svg
|
||||
width={svgWidth}
|
||||
height={svgHeight}
|
||||
viewBox={`0 0 ${svgWidth} ${svgHeight}`}
|
||||
className="overflow-visible"
|
||||
>
|
||||
{/* Draw edges (from child UP to parent) */}
|
||||
{positions.map(({ node, x, y, parentX, parentY }) => {
|
||||
if (parentX === undefined || parentY === undefined) return null;
|
||||
|
||||
// Determine if this edge is on the current find path
|
||||
const isOnPath = pathNodeIds.has(node.id);
|
||||
|
||||
// Draw line from child to parent (upward)
|
||||
// Arrow points toward parent (at the top)
|
||||
const startY = y - NODE_RADIUS;
|
||||
const endY = parentY + NODE_RADIUS;
|
||||
|
||||
return (
|
||||
<g key={`edge-${node.id}`}>
|
||||
<motion.line
|
||||
x1={x}
|
||||
y1={startY}
|
||||
x2={parentX}
|
||||
y2={endY}
|
||||
className={cn(
|
||||
'stroke-[var(--border)]',
|
||||
isOnPath && 'stroke-[var(--primary)]',
|
||||
node.state === 'cycle' && 'stroke-[var(--error)]'
|
||||
)}
|
||||
strokeWidth={isOnPath ? 2.5 : 2}
|
||||
markerEnd="url(#arrowhead)"
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
}}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Arrow marker definition */}
|
||||
<defs>
|
||||
<marker
|
||||
id="arrowhead"
|
||||
markerWidth="10"
|
||||
markerHeight="7"
|
||||
refX="9"
|
||||
refY="3.5"
|
||||
orient="auto"
|
||||
>
|
||||
<polygon
|
||||
points="0 0, 10 3.5, 0 7"
|
||||
className="fill-[var(--border)]"
|
||||
/>
|
||||
</marker>
|
||||
<marker
|
||||
id="arrowhead-primary"
|
||||
markerWidth="10"
|
||||
markerHeight="7"
|
||||
refX="9"
|
||||
refY="3.5"
|
||||
orient="auto"
|
||||
>
|
||||
<polygon
|
||||
points="0 0, 10 3.5, 0 7"
|
||||
className="fill-[var(--primary)]"
|
||||
/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{/* Draw nodes */}
|
||||
{positions.map(({ node, x, y }) => (
|
||||
<UnionFindNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
x={x}
|
||||
y={y}
|
||||
radius={NODE_RADIUS}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Current operation indicator */}
|
||||
{unionFind.message && (
|
||||
<text
|
||||
x={svgWidth / 2}
|
||||
y={svgHeight - 10}
|
||||
textAnchor="middle"
|
||||
className="fill-[var(--muted-foreground)] text-xs"
|
||||
>
|
||||
{unionFind.message}
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* Current edge indicator */}
|
||||
{unionFind.currentEdge && (
|
||||
<text
|
||||
x={svgWidth / 2}
|
||||
y={20}
|
||||
textAnchor="middle"
|
||||
className="fill-[var(--foreground)] text-sm font-mono"
|
||||
>
|
||||
Processing edge: [{unionFind.currentEdge[0]}, {unionFind.currentEdge[1]}]
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,8 @@ export { GridCell } from "./primitives/grid-cell";
|
||||
export { DecisionNode } from "./primitives/decision-node";
|
||||
export { HeapNode } from "./primitives/heap-node";
|
||||
export { IntervalBar } from "./primitives/interval-bar";
|
||||
export { TrieNode } from "./primitives/trie-node";
|
||||
export { UnionFindNode } from "./primitives/union-find-node";
|
||||
|
||||
// Data structures
|
||||
export { ArrayView } from "./data-structures/array-view";
|
||||
@@ -29,6 +31,8 @@ export { GridView } from "./data-structures/grid-view";
|
||||
export { DecisionTreeView } from "./data-structures/decision-tree-view";
|
||||
export { HeapView } from "./data-structures/heap-view";
|
||||
export { IntervalView } from "./data-structures/interval-view";
|
||||
export { TrieView } from "./data-structures/trie-view";
|
||||
export { UnionFindView } from "./data-structures/union-find-view";
|
||||
|
||||
// Algorithm visualizations
|
||||
export { MonotonicStackVisualization } from "./algorithms/monotonic-stack";
|
||||
@@ -44,3 +48,5 @@ export { HeapVisualization } from "./algorithms/heap";
|
||||
export { GreedyVisualization } from "./algorithms/greedy";
|
||||
export { IntervalsVisualization } from "./algorithms/intervals";
|
||||
export { MatrixTraversalVisualization } from "./algorithms/matrix-traversal";
|
||||
export { TrieVisualization } from "./algorithms/trie";
|
||||
export { UnionFindVisualization } from "./algorithms/union-find";
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { UnionFindNodeState } from '@/lib/visualizations/types';
|
||||
|
||||
interface UnionFindNodeProps {
|
||||
node: UnionFindNodeState;
|
||||
x: number;
|
||||
y: number;
|
||||
radius?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const STATE_CLASSES = {
|
||||
normal: 'fill-[var(--surface-variant)] stroke-[var(--border)]',
|
||||
root: 'fill-[var(--primary)]/20 stroke-[var(--primary)]',
|
||||
finding: 'fill-[var(--info)]/20 stroke-[var(--info)]',
|
||||
compressing: 'fill-[var(--info)]/30 stroke-[var(--info)]',
|
||||
merging: 'fill-[var(--warning)]/20 stroke-[var(--warning)]',
|
||||
highlighted: 'fill-[var(--primary)]/30 stroke-[var(--primary)]',
|
||||
cycle: 'fill-[var(--error)]/20 stroke-[var(--error)]',
|
||||
} as const;
|
||||
|
||||
const TEXT_CLASSES = {
|
||||
normal: 'fill-[var(--foreground)]',
|
||||
root: 'fill-[var(--primary)]',
|
||||
finding: 'fill-[var(--info)]',
|
||||
compressing: 'fill-[var(--info)]',
|
||||
merging: 'fill-[var(--warning)]',
|
||||
highlighted: 'fill-[var(--primary)]',
|
||||
cycle: 'fill-[var(--error)]',
|
||||
} as const;
|
||||
|
||||
export function UnionFindNode({
|
||||
node,
|
||||
x,
|
||||
y,
|
||||
radius = 24,
|
||||
className,
|
||||
}: UnionFindNodeProps) {
|
||||
const isActive = node.state === 'finding' || node.state === 'compressing' || node.state === 'merging';
|
||||
const isRoot = node.parentId === null;
|
||||
|
||||
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={isRoot ? 3 : 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 }}
|
||||
/>
|
||||
|
||||
{/* Root indicator: inner circle */}
|
||||
{isRoot && (
|
||||
<motion.circle
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={radius - 6}
|
||||
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 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Value 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]
|
||||
)}
|
||||
>
|
||||
{node.value}
|
||||
</text>
|
||||
|
||||
{/* Rank badge (top-right corner) */}
|
||||
<g>
|
||||
<circle
|
||||
cx={x + radius * 0.7}
|
||||
cy={y - radius * 0.7}
|
||||
r={10}
|
||||
className="fill-[var(--surface)] stroke-[var(--border)]"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text
|
||||
x={x + radius * 0.7}
|
||||
y={y - radius * 0.7}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
className="pointer-events-none select-none font-mono text-xs fill-[var(--muted-foreground)]"
|
||||
>
|
||||
{node.rank}
|
||||
</text>
|
||||
</g>
|
||||
</motion.g>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user