// Draggable node with avatar function Node({ node, selected, onSelect, onDrag, onDragEnd, onStartConnect, onFinishConnect, onDelete, dragging, worldZoom, underAttack }){ const ref = React.useRef(null); const moveState = React.useRef(null); const onPointerDown = (e) => { if (e.target.closest('.connect-handle')) return; if (e.target.closest('.delete-btn')) return; e.stopPropagation(); onSelect(node.id); const startX = e.clientX, startY = e.clientY; const ox = node.x, oy = node.y; moveState.current = { startX, startY, ox, oy, moved:false }; e.currentTarget.setPointerCapture(e.pointerId); }; const onPointerMove = (e) => { if (!moveState.current) return; const { startX, startY, ox, oy } = moveState.current; const dx = (e.clientX - startX) / worldZoom; const dy = (e.clientY - startY) / worldZoom; if (Math.abs(dx)+Math.abs(dy) > 2) moveState.current.moved = true; onDrag(node.id, ox+dx, oy+dy); }; const onPointerUp = (e) => { if (!moveState.current) return; const moved = moveState.current.moved; moveState.current = null; onDragEnd(node.id, moved); try { e.currentTarget.releasePointerCapture(e.pointerId); } catch(_){} }; const handleConnectDown = (e) => { e.stopPropagation(); e.preventDefault(); onStartConnect(node.id, e); }; const handleFinishUp = (e) => { // If currently connecting, finish here. Parent handles routing on pointerup too. onFinishConnect(node.id); }; const isBranch = node.variant === 'branch'; const shapeClass = isBranch ? 'circle' : (node.shape || 'circle'); const avatarStyle = { background: node.color || '#fff' }; let inner; if (isBranch){ // Branch nodes are just a colored dot with an optional emoji. No image, // no fallback initial — keeps them visually "minor" vs full cards. inner = node.emoji ?
{node.emoji}
: null; } else if (node.image){ inner = ; } else if (node.emoji){ inner =
{node.emoji}
; } else { const ini = (node.name||'?').trim().charAt(0).toUpperCase(); inner =
{ini}
; } return (
{/* Effect aura: particles that visualize a buff / damage / status state on the card. Legacy boolean node.aura is treated as 'buff' for backward compatibility. */} {(() => { const effect = node.effect || (node.aura ? 'buff' : 'none'); if (effect === 'none') return null; const palette = { buff: '#E9C46A', // gold damage: '#FF4545', // rust red status: '#C77DFF', // arcane purple heal: '#7FE3A9', // healing green shield: '#6DD3F0', // shield cyan }; const color = palette[effect] || '#E9C46A'; return (
{[0,1,2,3,4,5].map(i => ( ))}
); })()}
{inner}
{(node.name || node.subtitle) ? (
{node.name ?
{node.name}
: null} {node.subtitle ?
{node.subtitle}
: null}
) : null}
{e.stopPropagation(); onDelete(node.id);}} title="Delete" >×
); } function contrastInk(bg){ if (!bg) return '#fff'; const m = bg.match(/^#([0-9a-f]{6})$/i); if (!m) return '#fff'; const r = parseInt(m[1].slice(0,2),16); const g = parseInt(m[1].slice(2,4),16); const b = parseInt(m[1].slice(4,6),16); const L = (0.299*r+0.587*g+0.114*b)/255; return L>0.6 ? '#3a2c3a' : '#fff'; } window.Node = Node; window.contrastInk = contrastInk;