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 (