// 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;