// Group visuals — split into two components so Canvas can draw them at // different z-order layers: // // GroupBlob — the filtered blob fill + spoke connectors (goes BELOW edges/nodes) // GroupLabel — the colored name pill (goes ABOVE edges/nodes so it never gets hidden, // and can be dragged freely to any offset via group.labelOffset) // // Non-group nodes that happen to sit between members are intentionally // ignored — the connecting strokes pass right through them. function GroupBlob({ group, nodes, selected, composing, branchHover, onSelect }){ const memberNodes = (group.nodeIds || []) .map(nid => nodes.find(n => n.id === nid)) .filter(Boolean); if (memberNodes.length === 0) return null; const R = 55; // member blob radius const LINK_W = 70; // width of the connector line (must survive the alpha threshold) const fill = group.color || '#E9C46A'; // Group centroid — we spoke-connect every member to this point so that // spread-out groups still read as one shape after the metaball filter. const cx = memberNodes.reduce((s,n) => s + n.x, 0) / memberNodes.length; const cy = memberNodes.reduce((s,n) => s + n.y, 0) / memberNodes.length; return ( { e.stopPropagation(); onSelect && onSelect(group.id); }} style={{cursor:'pointer'}}> {/* Filtered blob: circles at each member + thick spoke lines toward centroid. The shared #group-goo filter merges everything into a single gooey shape. */} {memberNodes.length > 1 && memberNodes.map(n => ( ))} {memberNodes.map(n => ( ))} ); } function GroupLabel({ group, nodes, updateGroup, worldZoom }){ if (group.showName === false || !group.name) return null; const memberNodes = (group.nodeIds || []) .map(nid => nodes.find(n => n.id === nid)) .filter(Boolean); if (memberNodes.length === 0) return null; const R = 55; const fill = group.color || '#E9C46A'; // Base position: horizontally centered on bbox, slightly INSIDE the top of the blob // so the pill reads as "attached" to the group rather than floating above. let minX=Infinity, minY=Infinity, maxX=-Infinity; memberNodes.forEach(n => { minX = Math.min(minX, n.x - R); minY = Math.min(minY, n.y - R); maxX = Math.max(maxX, n.x + R); }); const basePos = { x: (minX + maxX) / 2, y: minY + 12 }; // User-customised offset (optional). Default to no offset for backward compat. const offset = group.labelOffset || { dx: 0, dy: 0 }; const pos = { x: basePos.x + (offset.dx || 0), y: basePos.y + (offset.dy || 0) }; // Pick a readable text color for the name pill (fill is the group color). const textColor = (typeof window !== 'undefined' && window.contrastInk) ? window.contrastInk(fill) : '#3a2c3a'; const W = Math.max(60, group.name.length * 12 + 28); // Drag support — live updates with skipHistory, commit once on drag end. const moveRef = React.useRef(null); const onPointerDown = (e) => { if (!updateGroup) return; e.stopPropagation(); moveRef.current = { startX: e.clientX, startY: e.clientY, ox: offset.dx || 0, oy: offset.dy || 0, moved: false, last: { dx: offset.dx || 0, dy: offset.dy || 0 }, }; try { e.currentTarget.setPointerCapture(e.pointerId); } catch(_){} }; const onPointerMove = (e) => { if (!moveRef.current || !updateGroup) return; const zoom = worldZoom || 1; const dx = (e.clientX - moveRef.current.startX) / zoom; const dy = (e.clientY - moveRef.current.startY) / zoom; if (Math.abs(dx) + Math.abs(dy) > 2) moveRef.current.moved = true; const next = { dx: moveRef.current.ox + dx, dy: moveRef.current.oy + dy }; moveRef.current.last = next; updateGroup(group.id, { labelOffset: next }, { skipHistory: true }); }; const onPointerUp = (e) => { if (!moveRef.current) return; const { moved, last } = moveRef.current; moveRef.current = null; if (moved && updateGroup) { // Commit one history entry representing the whole drag. updateGroup(group.id, { labelOffset: last }); } try { e.currentTarget.releasePointerCapture(e.pointerId); } catch(_){} }; return ( {group.name} ); } window.GroupBlob = GroupBlob; window.GroupLabel = GroupLabel;