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.
+