feat: aggregate multiple relations between minimized groups

Implements visual aggregation of edges when multiple relations exist
between two minimized groups, preventing overlapping edges and improving
graph readability.

Key features:
- Aggregate edges between minimized groups using normalized keys (sorted
  node IDs) so bidirectional edges (A->B and B->A) merge together
- Display single neutral-styled edge (dark gray, solid, no arrows) with
  relation counter badge showing "X relations"
- Disable context menu and use special info panel for aggregated edges
- Hide individual edge type labels when aggregated
- Store all original relation data in aggregatedRelations array

Implementation details:
- GraphEditor: Use Map to deduplicate edges with bidirectional key for
  group-to-group connections, attach aggregatedCount metadata
- CustomEdge: Detect aggregated edges and apply neutral styling (#4b5563
  color, undirected/no arrows, no type label)
- RightPanel: Show "Aggregated Relations" info panel instead of
  EdgeEditorPanel for aggregated edges
- Context menu: Skip aggregated edges to prevent editing synthetic edges

Users can maximize groups to see and edit individual relations. This
keeps the graph clean when working with minimized groups while preserving
all underlying relationship data.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2025-10-21 11:45:27 +02:00
parent 7c49ad0baa
commit ace816f2a5
3 changed files with 102 additions and 17 deletions

View file

@ -93,13 +93,21 @@ const CustomEdge = ({
targetPosition: finalTargetPosition, targetPosition: finalTargetPosition,
}); });
// Check if this is an aggregated edge
const isAggregated = !!(data as { aggregatedCount?: number })?.aggregatedCount;
// Find the edge type configuration // Find the edge type configuration
const edgeTypeConfig = edgeTypes.find((et) => et.id === data?.type); const edgeTypeConfig = edgeTypes.find((et) => et.id === data?.type);
const edgeColor = edgeTypeConfig?.color || '#6b7280';
const edgeStyle = edgeTypeConfig?.style || 'solid'; // For aggregated edges, use neutral styling (dark gray, solid, no arrows)
const edgeColor = isAggregated ? '#4b5563' : (edgeTypeConfig?.color || '#6b7280'); // dark gray for aggregated
const edgeStyle = isAggregated ? 'solid' : (edgeTypeConfig?.style || 'solid');
// Use custom label if provided, otherwise use type's default label // Use custom label if provided, otherwise use type's default label
const displayLabel = data?.label || edgeTypeConfig?.label; // For aggregated edges, show "Multiple types" instead
const displayLabel = isAggregated
? undefined // Don't show individual type label for aggregated edges
: (data?.label || edgeTypeConfig?.label);
// Convert style to stroke-dasharray // Convert style to stroke-dasharray
const strokeDasharray = { const strokeDasharray = {
@ -109,7 +117,10 @@ const CustomEdge = ({
}[edgeStyle]; }[edgeStyle];
// Get directionality (default to 'directed' for backwards compatibility) // Get directionality (default to 'directed' for backwards compatibility)
const directionality = data?.directionality || edgeTypeConfig?.defaultDirectionality || 'directed'; // For aggregated edges, use 'undirected' (no arrows)
const directionality = isAggregated
? 'undirected'
: (data?.directionality || edgeTypeConfig?.defaultDirectionality || 'directed');
// Check if this edge matches the filter criteria // Check if this edge matches the filter criteria
const isMatch = useMemo(() => { const isMatch = useMemo(() => {
@ -212,8 +223,8 @@ const CustomEdge = ({
markerStart={markerStart} markerStart={markerStart}
/> />
{/* Edge label - show custom or type default, plus labels */} {/* Edge label - show custom or type default, plus labels, plus aggregation count */}
{(displayLabel || (data?.labels && data.labels.length > 0)) && ( {(displayLabel || (data?.labels && data.labels.length > 0) || (data as { aggregatedCount?: number })?.aggregatedCount) && (
<EdgeLabelRenderer> <EdgeLabelRenderer>
<div <div
style={{ style={{
@ -247,6 +258,15 @@ const CustomEdge = ({
})} })}
</div> </div>
)} )}
{/* Aggregation counter for multiple relations between minimized groups */}
{(data as { aggregatedCount?: number })?.aggregatedCount && (
<div
className="mt-1 px-2 py-0.5 rounded-full text-xs font-semibold text-white"
style={{ backgroundColor: edgeColor }}
>
{(data as { aggregatedCount?: number }).aggregatedCount} relations
</div>
)}
</div> </div>
</EdgeLabelRenderer> </EdgeLabelRenderer>
)} )}

View file

@ -187,8 +187,9 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onMultiSelect,
} }
}); });
// Map to deduplicate edges between groups: "source_target" -> edge // Map to deduplicate and aggregate edges between groups
const edgeMap = new Map<string, Edge>(); // Key: "source_target" -> { edge, aggregatedRelations: [...] }
const edgeMap = new Map<string, { edge: Edge; aggregatedRelations: Relation[] }>();
// Reroute edges: if source or target is in a minimized group, redirect to the group // Reroute edges: if source or target is in a minimized group, redirect to the group
// Filter out edges that are internal to a minimized group (both source and target in same group) // Filter out edges that are internal to a minimized group (both source and target in same group)
@ -206,10 +207,18 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onMultiSelect,
} }
// Create edge key for deduplication // Create edge key for deduplication
const edgeKey = `${newSource}_${newTarget}`; // For edges between two minimized groups, use a normalized key (alphabetically sorted)
// so that A->B and B->A use the same key and get aggregated together
const bothAreGroups = sourceChanged && targetChanged;
const edgeKey = bothAreGroups
? [newSource, newTarget].sort().join('_') // Normalized key for bidirectional aggregation
: `${newSource}_${newTarget}`; // Directional key for normal edges
// Check if this edge was rerouted (at least one endpoint changed)
const wasRerouted = sourceChanged || targetChanged;
// Only update if source or target changed // Only update if source or target changed
if (sourceChanged || targetChanged) { if (wasRerouted) {
// Destructure to separate handle properties from the rest // Destructure to separate handle properties from the rest
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { sourceHandle, targetHandle, ...edgeWithoutHandles } = edge; const { sourceHandle, targetHandle, ...edgeWithoutHandles } = edge;
@ -234,20 +243,43 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onMultiSelect,
newEdge.targetHandle = targetHandle; newEdge.targetHandle = targetHandle;
} }
// If we already have an edge between these nodes, keep the first one // If we already have an edge between these nodes, aggregate the relations
// (you could also merge labels or aggregate data here if needed) if (edgeMap.has(edgeKey)) {
if (!edgeMap.has(edgeKey)) { const existing = edgeMap.get(edgeKey)!;
edgeMap.set(edgeKey, newEdge); existing.aggregatedRelations.push(edge as Relation);
} else {
// First edge between these groups - store it with aggregation data
edgeMap.set(edgeKey, {
edge: newEdge,
aggregatedRelations: [edge as Relation],
});
} }
} else { } else {
// No rerouting needed, just add the edge // No rerouting needed, just add the edge (no aggregation for normal edges)
if (!edgeMap.has(edgeKey)) { if (!edgeMap.has(edgeKey)) {
edgeMap.set(edgeKey, edge); edgeMap.set(edgeKey, {
edge,
aggregatedRelations: [],
});
} }
} }
}); });
return Array.from(edgeMap.values()); // Convert the map to an array of edges, attaching aggregation metadata
return Array.from(edgeMap.values()).map(({ edge, aggregatedRelations }) => {
if (aggregatedRelations.length > 1) {
// Multiple relations aggregated - add metadata to edge data
return {
...edge,
data: {
...edge.data,
aggregatedCount: aggregatedRelations.length,
aggregatedRelations: aggregatedRelations,
},
} as Edge;
}
return edge;
});
}, [storeEdges, storeGroups, storeNodes]); }, [storeEdges, storeGroups, storeNodes]);
const [edges, setEdgesState, onEdgesChange] = useEdgesState( const [edges, setEdgesState, onEdgesChange] = useEdgesState(
@ -831,6 +863,13 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onMultiSelect,
const handleEdgeContextMenu = useCallback( const handleEdgeContextMenu = useCallback(
(event: React.MouseEvent, edge: Edge) => { (event: React.MouseEvent, edge: Edge) => {
event.preventDefault(); event.preventDefault();
// Don't show context menu for aggregated edges (synthetic edges between minimized groups)
const isAggregated = !!(edge.data as { aggregatedCount?: number })?.aggregatedCount;
if (isAggregated) {
return; // No context menu for aggregated edges
}
setContextMenu({ setContextMenu({
x: event.clientX, x: event.clientX,
y: event.clientY, y: event.clientY,

View file

@ -133,6 +133,32 @@ const RightPanel = ({
// Edge properties view // Edge properties view
if (selectedEdge) { if (selectedEdge) {
// Check if this is an aggregated edge (multiple relations between minimized groups)
const isAggregated = !!(selectedEdge.data as { aggregatedCount?: number })?.aggregatedCount;
if (isAggregated) {
// Show a special view for aggregated edges
const aggregatedCount = (selectedEdge.data as { aggregatedCount?: number })?.aggregatedCount || 0;
return (
<div
className="h-full bg-white border-l border-gray-200 flex flex-col"
style={{ width: `${rightPanelWidth}px` }}
>
<PanelHeader title="Aggregated Relations" onCollapse={collapseRightPanel} />
<div className="flex-1 overflow-y-auto p-4">
<div className="bg-blue-50 border border-blue-200 rounded p-4 mb-4">
<p className="text-sm text-blue-800">
<strong>{aggregatedCount} relations</strong> are aggregated between these minimized groups.
</p>
<p className="text-xs text-blue-600 mt-2">
Maximize the groups to see and edit individual relations.
</p>
</div>
</div>
</div>
);
}
return ( return (
<div <div
className="h-full bg-white border-l border-gray-200 flex flex-col" className="h-full bg-white border-l border-gray-200 flex flex-col"