110 lines
2.7 KiB
TypeScript
110 lines
2.7 KiB
TypeScript
'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>
|
|
);
|
|
}
|