feat(viz): backtracking, greedy, intervals, matrix
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { useVisualization } from '@/lib/visualizations/use-visualization';
|
||||
import type { AlgorithmDefinition } from '@/lib/visualizations/types';
|
||||
import { VisualizationContainer } from '../core/visualization-container';
|
||||
import { DecisionTreeView } from '../data-structures/decision-tree-view';
|
||||
import { ArrayView } from '../data-structures/array-view';
|
||||
|
||||
interface BacktrackingVisualizationProps {
|
||||
algorithm: AlgorithmDefinition;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BacktrackingVisualization({
|
||||
algorithm,
|
||||
className,
|
||||
}: BacktrackingVisualizationProps) {
|
||||
const {
|
||||
currentStep,
|
||||
currentStepIndex,
|
||||
totalSteps,
|
||||
playback,
|
||||
controls,
|
||||
currentPhase,
|
||||
progress,
|
||||
} = useVisualization(algorithm);
|
||||
|
||||
const { dataState } = currentStep;
|
||||
const decisionTree = dataState.decisionTrees?.[0] ?? null;
|
||||
const inputArray = dataState.arrays?.[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 flex-col items-center gap-6">
|
||||
{/* Input array (if present) */}
|
||||
{inputArray && (
|
||||
<ArrayView
|
||||
array={inputArray}
|
||||
pointers={dataState.pointers}
|
||||
elementSize="sm"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Decision tree */}
|
||||
{decisionTree && (
|
||||
<DecisionTreeView tree={decisionTree} />
|
||||
)}
|
||||
</div>
|
||||
</VisualizationContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { useVisualization } from '@/lib/visualizations/use-visualization';
|
||||
import type { AlgorithmDefinition } from '@/lib/visualizations/types';
|
||||
import { VisualizationContainer } from '../core/visualization-container';
|
||||
import { ArrayView } from '../data-structures/array-view';
|
||||
import { CalculationBubble } from '../primitives/calculation-bubble';
|
||||
|
||||
interface GreedyVisualizationProps {
|
||||
algorithm: AlgorithmDefinition;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function GreedyVisualization({
|
||||
algorithm,
|
||||
className,
|
||||
}: GreedyVisualizationProps) {
|
||||
const {
|
||||
currentStep,
|
||||
currentStepIndex,
|
||||
totalSteps,
|
||||
playback,
|
||||
controls,
|
||||
currentPhase,
|
||||
progress,
|
||||
} = useVisualization(algorithm);
|
||||
|
||||
const { dataState } = currentStep;
|
||||
const mainArray = dataState.arrays[0];
|
||||
const calculation = dataState.calculations[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 flex-col items-center">
|
||||
{/* Fixed height area for calculation bubble */}
|
||||
<div className="flex h-8 items-center justify-center">
|
||||
<CalculationBubble calculation={calculation} />
|
||||
</div>
|
||||
{/* Array visualization with pointers */}
|
||||
<div className="mt-2">
|
||||
{mainArray && (
|
||||
<ArrayView
|
||||
array={mainArray}
|
||||
pointers={dataState.pointers}
|
||||
elementSize="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</VisualizationContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { useVisualization } from '@/lib/visualizations/use-visualization';
|
||||
import type { AlgorithmDefinition } from '@/lib/visualizations/types';
|
||||
import { VisualizationContainer } from '../core/visualization-container';
|
||||
import { IntervalView } from '../data-structures/interval-view';
|
||||
|
||||
interface IntervalsVisualizationProps {
|
||||
algorithm: AlgorithmDefinition;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function IntervalsVisualization({
|
||||
algorithm,
|
||||
className,
|
||||
}: IntervalsVisualizationProps) {
|
||||
const {
|
||||
currentStep,
|
||||
currentStepIndex,
|
||||
totalSteps,
|
||||
playback,
|
||||
controls,
|
||||
currentPhase,
|
||||
progress,
|
||||
} = useVisualization(algorithm);
|
||||
|
||||
const { dataState } = currentStep;
|
||||
const inputIntervals = dataState.intervals?.[0] ?? null;
|
||||
const resultIntervals = dataState.intervals?.[1] ?? 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 flex-col items-center gap-6">
|
||||
{/* Input intervals */}
|
||||
{inputIntervals && (
|
||||
<IntervalView intervalList={inputIntervals} />
|
||||
)}
|
||||
|
||||
{/* Result intervals (shown when building merged result) */}
|
||||
{resultIntervals && resultIntervals.intervals.length > 0 && (
|
||||
<IntervalView intervalList={resultIntervals} />
|
||||
)}
|
||||
</div>
|
||||
</VisualizationContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { useVisualization } from '@/lib/visualizations/use-visualization';
|
||||
import type { AlgorithmDefinition } from '@/lib/visualizations/types';
|
||||
import { VisualizationContainer } from '../core/visualization-container';
|
||||
import { GridView } from '../data-structures/grid-view';
|
||||
import { CalculationBubble } from '../primitives/calculation-bubble';
|
||||
|
||||
interface MatrixTraversalVisualizationProps {
|
||||
algorithm: AlgorithmDefinition;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MatrixTraversalVisualization({
|
||||
algorithm,
|
||||
className,
|
||||
}: MatrixTraversalVisualizationProps) {
|
||||
const {
|
||||
currentStep,
|
||||
currentStepIndex,
|
||||
totalSteps,
|
||||
playback,
|
||||
controls,
|
||||
currentPhase,
|
||||
progress,
|
||||
} = useVisualization(algorithm);
|
||||
|
||||
const { dataState } = currentStep;
|
||||
const grid = dataState.grids?.[0] ?? null;
|
||||
const calculation = dataState.calculations[0] ?? null;
|
||||
const gridPointers = dataState.gridPointers ?? [];
|
||||
|
||||
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 flex-col items-center gap-4">
|
||||
{/* Calculation bubble */}
|
||||
<div className="flex h-8 items-center justify-center">
|
||||
<CalculationBubble calculation={calculation} />
|
||||
</div>
|
||||
|
||||
{/* Island Grid */}
|
||||
{grid && (
|
||||
<GridView
|
||||
grid={grid}
|
||||
pointers={gridPointers}
|
||||
cellSize="md"
|
||||
showColLabels={true}
|
||||
showRowLabels={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</VisualizationContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DecisionTreeState, DecisionNodeState } from '@/lib/visualizations/types';
|
||||
import { DecisionNode } from '../primitives/decision-node';
|
||||
|
||||
interface DecisionTreeViewProps {
|
||||
tree: DecisionTreeState;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const NODE_WIDTH = 64;
|
||||
const NODE_HEIGHT = 32;
|
||||
const LEVEL_HEIGHT = 80;
|
||||
const MIN_NODE_SPACING = 72;
|
||||
const SVG_PADDING = 40;
|
||||
|
||||
interface NodePosition {
|
||||
node: DecisionNodeState;
|
||||
x: number;
|
||||
y: number;
|
||||
parentX?: number;
|
||||
parentY?: number;
|
||||
isLeftChild?: boolean;
|
||||
}
|
||||
|
||||
function buildNodeMap(nodes: DecisionNodeState[]): Map<string, DecisionNodeState> {
|
||||
const map = new Map<string, DecisionNodeState>();
|
||||
for (const node of nodes) {
|
||||
map.set(node.id, node);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function calculateTreeDepth(
|
||||
nodeId: string | null,
|
||||
nodeMap: Map<string, DecisionNodeState>
|
||||
): number {
|
||||
if (!nodeId) return 0;
|
||||
const node = nodeMap.get(nodeId);
|
||||
if (!node) return 0;
|
||||
const leftDepth = calculateTreeDepth(node.left, nodeMap);
|
||||
const rightDepth = calculateTreeDepth(node.right, nodeMap);
|
||||
return 1 + Math.max(leftDepth, rightDepth);
|
||||
}
|
||||
|
||||
function calculatePositions(
|
||||
tree: DecisionTreeState,
|
||||
totalWidth: number
|
||||
): NodePosition[] {
|
||||
const nodeMap = buildNodeMap(tree.nodes);
|
||||
const positions: NodePosition[] = [];
|
||||
|
||||
function traverse(
|
||||
nodeId: string | null,
|
||||
level: number,
|
||||
left: number,
|
||||
right: number,
|
||||
parentX?: number,
|
||||
parentY?: number,
|
||||
isLeftChild?: boolean
|
||||
) {
|
||||
if (!nodeId) return;
|
||||
const node = nodeMap.get(nodeId);
|
||||
if (!node) return;
|
||||
|
||||
const x = (left + right) / 2;
|
||||
const y = SVG_PADDING + level * LEVEL_HEIGHT + NODE_HEIGHT / 2;
|
||||
|
||||
positions.push({ node, x, y, parentX, parentY, isLeftChild });
|
||||
|
||||
const childWidth = (right - left) / 2;
|
||||
traverse(node.left, level + 1, left, left + childWidth, x, y, true);
|
||||
traverse(node.right, level + 1, left + childWidth, right, x, y, false);
|
||||
}
|
||||
|
||||
traverse(tree.rootId, 0, 0, totalWidth);
|
||||
return positions;
|
||||
}
|
||||
|
||||
export function DecisionTreeView({
|
||||
tree,
|
||||
className,
|
||||
}: DecisionTreeViewProps) {
|
||||
const { positions, svgWidth, svgHeight } = useMemo(() => {
|
||||
const nodeMap = buildNodeMap(tree.nodes);
|
||||
const depth = calculateTreeDepth(tree.rootId, nodeMap);
|
||||
const maxNodesAtBottom = Math.pow(2, depth - 1);
|
||||
const width = Math.max(
|
||||
maxNodesAtBottom * MIN_NODE_SPACING + SVG_PADDING * 2,
|
||||
300
|
||||
);
|
||||
const height = depth * LEVEL_HEIGHT + SVG_PADDING * 2;
|
||||
const pos = calculatePositions(tree, width);
|
||||
return { positions: pos, svgWidth: width, svgHeight: height };
|
||||
}, [tree]);
|
||||
|
||||
const currentPath = new Set(tree.currentPath ?? []);
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center', className)}>
|
||||
{tree.label && (
|
||||
<span className="mb-2 text-sm font-medium text-[var(--muted-foreground)]">
|
||||
{tree.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, isLeftChild }) =>
|
||||
parentX !== undefined && parentY !== undefined ? (
|
||||
<g key={`edge-${node.id}`}>
|
||||
{/* Edge line */}
|
||||
<motion.line
|
||||
x1={parentX}
|
||||
y1={parentY + NODE_HEIGHT / 2}
|
||||
x2={x}
|
||||
y2={y - NODE_HEIGHT / 2}
|
||||
className={cn(
|
||||
'stroke-[var(--border)]',
|
||||
currentPath.has(node.id) && 'stroke-[var(--primary)]'
|
||||
)}
|
||||
strokeWidth={currentPath.has(node.id) ? 2.5 : 2}
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: node.state === 'complete' ? 0.6 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
{/* Branch label */}
|
||||
<text
|
||||
x={(parentX + x) / 2 + (isLeftChild ? -8 : 8)}
|
||||
y={(parentY + NODE_HEIGHT / 2 + y - NODE_HEIGHT / 2) / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
className={cn(
|
||||
'text-[10px] font-medium',
|
||||
currentPath.has(node.id)
|
||||
? 'fill-[var(--primary)]'
|
||||
: 'fill-[var(--muted-foreground)]'
|
||||
)}
|
||||
>
|
||||
{isLeftChild ? '+' : '−'}
|
||||
</text>
|
||||
</g>
|
||||
) : null
|
||||
)}
|
||||
|
||||
{/* Draw nodes */}
|
||||
{positions.map(({ node, x, y }) => (
|
||||
<DecisionNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
x={x}
|
||||
y={y}
|
||||
width={NODE_WIDTH}
|
||||
height={NODE_HEIGHT}
|
||||
isInPath={currentPath.has(node.id)}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { IntervalListState } from '@/lib/visualizations/types';
|
||||
import { IntervalBar } from '../primitives/interval-bar';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
|
||||
interface IntervalViewProps {
|
||||
intervalList: IntervalListState;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PIXELS_PER_UNIT = 20;
|
||||
const BAR_HEIGHT = 28;
|
||||
const ROW_GAP = 8;
|
||||
|
||||
export function IntervalView({
|
||||
intervalList,
|
||||
className,
|
||||
}: IntervalViewProps) {
|
||||
// Calculate timeline range
|
||||
const minValue = intervalList.minValue ?? 0;
|
||||
const maxValue = intervalList.maxValue ?? Math.max(
|
||||
...intervalList.intervals.map((i) => i.end),
|
||||
20
|
||||
);
|
||||
|
||||
const timelineWidth = (maxValue - minValue) * PIXELS_PER_UNIT;
|
||||
|
||||
// Generate tick marks for the number line
|
||||
const tickStep = maxValue <= 10 ? 1 : maxValue <= 20 ? 2 : 5;
|
||||
const ticks: number[] = [];
|
||||
for (let i = minValue; i <= maxValue; i += tickStep) {
|
||||
ticks.push(i);
|
||||
}
|
||||
|
||||
// Calculate row assignments to prevent overlapping intervals
|
||||
const rows: IntervalListState['intervals'][] = [];
|
||||
for (const interval of intervalList.intervals) {
|
||||
let placed = false;
|
||||
for (const row of rows) {
|
||||
const overlaps = row.some(
|
||||
(existing) => !(interval.end <= existing.start || interval.start >= existing.end)
|
||||
);
|
||||
if (!overlaps) {
|
||||
row.push(interval);
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!placed) {
|
||||
rows.push([interval]);
|
||||
}
|
||||
}
|
||||
|
||||
const intervalsHeight = rows.length * (BAR_HEIGHT + ROW_GAP) - ROW_GAP;
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center', className)}>
|
||||
{intervalList.label && (
|
||||
<span className="mb-2 text-sm font-medium text-[var(--muted-foreground)]">
|
||||
{intervalList.label}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="relative" style={{ width: `${timelineWidth}px` }}>
|
||||
{/* Intervals area */}
|
||||
<div
|
||||
className="relative mb-2"
|
||||
style={{ height: `${Math.max(intervalsHeight, BAR_HEIGHT)}px` }}
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{rows.map((row, rowIndex) =>
|
||||
row.map((interval) => (
|
||||
<div
|
||||
key={interval.id}
|
||||
style={{ position: 'absolute', top: `${rowIndex * (BAR_HEIGHT + ROW_GAP)}px` }}
|
||||
>
|
||||
<IntervalBar
|
||||
interval={interval}
|
||||
pixelsPerUnit={PIXELS_PER_UNIT}
|
||||
minValue={minValue}
|
||||
height={BAR_HEIGHT}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Number line */}
|
||||
<div className="relative h-6">
|
||||
{/* Horizontal line */}
|
||||
<div
|
||||
className="absolute top-0 h-px bg-[var(--border)]"
|
||||
style={{ width: `${timelineWidth}px` }}
|
||||
/>
|
||||
|
||||
{/* Tick marks and labels */}
|
||||
{ticks.map((tick) => {
|
||||
const x = (tick - minValue) * PIXELS_PER_UNIT;
|
||||
return (
|
||||
<div key={tick} className="absolute" style={{ left: `${x}px` }}>
|
||||
<div className="h-2 w-px bg-[var(--border)]" />
|
||||
<span className="absolute -translate-x-1/2 mt-1 text-xs text-[var(--muted-foreground)]">
|
||||
{tick}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export { TreeNode } from "./primitives/tree-node";
|
||||
export { GridCell } from "./primitives/grid-cell";
|
||||
export { DecisionNode } from "./primitives/decision-node";
|
||||
export { HeapNode } from "./primitives/heap-node";
|
||||
export { IntervalBar } from "./primitives/interval-bar";
|
||||
|
||||
// Data structures
|
||||
export { ArrayView } from "./data-structures/array-view";
|
||||
@@ -27,6 +28,7 @@ export { BinaryTreeView } from "./data-structures/binary-tree-view";
|
||||
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";
|
||||
|
||||
// Algorithm visualizations
|
||||
export { MonotonicStackVisualization } from "./algorithms/monotonic-stack";
|
||||
@@ -39,3 +41,6 @@ export { LinkedListVisualization } from "./algorithms/linked-list";
|
||||
export { CoinChangeVisualization } from "./algorithms/coin-change";
|
||||
export { BacktrackingVisualization } from "./algorithms/backtracking";
|
||||
export { HeapVisualization } from "./algorithms/heap";
|
||||
export { GreedyVisualization } from "./algorithms/greedy";
|
||||
export { IntervalsVisualization } from "./algorithms/intervals";
|
||||
export { MatrixTraversalVisualization } from "./algorithms/matrix-traversal";
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DecisionNodeState } from '@/lib/visualizations/types';
|
||||
|
||||
interface DecisionNodeProps {
|
||||
node: DecisionNodeState;
|
||||
x: number;
|
||||
y: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
isInPath?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const STATE_CLASSES = {
|
||||
normal: 'fill-[var(--surface-variant)] stroke-[var(--border)]',
|
||||
exploring: 'fill-[var(--info)]/20 stroke-[var(--info)]',
|
||||
complete: 'fill-[var(--success)]/20 stroke-[var(--success)]',
|
||||
backtracking: 'fill-[var(--warning)]/20 stroke-[var(--warning)]',
|
||||
current: 'fill-[var(--primary)]/20 stroke-[var(--primary)]',
|
||||
} as const;
|
||||
|
||||
const TEXT_CLASSES = {
|
||||
normal: 'fill-[var(--foreground)]',
|
||||
exploring: 'fill-[var(--info)]',
|
||||
complete: 'fill-[var(--success)]',
|
||||
backtracking: 'fill-[var(--warning)]',
|
||||
current: 'fill-[var(--primary)]',
|
||||
} as const;
|
||||
|
||||
export function DecisionNode({
|
||||
node,
|
||||
x,
|
||||
y,
|
||||
width = 64,
|
||||
height = 32,
|
||||
isInPath = false,
|
||||
className,
|
||||
}: DecisionNodeProps) {
|
||||
const isActive = node.state === 'current' || node.state === 'exploring';
|
||||
const showDecision = node.decision && node.state === 'current';
|
||||
|
||||
return (
|
||||
<motion.g
|
||||
initial={false}
|
||||
animate={{
|
||||
scale: isActive ? 1.05 : 1,
|
||||
}}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
}}
|
||||
style={{ transformOrigin: `${x}px ${y}px` }}
|
||||
className={cn(className)}
|
||||
>
|
||||
{/* Decision label above node */}
|
||||
{showDecision && (
|
||||
<motion.text
|
||||
x={x}
|
||||
y={y - height / 2 - 12}
|
||||
textAnchor="middle"
|
||||
className="fill-[var(--primary)] text-xs font-medium"
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{node.decision}
|
||||
</motion.text>
|
||||
)}
|
||||
|
||||
{/* Node rectangle (rounded) */}
|
||||
<motion.rect
|
||||
x={x - width / 2}
|
||||
y={y - height / 2}
|
||||
width={width}
|
||||
height={height}
|
||||
rx={6}
|
||||
ry={6}
|
||||
strokeWidth={isInPath ? 2.5 : 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 }}
|
||||
/>
|
||||
|
||||
{/* Value text */}
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
className={cn(
|
||||
'pointer-events-none select-none font-mono text-xs font-medium',
|
||||
TEXT_CLASSES[node.state]
|
||||
)}
|
||||
>
|
||||
{node.value}
|
||||
</text>
|
||||
</motion.g>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { IntervalState } from '@/lib/visualizations/types';
|
||||
|
||||
interface IntervalBarProps {
|
||||
interval: IntervalState;
|
||||
pixelsPerUnit: number;
|
||||
minValue: number;
|
||||
height?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const STATE_CLASSES = {
|
||||
normal: 'bg-[var(--muted)] border-[var(--border)] text-[var(--foreground)]',
|
||||
highlighted: 'bg-[var(--primary)]/30 border-[var(--primary)] text-[var(--primary)]',
|
||||
merging: 'bg-amber-500/30 border-amber-500 text-amber-500',
|
||||
merged: 'bg-green-500/30 border-green-500 text-green-500',
|
||||
dimmed: 'bg-[var(--muted)]/30 border-[var(--border)]/50 text-[var(--muted-foreground)] opacity-40',
|
||||
} as const;
|
||||
|
||||
export function IntervalBar({
|
||||
interval,
|
||||
pixelsPerUnit,
|
||||
minValue,
|
||||
height = 32,
|
||||
className,
|
||||
}: IntervalBarProps) {
|
||||
const width = (interval.end - interval.start) * pixelsPerUnit;
|
||||
const left = (interval.start - minValue) * pixelsPerUnit;
|
||||
const label = interval.label ?? `[${interval.start},${interval.end}]`;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: interval.state === 'highlighted' || interval.state === 'merging' ? 1.02 : 1,
|
||||
}}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={cn(
|
||||
'absolute flex items-center justify-center rounded border-2 font-mono text-xs font-medium transition-colors duration-200',
|
||||
STATE_CLASSES[interval.state],
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
left: `${left}px`,
|
||||
width: `${Math.max(width, 40)}px`,
|
||||
height: `${height}px`,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user