mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-01-26 23:43:40 +00:00
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:
parent
7c49ad0baa
commit
ace816f2a5
3 changed files with 102 additions and 17 deletions
|
|
@ -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) && (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -247,6 +258,15 @@ const CustomEdge = ({
|
|||
})}
|
||||
</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>
|
||||
</EdgeLabelRenderer>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -187,8 +187,9 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onMultiSelect,
|
|||
}
|
||||
});
|
||||
|
||||
// Map to deduplicate edges between groups: "source_target" -> edge
|
||||
const edgeMap = new Map<string, Edge>();
|
||||
// Map to deduplicate and aggregate edges between groups
|
||||
// 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
|
||||
// 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,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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 (
|
||||
<div
|
||||
className="h-full bg-white border-l border-gray-200 flex flex-col"
|
||||
|
|
|
|||
Loading…
Reference in a new issue