From ace816f2a5a8eb9e281f58221968f7f639aa514c Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Tue, 21 Oct 2025 11:45:27 +0200 Subject: [PATCH] feat: aggregate multiple relations between minimized groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/Edges/CustomEdge.tsx | 32 +++++++++++--- src/components/Editor/GraphEditor.tsx | 61 ++++++++++++++++++++++----- src/components/Panels/RightPanel.tsx | 26 ++++++++++++ 3 files changed, 102 insertions(+), 17 deletions(-) diff --git a/src/components/Edges/CustomEdge.tsx b/src/components/Edges/CustomEdge.tsx index ddd884f..ecd24c6 100644 --- a/src/components/Edges/CustomEdge.tsx +++ b/src/components/Edges/CustomEdge.tsx @@ -93,13 +93,21 @@ const CustomEdge = ({ targetPosition: finalTargetPosition, }); + // Check if this is an aggregated edge + const isAggregated = !!(data as { aggregatedCount?: number })?.aggregatedCount; + // Find the edge type configuration 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 - 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 const strokeDasharray = { @@ -109,7 +117,10 @@ const CustomEdge = ({ }[edgeStyle]; // 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 const isMatch = useMemo(() => { @@ -212,8 +223,8 @@ const CustomEdge = ({ markerStart={markerStart} /> - {/* Edge label - show custom or type default, plus labels */} - {(displayLabel || (data?.labels && data.labels.length > 0)) && ( + {/* Edge label - show custom or type default, plus labels, plus aggregation count */} + {(displayLabel || (data?.labels && data.labels.length > 0) || (data as { aggregatedCount?: number })?.aggregatedCount) && (
)} + {/* Aggregation counter for multiple relations between minimized groups */} + {(data as { aggregatedCount?: number })?.aggregatedCount && ( +
+ {(data as { aggregatedCount?: number }).aggregatedCount} relations +
+ )}
)} diff --git a/src/components/Editor/GraphEditor.tsx b/src/components/Editor/GraphEditor.tsx index 1f2753f..afbf3c0 100644 --- a/src/components/Editor/GraphEditor.tsx +++ b/src/components/Editor/GraphEditor.tsx @@ -187,8 +187,9 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onMultiSelect, } }); - // Map to deduplicate edges between groups: "source_target" -> edge - const edgeMap = new Map(); + // Map to deduplicate and aggregate edges between groups + // Key: "source_target" -> { edge, aggregatedRelations: [...] } + const edgeMap = new Map(); // 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) @@ -206,10 +207,18 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onMultiSelect, } // 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 - if (sourceChanged || targetChanged) { + if (wasRerouted) { // Destructure to separate handle properties from the rest // eslint-disable-next-line @typescript-eslint/no-unused-vars const { sourceHandle, targetHandle, ...edgeWithoutHandles } = edge; @@ -234,20 +243,43 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onMultiSelect, newEdge.targetHandle = targetHandle; } - // If we already have an edge between these nodes, keep the first one - // (you could also merge labels or aggregate data here if needed) - if (!edgeMap.has(edgeKey)) { - edgeMap.set(edgeKey, newEdge); + // If we already have an edge between these nodes, aggregate the relations + if (edgeMap.has(edgeKey)) { + const existing = edgeMap.get(edgeKey)!; + 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 { - // No rerouting needed, just add the edge + // No rerouting needed, just add the edge (no aggregation for normal edges) 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]); const [edges, setEdgesState, onEdgesChange] = useEdgesState( @@ -831,6 +863,13 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onMultiSelect, const handleEdgeContextMenu = useCallback( (event: React.MouseEvent, edge: Edge) => { 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({ x: event.clientX, y: event.clientY, diff --git a/src/components/Panels/RightPanel.tsx b/src/components/Panels/RightPanel.tsx index cd20885..8be531a 100644 --- a/src/components/Panels/RightPanel.tsx +++ b/src/components/Panels/RightPanel.tsx @@ -133,6 +133,32 @@ const RightPanel = ({ // Edge properties view 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 ( +
+ +
+
+

+ {aggregatedCount} relations are aggregated between these minimized groups. +

+

+ Maximize the groups to see and edit individual relations. +

+
+
+
+ ); + } + return (