diff --git a/packages/demo-app-ts/src/Demos.ts b/packages/demo-app-ts/src/Demos.ts index 2cc80168..a7ce0c18 100644 --- a/packages/demo-app-ts/src/Demos.ts +++ b/packages/demo-app-ts/src/Demos.ts @@ -13,6 +13,7 @@ import { ContextMenus } from './demos/ContextMenus'; import { TopologyPackage } from './demos/topologyPackageDemo/TopologyPackage'; import { ComplexGroup } from './demos/Groups'; import { CollapsibleGroups } from './demos/CollapsibleGroups'; +import { AggregateEdges } from './demos/aggregateEdges/AggregateEdges'; import { StatusConnectors } from './demos/statusConnectorsDemo/StatusConnectors'; import './Demo.css'; @@ -143,6 +144,11 @@ export const Demos: DemoInterface[] = [ id: 'collapsible-groups', name: 'Collapsible Groups', componentType: CollapsibleGroups + }, + { + id: 'aggregate-edges', + name: 'Aggregate Edges', + componentType: AggregateEdges } ]; diff --git a/packages/demo-app-ts/src/demos/aggregateEdges/AggregateEdge.tsx b/packages/demo-app-ts/src/demos/aggregateEdges/AggregateEdge.tsx new file mode 100644 index 00000000..2f977025 --- /dev/null +++ b/packages/demo-app-ts/src/demos/aggregateEdges/AggregateEdge.tsx @@ -0,0 +1,46 @@ +import { FunctionComponent } from 'react'; +import { observer } from 'mobx-react'; +import { AggregateEdgeRole, DefaultAggregatedEdge, GraphElement, WithSelectionProps } from '@patternfly/react-topology'; +import { useAggregateEdgesDemo } from './DemoContext'; + +type AggregateEdgeProps = { + element: GraphElement; +} & WithSelectionProps; + +/** + * Demo wrapper around DefaultAggregatedEdge: maps model data + demo toolbar + * options into explicit props (apps can derive these differently). + * + * Intentionally omits `onSelect` from `withSelection` so path multi-select from + * DefaultAggregatedEdge is not overwritten by single-id selection. + */ +const AggregateEdge: FunctionComponent = observer(({ element, selected }) => { + const { snapGeneration, showEdgeLabels, showMetricTags } = useAggregateEdgesDemo(); + const data = element.getData() || {}; + const role = data.role as AggregateEdgeRole | undefined; + const count = data.count as number | undefined; + const bidirectional = data.bidirectional as boolean | undefined; + const forwardEdgeIds = data.forwardEdgeIds as string[] | undefined; + const reverseEdgeIds = data.reverseEdgeIds as string[] | undefined; + + const edgeLabel = showEdgeLabels ? element.getLabel() : undefined; + const metricTag = showMetricTags ? (data.tag as string | undefined) : undefined; + // Only override the default count tag when the demo toolbar opts into labels/metrics. + const tag = edgeLabel || metricTag; + + return ( + + ); +}); + +export default AggregateEdge; diff --git a/packages/demo-app-ts/src/demos/aggregateEdges/AggregateEdges.tsx b/packages/demo-app-ts/src/demos/aggregateEdges/AggregateEdges.tsx new file mode 100644 index 00000000..2d2e7075 --- /dev/null +++ b/packages/demo-app-ts/src/demos/aggregateEdges/AggregateEdges.tsx @@ -0,0 +1,208 @@ +import { FunctionComponent, useEffect, useState, useRef } from 'react'; +import { action } from 'mobx'; +import { ToolbarGroup, ToolbarItem, Checkbox } from '@patternfly/react-core'; +import { + ColaLayout, + DefaultNode, + Graph, + GraphComponent, + GraphElement, + isEdge, + isNode, + Layout, + LayoutFactory, + ModelKind, + SELECTION_EVENT, + SelectionEventListener, + TopologyView, + Visualization, + VisualizationProvider, + VisualizationSurface, + withDragNode, + withPanZoom, + withSelection, + useEventListener, + observer +} from '@patternfly/react-topology'; +import DemoControlBar from '../DemoControlBar'; +import AggregateEdge from './AggregateEdge'; +import AggregateGroup from './AggregateGroup'; +import LabeledDefaultEdge from './LabeledDefaultEdge'; +import { getModel } from './model'; +import { AggregateEdgesDemoModel, AggregateEdgesDemoProvider, useAggregateEdgesDemo } from './DemoContext'; + +const layoutFactory: LayoutFactory = (_type: string, graph: Graph): Layout | undefined => + new ColaLayout(graph, { + layoutOnDrag: false, + nodeDistance: 80, + // Demo-sized graph: fewer ticks keeps aggregate edge snapping responsive. + maxTicks: 200, + initialUnconstrainedIterations: 50, + initialUserConstraintIterations: 25, + initialAllConstraintsIterations: 50 + }); + +/** Live Node.isCollapsed() — used only as input to createAggregateEdges. */ +const collectCollapsedIds = (controller: Visualization): Set => { + const ids = new Set(); + controller.getElements().forEach((element: GraphElement) => { + if (isNode(element) && element.isGroup() && element.isCollapsed()) { + ids.add(element.getId()); + } + }); + return ids; +}; + +/** + * Reused bridge/stub elements keep setStartPoint/setEndPoint overrides across collapse. + * Clear them so anchors recompute against the new collapsed bounds. + */ +const clearAggregateEdgeEndpoints = (controller: Visualization) => { + controller.getElements().forEach((element) => { + if (!isEdge(element) || element.getType() !== 'aggregate-edge') { + return; + } + element.setStartPoint(); + element.setEndPoint(); + }); +}; + +const applyDemoModel = ( + controller: Visualization, + groupEdges: boolean, + opts: { layout?: boolean; merge?: boolean; clearEndpoints?: boolean } = {} +) => { + const { layout = false, merge = true, clearEndpoints = false } = opts; + action(() => { + // Pass live collapse only for aggregation; getModel strips it before fromModel + // so we never re-drive Node.setCollapsed (DefaultGroup already owns that). + const model = getModel({ + groupEdges, + collapsedIds: collectCollapsedIds(controller) + }); + controller.fromModel(model, merge); + // Only clear on collapse/expand — label/tag toggles are UI-only and must not + // clear endpoints (geoKey unchanged → no AggregateEdge snap effect). + if (clearEndpoints) { + clearAggregateEdgeEndpoints(controller); + } + if (layout) { + controller.getGraph().layout(); + controller.getGraph().fit(80); + } + })(); +}; + +const AggregateEdgesView: FunctionComponent<{ controller: Visualization }> = observer(({ controller }) => { + const [selectedIds, setSelectedIds] = useState([]); + const { + groupEdges, + setGroupEdges, + showEdgeLabels, + setShowEdgeLabels, + showMetricTags, + setShowMetricTags, + setOnCollapseChange, + bumpSnapGeneration + } = useAggregateEdgesDemo(); + const fittedRef = useRef(false); + const groupEdgesRef = useRef(groupEdges); + groupEdgesRef.current = groupEdges; + + useEventListener(SELECTION_EVENT, (ids) => { + setSelectedIds(ids); + }); + + useEffect(() => { + const isFirstLoad = !fittedRef.current; + applyDemoModel(controller, groupEdges, { + merge: !isFirstLoad, + layout: isFirstLoad + }); + if (isFirstLoad) { + fittedRef.current = true; + } + }, [controller, groupEdges]); + + useEffect(() => { + setOnCollapseChange(() => { + // Collapse is already applied on the Node by DefaultGroup; rebuild aggregates only. + applyDemoModel(controller, groupEdgesRef.current, { merge: true, layout: false, clearEndpoints: true }); + bumpSnapGeneration(); + }); + }, [bumpSnapGeneration, controller, setOnCollapseChange]); + + const viewToolbar = ( + + + { + // Full graph shape change — re-layout + fit. + fittedRef.current = false; + setGroupEdges(checked); + }} + /> + + + setShowEdgeLabels(checked)} + /> + + + setShowMetricTags(checked)} + /> + + + ); + + return ( + } viewToolbar={viewToolbar}> + + + ); +}); + +export const AggregateEdges = () => { + const [controller] = useState(() => { + const vis = new Visualization(); + vis.registerLayoutFactory(layoutFactory); + vis.registerComponentFactory((kind, type) => { + if (kind === ModelKind.graph) { + return withPanZoom()(GraphComponent); + } + if (type === 'group') { + return withDragNode({ canCancel: false })(withSelection()(AggregateGroup)); + } + if (type === 'aggregate-edge') { + return withSelection()(AggregateEdge); + } + if (kind === ModelKind.node) { + return withDragNode({ canCancel: false })(withSelection()(DefaultNode)); + } + if (kind === ModelKind.edge) { + return withSelection()(LabeledDefaultEdge); + } + return undefined; + }); + vis.fromModel(getModel({ groupEdges: false }), false); + return vis; + }); + + return ( + + + + + + ); +}; diff --git a/packages/demo-app-ts/src/demos/aggregateEdges/AggregateGroup.tsx b/packages/demo-app-ts/src/demos/aggregateEdges/AggregateGroup.tsx new file mode 100644 index 00000000..a659cd50 --- /dev/null +++ b/packages/demo-app-ts/src/demos/aggregateEdges/AggregateGroup.tsx @@ -0,0 +1,35 @@ +import { FunctionComponent } from 'react'; +import { observer } from 'mobx-react'; +import { DefaultGroup, GraphElement, WithDragNodeProps, WithSelectionProps } from '@patternfly/react-topology'; +import { useAggregateEdgesDemo } from './DemoContext'; + +type AggregateGroupProps = { + element: GraphElement; +} & WithDragNodeProps & + WithSelectionProps; + +const COLLAPSED_SIZE = 60; + +/** + * Collapsible group built on DefaultGroup so expand/collapse uses the built-in + * chrome (rather than external toolbar toggles). Collapse notifies the demo so + * aggregate edges can be rebuilt from leaf edges. + */ +const AggregateGroup: FunctionComponent = observer(({ element, ...rest }) => { + const { onCollapseChange } = useAggregateEdgesDemo(); + const data = element.getData() || {}; + return ( + + ); +}); + +export default AggregateGroup; diff --git a/packages/demo-app-ts/src/demos/aggregateEdges/DemoContext.tsx b/packages/demo-app-ts/src/demos/aggregateEdges/DemoContext.tsx new file mode 100644 index 00000000..71224d68 --- /dev/null +++ b/packages/demo-app-ts/src/demos/aggregateEdges/DemoContext.tsx @@ -0,0 +1,64 @@ +import { createContext, useContext } from 'react'; +import { action, makeObservable, observable } from 'mobx'; + +export class AggregateEdgesDemoModel { + private groupEdgesP: boolean = false; + protected showEdgeLabelsP: boolean = false; + protected showMetricTagsP: boolean = false; + protected snapGenerationP: number = 0; + protected onCollapseChangeP: () => void; + + constructor() { + makeObservable< + AggregateEdgesDemoModel, + 'groupEdgesP' | 'showEdgeLabelsP' | 'showMetricTagsP' | 'snapGenerationP' | 'onCollapseChangeP' + >(this, { + groupEdgesP: observable, + showEdgeLabelsP: observable, + showMetricTagsP: observable, + onCollapseChangeP: observable, + snapGenerationP: observable, + setGroupEdges: action, + setShowEdgeLabels: action, + setShowMetricTags: action, + bumpSnapGeneration: action, + setOnCollapseChange: action + }); + } + + public get groupEdges(): boolean { + return this.groupEdgesP; + } + public setGroupEdges = (grouped: boolean): void => { + this.groupEdgesP = grouped; + }; + public get showEdgeLabels(): boolean { + return this.showEdgeLabelsP; + } + public setShowEdgeLabels = (show: boolean): void => { + this.showEdgeLabelsP = show; + }; + public get showMetricTags(): boolean { + return this.showMetricTagsP; + } + public setShowMetricTags = (show: boolean): void => { + this.showMetricTagsP = show; + }; + public get snapGeneration(): number { + return this.snapGenerationP; + } + public bumpSnapGeneration = (): void => { + this.snapGenerationP = this.snapGenerationP + 1; + }; + public get onCollapseChange(): () => void { + return this.onCollapseChangeP; + } + public setOnCollapseChange = (onChange: () => void): void => { + this.onCollapseChangeP = onChange; + }; +} +export const AggregateEdgesDemoContext = createContext(new AggregateEdgesDemoModel()); + +export const AggregateEdgesDemoProvider = AggregateEdgesDemoContext.Provider; + +export const useAggregateEdgesDemo = (): AggregateEdgesDemoModel => useContext(AggregateEdgesDemoContext); diff --git a/packages/demo-app-ts/src/demos/aggregateEdges/LabeledDefaultEdge.tsx b/packages/demo-app-ts/src/demos/aggregateEdges/LabeledDefaultEdge.tsx new file mode 100644 index 00000000..e4ad8f7a --- /dev/null +++ b/packages/demo-app-ts/src/demos/aggregateEdges/LabeledDefaultEdge.tsx @@ -0,0 +1,20 @@ +import { FunctionComponent } from 'react'; +import { observer } from 'mobx-react'; +import { DefaultEdge, GraphElement, WithSelectionProps } from '@patternfly/react-topology'; +import { useAggregateEdgesDemo } from './DemoContext'; + +type LabeledDefaultEdgeProps = { + element: GraphElement; +} & WithSelectionProps; + +/** + * Leaf (non-aggregate) edge that shows a custom label or metric tag when present. + */ +const LabeledDefaultEdge: FunctionComponent = observer(({ element, ...rest }) => { + const { showEdgeLabels, showMetricTags } = useAggregateEdgesDemo(); + const label = showEdgeLabels ? element.getLabel() : undefined; + const metricTag = showMetricTags ? (element.getData()?.tag as string) : undefined; + return ; +}); + +export default LabeledDefaultEdge; diff --git a/packages/demo-app-ts/src/demos/aggregateEdges/model.ts b/packages/demo-app-ts/src/demos/aggregateEdges/model.ts new file mode 100644 index 00000000..cc4a0a52 --- /dev/null +++ b/packages/demo-app-ts/src/demos/aggregateEdges/model.ts @@ -0,0 +1,193 @@ +import { EdgeModel, NodeModel, NodeShape, createAggregateEdges, Model } from '@patternfly/react-topology'; + +const COLLAPSED_SIZE = 60; + +export interface DemoOptions { + groupEdges: boolean; + /** + * Live collapsed group ids from the controller. Used only to feed + * createAggregateEdges — never written onto NodeModels returned for fromModel + * (Node.isCollapsed() remains the source of truth). + */ + collapsedIds?: Set; +} + +const leaf = (id: string, label: string): NodeModel => ({ + id, + type: 'node', + label, + width: 40, + height: 40, + shape: NodeShape.ellipse +}); + +const groupNode = (id: string, label: string, children: string[]): NodeModel => ({ + id, + type: 'group', + label, + group: true, + children, + style: { padding: 20 }, + data: { + collapsedWidth: COLLAPSED_SIZE, + collapsedHeight: COLLAPSED_SIZE + } +}); + +/** Demo byte-rate formatter (NetObserv-style: scale then append unit). */ +const formatBps = (bps: number): string => { + if (bps >= 1_000_000) { + return `${(bps / 1_000_000).toFixed(1)} MBps`; + } + if (bps >= 1_000) { + return `${(bps / 1_000).toFixed(1)} kBps`; + } + return `${Math.round(bps)} Bps`; +}; + +/** + * After structural aggregation, sum leaf metrics onto the label-bearing bridge + * and format a single tag — mirrors how NetObserv should merge byte rates. + */ +const applyMetricTags = (edges: EdgeModel[]): EdgeModel[] => { + const byId = new Map(edges.map((e) => [e.id, e])); + + edges.forEach((edge) => { + const role = edge.data?.role as string | undefined; + const leafIds: string[] = edge.data?.aggregatedEdgeIds || []; + if (!leafIds.length) { + // Non-aggregate leaf: format its own bps if present. + if (typeof edge.data?.bps === 'number' && edge.data.bps > 0) { + edge.data = { ...edge.data, tag: formatBps(edge.data.bps) }; + } + return; + } + + // Put the summed metric on the bridge only (one tag along a multi-part path). + if (role && role !== 'bridge') { + return; + } + + const bps = leafIds.reduce((sum, id) => sum + (byId.get(id)?.data?.bps || 0), 0); + if (bps > 0) { + edge.data = { ...edge.data, bps, tag: formatBps(bps) }; + } + }); + + return edges; +}; + +const link = (source: string, target: string, options: { label?: string; bps?: number } = {}): EdgeModel => ({ + id: `${source}_${target}`, + type: 'edge', + source, + target, + ...(options.label ? { label: options.label } : {}), + ...(options.bps != null ? { data: { bps: options.bps } } : {}) +}); + +export const getModel = ({ groupEdges, collapsedIds }: DemoOptions): Model => { + const group1Nodes = [leaf('11', '1-1'), leaf('12', '1-2'), leaf('13', '1-3')]; + const group2Nodes = [leaf('21', '2-1'), leaf('22', '2-2'), leaf('23', '2-3'), leaf('24', '2-4'), leaf('25', '2-5')]; + const subGroup1Nodes = [leaf('14', '1-4'), leaf('15', '1-5')]; + const subGroup3Nodes = [leaf('31', '3-1'), leaf('32', '3-2'), leaf('33', '3-3')]; + + const subGroup1 = groupNode( + 'Subgroup 1', + 'Subgroup 1', + subGroup1Nodes.map((n) => n.id) + ); + const subGroup3 = groupNode( + 'Subgroup 3', + 'Subgroup 3', + subGroup3Nodes.map((n) => n.id) + ); + const group1 = groupNode('Group 1', 'Group 1', [...group1Nodes.map((n) => n.id), subGroup1.id]); + const group2 = groupNode( + 'Group 2', + 'Group 2', + group2Nodes.map((n) => n.id) + ); + const group3 = groupNode('Group 3', 'Group 3', [subGroup3.id]); + + const ungrouped = [leaf('1', 'One'), leaf('2', 'Two')]; + + const nodes: NodeModel[] = [ + ...ungrouped, + ...group1Nodes, + ...subGroup1Nodes, + ...group2Nodes, + ...subGroup3Nodes, + group1, + group2, + subGroup1, + subGroup3, + group3 + ]; + + // Stamp collapse for createAggregateEdges only — stripped before return. + if (collapsedIds?.size) { + nodes.forEach((n) => { + if (collapsedIds.has(n.id)) { + n.collapsed = true; + } + }); + } + + const edges: EdgeModel[] = [ + // Intra-group edges (should stay visible when aggregating between groups) + link('11', '12', { label: 'local', bps: 120 }), + link('12', '13', { bps: 80 }), + link('14', '15', { bps: 40 }), + link('21', '22', { bps: 60 }), + link('22', '23', { bps: 90 }), + link('24', '25', { bps: 50 }), + link('31', '32', { bps: 70 }), + link('32', '33', { bps: 30 }), + // Group 1 → Group 2 (bridge should sum these rates) + link('11', '21', { label: 'traffic', bps: 400 }), + link('12', '21', { bps: 500 }), + link('13', '21', { bps: 300 }), + // Ungrouped → Subgroup 3 members + link('1', '31', { label: 'ingress', bps: 250 }), + link('1', '32', { bps: 150 }), + link('2', '31', { bps: 200 }), + // Node → group id (ungrouped node targets the group itself) + link('2', 'Group 2', { label: 'attach', bps: 180 }), + link('1', 'Subgroup 3', { bps: 100 }), + // Group 2 ↔ Subgroup 3 (bidirectional mix) + link('21', '31', { label: 'mesh', bps: 350 }), + link('32', '21', { bps: 220 }), + link('21', '32', { bps: 180 }), + link('22', '31', { bps: 140 }), + link('22', '32', { bps: 160 }), + // Subgroup ↔ subgroup under different parents + link('14', '31', { label: 'peer', bps: 90 }), + link('15', '32', { bps: 110 }), + link('33', '14', { bps: 75 }), + // Cross nest: Group 2 member → Group 1 + link('23', '11', { label: 'sync', bps: 450 }) + ]; + + let resultEdges = createAggregateEdges('aggregate-edge', edges, nodes, { + groupEdges, + collapsedGroups: true + }); + + // Drop collapsed so fromModel merge does not re-call setCollapsed. + nodes.forEach((n) => { + delete n.collapsed; + }); + + resultEdges = applyMetricTags(resultEdges); + + return { + graph: { + id: 'g1', + type: 'graph', + layout: 'Cola' + }, + nodes, + edges: resultEdges + }; +}; diff --git a/packages/module/src/components/edges/DefaultAggregatedEdge.tsx b/packages/module/src/components/edges/DefaultAggregatedEdge.tsx new file mode 100644 index 00000000..4b2005e4 --- /dev/null +++ b/packages/module/src/components/edges/DefaultAggregatedEdge.tsx @@ -0,0 +1,236 @@ +import { FunctionComponent, MouseEvent, useCallback, useEffect, useRef, useState } from 'react'; +import { action } from 'mobx'; +import { observer } from 'mobx-react'; +import { + Edge, + EdgeTerminalType, + GRAPH_LAYOUT_END_EVENT, + GraphLayoutEndEventListener, + isEdge, + isNode +} from '../../types'; +import { AggregateEdgeRole } from '../../utils/createAggregateEdges'; +import { SELECTION_STATE } from '../../behavior'; +import { useEventListener } from '../../hooks'; +import DefaultEdge, { DefaultEdgeProps } from './DefaultEdge'; +import { + AGGREGATE_HULL_SETTLE_MS, + AGGREGATE_HULL_SNAP_THRESHOLD, + AGGREGATE_MOVE_SNAP_THRESHOLD, + boundsKey, + findRelatedBridge, + getBridgeTerminalPresentation, + getSelectionFocusLeaves, + MUTED_TERMINAL_CLASS, + selectRelatedAggregateSegments, + snapAggregateEdge +} from './aggregateEdgeUtils'; + +export interface DefaultAggregatedEdgeProps extends DefaultEdgeProps { + /** + * Segment role (`exit` / `bridge` / `entry`). Typically from `createAggregateEdges` + * edge data, but apps may supply an equivalent. + */ + role?: AggregateEdgeRole | string; + /** + * Number of leaf edges folded into this segment. Used for the default bridge tag + * when {@link DefaultAggregatedEdgeProps.tag} is omitted. + */ + count?: number; + /** When true, the bridge shows terminals on both ends. */ + bidirectional?: boolean; + /** Leaf edge ids flowing in the bridge's stored orientation. */ + forwardEdgeIds?: string[]; + /** Leaf edge ids flowing opposite the bridge orientation. */ + reverseEdgeIds?: string[]; + /** + * Bump after structural changes (e.g. group collapse / model rebuild) that leave + * fixed endpoints stale without enough bound change to re-trigger snap. + * Layout end is handled internally via {@link GRAPH_LAYOUT_END_EVENT}. + */ + snapGeneration?: number; +} + +/** + * Default renderer for edges produced by {@link createAggregateEdges} with + * `groupEdges` (exit / bridge / entry roles). + * + * Handles: + * - Snapping endpoints to group outlines while layout moves, then refining after settle + * - Force-resnap after layout end (and optionally via {@link DefaultAggregatedEdgeProps.snapGeneration}) + * - Multi-segment path selection by shared `aggregatedEdgeIds` + * - Bidirectional bridge terminals, muting the opposite arrow when selection is one-way + * + * Aggregate metadata (`role`, `count`, `bidirectional`, …) is passed as props so apps + * can derive or override them instead of reading a fixed `element.getData()` shape. + * + * Extends {@link DefaultEdgeProps} and forwards remaining props to {@link DefaultEdge}. + * + * **Selection:** path click fires `SELECTION_EVENT` with all related segment ids + * (it does not write controller selection state). Wire that event into controlled + * selection — e.g. `VisualizationSurface state={{ selectedIds }}` — and do **not** + * forward `withSelection`'s `onSelect` into this component (it would collapse the + * path selection back to the clicked edge id). Optional `onSelect` still runs after + * path select for app-specific side effects. + */ +const DefaultAggregatedEdge: FunctionComponent = observer( + ({ + element, + selected, + onSelect, + role, + count, + bidirectional, + forwardEdgeIds, + reverseEdgeIds, + snapGeneration = 0, + tag: tagProp, + startTerminalType: startTerminalTypeProp, + endTerminalType: endTerminalTypeProp, + startTerminalClass: startTerminalClassProp, + endTerminalClass: endTerminalClassProp, + ...rest + }) => { + const edge = isEdge(element) ? (element as Edge) : null; + const [layoutSnapGeneration, setLayoutSnapGeneration] = useState(0); + + const onLayoutEnd = useCallback(() => { + if (!edge?.hasController()) { + return; + } + action(() => { + edge.setStartPoint(); + edge.setEndPoint(); + })(); + setLayoutSnapGeneration((g) => g + 1); + }, [edge]); + + // Clear this edge's fixed endpoints and force-resnap when Cola (etc.) finishes. + // Mid-layout snaps often leave stubs/bridges pointing at stale hulls. + useEventListener(GRAPH_LAYOUT_END_EVENT, onLayoutEnd); + + const sourceNode = edge?.getSource(); + const targetNode = edge?.getTarget(); + + let geoKey = ''; + if (edge && sourceNode && targetNode && isNode(sourceNode) && isNode(targetNode)) { + geoKey = `${boundsKey(sourceNode)}|${boundsKey(targetNode)}`; + if (role === 'exit' || role === 'entry') { + const bridge = findRelatedBridge(edge); + if (bridge) { + geoKey += `|${boundsKey(bridge.getSource())}|${boundsKey(bridge.getTarget())}`; + } + } + } + + const effectiveSnapGeneration = snapGeneration + layoutSnapGeneration; + const settleTimerRef = useRef | null>(null); + const rafRef = useRef(0); + const lastSnapGenerationRef = useRef(effectiveSnapGeneration); + + useEffect(() => { + if (!edge?.hasController()) { + return undefined; + } + if (role !== 'bridge' && role !== 'exit' && role !== 'entry') { + return undefined; + } + + const forceSnap = lastSnapGenerationRef.current !== effectiveSnapGeneration; + lastSnapGenerationRef.current = effectiveSnapGeneration; + + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + } + if (settleTimerRef.current) { + clearTimeout(settleTimerRef.current); + } + + // Two-phase snap while geometry is moving: + // 1) rAF — cheap approx outline (keeps stubs attached during Cola ticks) + // 2) settle timeout — precise hull after motion stops + // On forceSnap (layout end / collapse), skip approx and do one precise snap on rAF + // so we do not race two delay-0 callbacks (timeout can otherwise run before rAF). + if (forceSnap) { + rafRef.current = requestAnimationFrame(() => { + snapAggregateEdge(edge, role, true, 0); + }); + } else { + rafRef.current = requestAnimationFrame(() => { + snapAggregateEdge(edge, role, false, AGGREGATE_MOVE_SNAP_THRESHOLD); + }); + settleTimerRef.current = setTimeout(() => { + snapAggregateEdge(edge, role, true, AGGREGATE_HULL_SNAP_THRESHOLD); + }, AGGREGATE_HULL_SETTLE_MS); + } + + return () => { + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + } + if (settleTimerRef.current) { + clearTimeout(settleTimerRef.current); + } + }; + }, [edge, role, geoKey, effectiveSnapGeneration]); + + if (!edge?.hasController()) { + return null; + } + + const handleSelect = (e: MouseEvent) => { + e.stopPropagation(); + if (!edge.hasController()) { + return; + } + selectRelatedAggregateSegments(edge); + onSelect?.(e); + }; + + let startTerminalType = EdgeTerminalType.none; + let endTerminalType = EdgeTerminalType.none; + let startTerminalClass: string | undefined; + let endTerminalClass: string | undefined; + if (role === 'bridge') { + const selectionState = edge.getController().getState<{ [SELECTION_STATE]?: string[] }>(); + const selectedIds = selectionState[SELECTION_STATE] || []; + const focusLeaves = getSelectionFocusLeaves(edge, selectedIds); + const terminals = getBridgeTerminalPresentation( + { bidirectional, forwardEdgeIds, reverseEdgeIds }, + selected, + focusLeaves + ); + startTerminalType = terminals.start; + endTerminalType = terminals.end; + if (terminals.muteStart) { + startTerminalClass = MUTED_TERMINAL_CLASS; + } + if (terminals.muteEnd) { + endTerminalClass = MUTED_TERMINAL_CLASS; + } + } else if (role === 'entry' && isNode(targetNode) && !targetNode.isGroup()) { + endTerminalType = EdgeTerminalType.directional; + } + + let tag = tagProp; + if (tag === undefined && role === 'bridge' && count && count > 1) { + tag = String(count); + } + + return ( + + ); + } +); + +export default DefaultAggregatedEdge; diff --git a/packages/module/src/components/edges/DefaultEdge.tsx b/packages/module/src/components/edges/DefaultEdge.tsx index c1f57831..92430db1 100644 --- a/packages/module/src/components/edges/DefaultEdge.tsx +++ b/packages/module/src/components/edges/DefaultEdge.tsx @@ -1,4 +1,4 @@ -import { useLayoutEffect, useRef } from 'react'; +import { FunctionComponent, MouseEvent, ReactNode, useLayoutEffect, useRef } from 'react'; import { observer } from 'mobx-react'; import { Edge, @@ -22,9 +22,9 @@ import DefaultConnectorTag from './DefaultConnectorTag'; import { Point } from '../../geom'; import { getConnectorStartPoint } from './terminals/terminalUtils'; -interface DefaultEdgeProps { +export interface DefaultEdgeProps { /** Additional content added to the edge */ - children?: React.ReactNode; + children?: ReactNode; /** Additional classes added to the edge */ className?: string; /** The graph edge element to represent */ @@ -78,7 +78,7 @@ interface DefaultEdgeProps { /** Function to call when the element should become selected (or deselected). Part of WithSelectionProps */ onSelect?: OnSelect; /** Function to call to show a context menu for the edge */ - onContextMenu?: (e: React.MouseEvent) => void; + onContextMenu?: (e: MouseEvent) => void; /** Flag indicating that the context menu for the edge is currently open */ contextMenuOpen?: boolean; /** @@ -91,7 +91,7 @@ interface DefaultEdgeProps { type DefaultEdgeInnerProps = Omit & { element: Edge }; -const DefaultEdgeInner: React.FunctionComponent = observer( +const DefaultEdgeInner: FunctionComponent = observer( ({ element, dragging, @@ -271,7 +271,7 @@ const DefaultEdgeInner: React.FunctionComponent = observe } ); -const DefaultEdge: React.FunctionComponent = ({ +const DefaultEdge: FunctionComponent = ({ element, startTerminalType = EdgeTerminalType.none, startTerminalSize = 14, diff --git a/packages/module/src/components/edges/aggregateEdgeUtils.ts b/packages/module/src/components/edges/aggregateEdgeUtils.ts new file mode 100644 index 00000000..22a659c9 --- /dev/null +++ b/packages/module/src/components/edges/aggregateEdgeUtils.ts @@ -0,0 +1,381 @@ +import { action } from 'mobx'; +import { AnchorEnd, Edge, EdgeTerminalType, isNode, Node, NodeStyle } from '../../types'; +import { SELECTION_EVENT, SELECTION_STATE } from '../../behavior'; +import Point from '../../geom/Point'; + +interface XY { + x: number; + y: number; +} + +export const AGGREGATE_MOVE_SNAP_THRESHOLD = 3; +export const AGGREGATE_HULL_SNAP_THRESHOLD = 2; +export const AGGREGATE_HULL_SETTLE_MS = 100; + +export const MUTED_TERMINAL_CLASS = 'pf-m-muted'; + +export const findRelatedBridge = (stub: Edge): Edge | undefined => { + if (!stub.hasController()) { + return undefined; + } + + const bridgeId = stub.getData()?.bridgeId as string | undefined; + if (bridgeId) { + try { + return stub.getController().getEdgeById(bridgeId); + } catch { + return undefined; + } + } + + const bridgeKey = stub.getData()?.bridgeKey as string | undefined; + if (!bridgeKey) { + return undefined; + } + + return stub + .getGraph() + .getEdges() + .find((e) => e.getData()?.role === 'bridge' && e.getData()?.bridgeKey === bridgeKey); +}; + +const readGroupPadding = (group: Node): number => { + const padding = group.getStyle()?.padding; + if (typeof padding === 'number') { + return padding; + } + if (padding && typeof padding === 'object') { + const box = padding as { top?: number; right?: number; bottom?: number; left?: number }; + return Math.max(box.top ?? 0, box.right ?? 0, box.bottom ?? 0, box.left ?? 0); + } + return 17; +}; + +const ellipseOnBounds = (group: Node, toward: Node): XY => { + const b = group.getBounds(); + const cx = b.x + b.width / 2; + const cy = b.y + b.height / 2; + const reference = toward.getBounds().getCenter(); + const extra = Math.max(10, readGroupPadding(group) * 0.5); + const width = b.width + extra * 2; + const height = b.height + extra * 2; + + if (width === 0 || height === 0 || (cx === reference.x && cy === reference.y)) { + return { x: cx, y: cy }; + } + + const dispX = (cx - reference.x) / (width / 2); + const dispY = (cy - reference.y) / (height / 2); + const len = Math.sqrt(dispX * dispX + dispY * dispY); + if (len === 0) { + return { x: cx, y: cy }; + } + const lenProportion = (len - 1) / len; + return { + x: (cx - reference.x) * lenProportion + reference.x, + y: (cy - reference.y) * lenProportion + reference.y + }; +}; + +interface AnchorWithSvg { + svgElement?: SVGElement; + getLocation: (reference: Point) => Point; +} + +const getAnchorSvg = (group: Node, end: AnchorEnd): SVGElement | undefined => { + const anchor = group.getAnchor(end) as AnchorWithSvg | undefined; + return anchor?.svgElement; +}; + +/** Motion-time outline snap: coarse hull path sample, or O(1) for rect/ellipse. */ +const approxBorderFacing = (group: Node, toward: Node, end: AnchorEnd = AnchorEnd.both): XY => { + const reference = toward.getBounds().getCenter(); + const svg = getAnchorSvg(group, end); + + if (svg instanceof SVGRectElement || svg instanceof SVGEllipseElement || svg instanceof SVGCircleElement) { + const loc = group.getAnchor(end).getLocation(reference); + return { x: loc.x, y: loc.y }; + } + + if (svg instanceof SVGPathElement && svg.viewportElement) { + try { + const localRef = reference.clone(); + group.translateFromParent(localRef); + + const pathLength = svg.getTotalLength(); + if (pathLength > 0) { + const box = svg.getBBox(); + const cx = box.x + box.width / 2; + const cy = box.y + box.height / 2; + const vx = localRef.x - cx; + const vy = localRef.y - cy; + const vLen = Math.hypot(vx, vy) || 1; + + const samples = 16; + let best: XY | undefined; + let bestScore = Infinity; + for (let i = 0; i < samples; i++) { + const p = svg.getPointAtLength((pathLength * i) / samples); + const wx = p.x - cx; + const wy = p.y - cy; + const dot = vx * wx + vy * wy; + if (dot <= 0) { + continue; + } + const cross = Math.abs(vx * wy - vy * wx) / vLen; + if (cross < bestScore) { + bestScore = cross; + best = { x: p.x, y: p.y }; + } + } + + if (best) { + const pt = new Point(best.x, best.y); + group.translateToParent(pt); + return { x: pt.x, y: pt.y }; + } + } + } catch { + // fall through + } + } + + return ellipseOnBounds(group, toward); +}; + +const hullBorderFacing = (group: Node, toward: Node, end: AnchorEnd = AnchorEnd.both): XY => { + const reference = toward.getBounds().getCenter(); + const anchor = group.getAnchor(end); + if (anchor) { + const loc = anchor.getLocation(reference); + return { x: loc.x, y: loc.y }; + } + return approxBorderFacing(group, toward, end); +}; + +const getPathPeer = (stub: Edge, role: 'exit' | 'entry', bridge: Edge): Node | undefined => { + const bridgeSource = bridge.getSource(); + const bridgeTarget = bridge.getTarget(); + if (!isNode(bridgeSource) || !isNode(bridgeTarget)) { + return undefined; + } + + const endIds = new Set([stub.getSource().getId(), stub.getTarget().getId()]); + if (endIds.has(bridgeSource.getId())) { + return bridgeTarget; + } + if (endIds.has(bridgeTarget.getId())) { + return bridgeSource; + } + + const groupNode = role === 'exit' ? stub.getTarget() : stub.getSource(); + if (!isNode(groupNode)) { + return undefined; + } + const center = groupNode.getBounds().getCenter(); + const sc = bridgeSource.getBounds().getCenter(); + const tc = bridgeTarget.getBounds().getCenter(); + const dSource = (sc.x - center.x) ** 2 + (sc.y - center.y) ** 2; + const dTarget = (tc.x - center.x) ** 2 + (tc.y - center.y) ** 2; + return dSource <= dTarget ? bridgeTarget : bridgeSource; +}; + +const getRelatedSegmentIds = (edge: Edge): string[] => { + const leafIds = (edge.getData()?.aggregatedEdgeIds as string[] | undefined) || []; + if (!leafIds.length) { + return [edge.getId()]; + } + + const leafSet = new Set(leafIds); + return edge + .getGraph() + .getEdges() + .filter((e) => { + const ids = (e.getData()?.aggregatedEdgeIds as string[] | undefined) || []; + return ids.some((id) => leafSet.has(id)); + }) + .map((e) => e.getId()); +}; + +/** + * Leaves that define the selected *flow* through a bridge. Prefer exit/entry stub + * leaves so co-selecting a bidirectional bridge does not pull in the opposite + * direction's arrowhead. + */ +export const getSelectionFocusLeaves = (edge: Edge, selectedIds: string[]): Set => { + const stubLeaves = new Set(); + selectedIds.forEach((id) => { + try { + const selected = edge.getController().getEdgeById(id); + const role = selected.getData()?.role as string | undefined; + if (role !== 'exit' && role !== 'entry') { + return; + } + ((selected.getData()?.aggregatedEdgeIds as string[]) || []).forEach((leafId) => stubLeaves.add(leafId)); + } catch { + // Edge may have been removed during model rebuild. + } + }); + if (stubLeaves.size > 0) { + return stubLeaves; + } + return new Set((edge.getData()?.aggregatedEdgeIds as string[]) || []); +}; + +export const getBridgeTerminalPresentation = ( + options: { + bidirectional?: boolean; + forwardEdgeIds?: string[]; + reverseEdgeIds?: string[]; + }, + selected: boolean | undefined, + focusLeaves: Set +): { + start: EdgeTerminalType; + end: EdgeTerminalType; + muteStart: boolean; + muteEnd: boolean; +} => { + const bidirectional = !!options.bidirectional; + if (!bidirectional) { + return { + start: EdgeTerminalType.none, + end: EdgeTerminalType.directional, + muteStart: false, + muteEnd: false + }; + } + + const both = { + start: EdgeTerminalType.directional, + end: EdgeTerminalType.directional, + muteStart: false, + muteEnd: false + }; + + if (!selected) { + return both; + } + + const forwardIds = options.forwardEdgeIds || []; + const reverseIds = options.reverseEdgeIds || []; + const hasForward = forwardIds.some((id) => focusLeaves.has(id)); + const hasReverse = reverseIds.some((id) => focusLeaves.has(id)); + + if ((hasForward && hasReverse) || (!hasForward && !hasReverse)) { + return both; + } + + return { + start: EdgeTerminalType.directional, + end: EdgeTerminalType.directional, + muteStart: hasForward && !hasReverse, + muteEnd: hasReverse && !hasForward + }; +}; + +const significantlyMoved = (a: Point, x: number, y: number, threshold: number): boolean => + Math.abs(a.x - x) > threshold || Math.abs(a.y - y) > threshold; + +export const boundsKey = (node: Node): string => { + const b = node.getBounds(); + return `${Math.round(b.x)},${Math.round(b.y)},${Math.round(b.width)},${Math.round(b.height)}`; +}; + +interface SnapPlan { + start: XY | null; + end: XY | null; +} + +const applySnapPlan = (edge: Edge, plan: SnapPlan, threshold: number): void => { + action(() => { + const startFixed = plan.start !== null; + const endFixed = plan.end !== null; + if (!startFixed && !endFixed) { + return; + } + const startMoved = startFixed + ? significantlyMoved(edge.getStartPoint(), plan.start.x, plan.start.y, threshold) + : false; + const endMoved = endFixed ? significantlyMoved(edge.getEndPoint(), plan.end.x, plan.end.y, threshold) : false; + if (!startMoved && !endMoved) { + return; + } + if (startFixed) { + edge.setStartPoint(Math.round(plan.start.x), Math.round(plan.start.y)); + } else { + edge.setStartPoint(); + } + if (endFixed) { + edge.setEndPoint(Math.round(plan.end.x), Math.round(plan.end.y)); + } else { + edge.setEndPoint(); + } + })(); +}; + +const computeSnapPlan = (edge: Edge, role: string | undefined, precise: boolean): SnapPlan | undefined => { + const sourceNode = edge.getSource(); + const targetNode = edge.getTarget(); + if (!isNode(sourceNode) || !isNode(targetNode)) { + return undefined; + } + + const borderFacing = precise + ? (group: Node, toward: Node, end?: AnchorEnd) => hullBorderFacing(group, toward, end) + : (group: Node, toward: Node, end?: AnchorEnd) => approxBorderFacing(group, toward, end ?? AnchorEnd.both); + + if (role === 'bridge') { + return { + start: borderFacing(sourceNode, targetNode, AnchorEnd.source), + end: borderFacing(targetNode, sourceNode, AnchorEnd.target) + }; + } + + if (role === 'exit' || role === 'entry') { + const bridge = findRelatedBridge(edge); + if (!bridge) { + return undefined; + } + const peer = getPathPeer(edge, role, bridge); + if (!peer) { + return undefined; + } + const plan: SnapPlan = { + start: null, + end: null + }; + if (sourceNode.isGroup()) { + plan.start = borderFacing(sourceNode, peer, AnchorEnd.source); + } + if (targetNode.isGroup()) { + plan.end = borderFacing(targetNode, peer, AnchorEnd.target); + } + return plan; + } + + return undefined; +}; + +/** Compute and apply a snap plan for an aggregate edge segment. */ +export const snapAggregateEdge = (edge: Edge, role: string | undefined, precise: boolean, threshold: number): void => { + if (!edge.hasController()) { + return; + } + const plan = computeSnapPlan(edge, role, precise); + if (plan) { + applySnapPlan(edge, plan, threshold); + } +}; + +/** Select/deselect this aggregate segment and every related exit/bridge/entry sharing its leaves. */ +export const selectRelatedAggregateSegments = (edge: Edge): string[] => { + const relatedIds = getRelatedSegmentIds(edge); + const ordered = [edge.getId(), ...relatedIds.filter((id) => id !== edge.getId())]; + const state = edge.getController().getState<{ [SELECTION_STATE]?: string[] }>(); + const allSelected = ordered.every((id) => state[SELECTION_STATE]?.includes(id)); + const selectedIds = allSelected ? [] : ordered; + // App listeners (e.g. VisualizationSurface via SELECTION_EVENT) own controller state updates. + edge.getController().fireEvent(SELECTION_EVENT, selectedIds); + return selectedIds; +}; diff --git a/packages/module/src/components/edges/index.ts b/packages/module/src/components/edges/index.ts index 5af71c91..eb19dd6e 100644 --- a/packages/module/src/components/edges/index.ts +++ b/packages/module/src/components/edges/index.ts @@ -1,3 +1,6 @@ export { default as DefaultEdge } from './DefaultEdge'; +export type { DefaultEdgeProps } from './DefaultEdge'; +export { default as DefaultAggregatedEdge } from './DefaultAggregatedEdge'; +export type { DefaultAggregatedEdgeProps } from './DefaultAggregatedEdge'; export { default as DefaultConntectorTag } from './DefaultConnectorTag'; export * from './terminals'; diff --git a/packages/module/src/components/groups/DefaultGroup.tsx b/packages/module/src/components/groups/DefaultGroup.tsx index 60674770..f1e6be34 100644 --- a/packages/module/src/components/groups/DefaultGroup.tsx +++ b/packages/module/src/components/groups/DefaultGroup.tsx @@ -1,4 +1,6 @@ +import { action } from 'mobx'; import { observer } from 'mobx-react'; +import { FunctionComponent, MouseEvent, ReactNode } from 'react'; import DefaultGroupExpanded from './DefaultGroupExpanded'; import { OnSelect, WithDndDragProps, ConnectDragSource, ConnectDropTarget } from '../../behavior'; import { BadgeLocation, GraphElement, isNode, LabelPosition, Node } from '../../types'; @@ -8,7 +10,7 @@ import DefaultGroupCollapsed from './DefaultGroupCollapsed'; interface DefaultGroupProps { /** Additional content added to the node */ - children?: React.ReactNode; + children?: ReactNode; /** Additional classes added to the group */ className?: string; /** The graph group node element to represent */ @@ -66,7 +68,7 @@ interface DefaultGroupProps { /** Callback when the group is collapsed */ onCollapseChange?: (group: Node, collapsed: boolean) => void; /** Shape of the collapsed group */ - getCollapsedShape?: (node: Node) => React.FunctionComponent; + getCollapsedShape?: (node: Node) => FunctionComponent; /** Shadow offset for the collapsed group */ collapsedShadowOffset?: number; /** Flag if the element selected. Part of WithSelectionProps */ @@ -80,7 +82,7 @@ interface DefaultGroupProps { /** A ref to add to the node for dropping. Part of WithDndDropProps */ dndDropRef?: ConnectDropTarget; /** Function to call to show a context menu for the node */ - onContextMenu?: (e: React.MouseEvent) => void; + onContextMenu?: (e: MouseEvent) => void; /** Flag indicating that the context menu for the node is currently open */ contextMenuOpen?: boolean; /** Hide context menu kebab for the group */ @@ -93,13 +95,15 @@ interface DefaultGroupProps { type DefaultGroupInnerProps = Omit & { element: Node }; -const DefaultGroupInner: React.FunctionComponent = observer( +const DefaultGroupInner: FunctionComponent = observer( ({ className, element, onCollapseChange, ...rest }) => { const handleCollapse = (group: Node, collapsed: boolean): void => { - if (collapsed && rest.collapsedWidth !== undefined && rest.collapsedHeight !== undefined) { - group.setDimensions(new Dimensions(rest.collapsedWidth, rest.collapsedHeight)); - } - group.setCollapsed(collapsed); + action(() => { + if (collapsed && rest.collapsedWidth !== undefined && rest.collapsedHeight !== undefined) { + group.setDimensions(new Dimensions(rest.collapsedWidth, rest.collapsedHeight)); + } + group.setCollapsed(collapsed); + })(); onCollapseChange && onCollapseChange(group, collapsed); }; @@ -112,7 +116,7 @@ const DefaultGroupInner: React.FunctionComponent = obser } ); -const DefaultGroup: React.FunctionComponent = ({ element, ...rest }: DefaultGroupProps) => { +const DefaultGroup: FunctionComponent = ({ element, ...rest }: DefaultGroupProps) => { if (!isNode(element)) { throw new Error('DefaultGroup must be used only on Node elements'); } diff --git a/packages/module/src/css/topology-components.css b/packages/module/src/css/topology-components.css index c1c79f84..fee61d6d 100644 --- a/packages/module/src/css/topology-components.css +++ b/packages/module/src/css/topology-components.css @@ -776,6 +776,14 @@ cursor: pointer; } +/* Opposite-direction arrow on a selected bidirectional aggregate bridge — keep visible, not highlighted. */ +.pf-topology__edge.pf-m-selected .pf-topology-connector-arrow.pf-m-muted, +.pf-topology__edge.pf-m-selected.pf-m-hover .pf-topology-connector-arrow.pf-m-muted { + fill: var(--edge--fill); + stroke: var(--edge--stroke); + stroke-width: var(--edge--stroke-width); +} + .pf-topology__edge__tag__background { fill: var(--pf-topology__edge__tag__background--Fill); stroke-width: 0; diff --git a/packages/module/src/utils/__tests__/createAggregateEdges.spec.ts b/packages/module/src/utils/__tests__/createAggregateEdges.spec.ts new file mode 100644 index 00000000..862b9527 --- /dev/null +++ b/packages/module/src/utils/__tests__/createAggregateEdges.spec.ts @@ -0,0 +1,391 @@ +import { createAggregateEdges, AggregateEdgesOptions } from '../createAggregateEdges'; +import { EdgeModel, NodeModel } from '../../types'; + +const node = (id: string, extras: Partial = {}): NodeModel => ({ + id, + type: 'node', + ...extras +}); + +const group = (id: string, children: string[], extras: Partial = {}): NodeModel => ({ + id, + type: 'group', + group: true, + children, + ...extras +}); + +const edge = (source: string, target: string, id?: string): EdgeModel => ({ + id: id || `${source}_${target}`, + type: 'edge', + source, + target +}); + +const aggregate = (edges: EdgeModel[], nodes: NodeModel[], options?: AggregateEdgesOptions) => + createAggregateEdges('aggregate-edge', edges, nodes, options); + +const visibleEdges = (result: EdgeModel[]) => result.filter((e) => e.visible !== false); +const aggregates = (result: EdgeModel[]) => result.filter((e) => e.type === 'aggregate-edge'); +const byRole = (result: EdgeModel[], role: string) => aggregates(result).filter((e) => e.data?.role === role); + +describe('createAggregateEdges', () => { + describe('default / collapsedGroups', () => { + const nodes = [ + node('n1'), + node('n2'), + node('n3'), + node('n4'), + group('g1', ['n1', 'n2'], { collapsed: true }), + group('g2', ['n3', 'n4'], { collapsed: true }) + ]; + + it('leaves unremapped edges unchanged', () => { + const nodesOpen = [ + node('n1'), + node('n2'), + group('g1', ['n1'], { collapsed: false }), + group('g2', ['n2'], { collapsed: false }) + ]; + const edges = [edge('n1', 'n2')]; + const result = aggregate(edges, nodesOpen); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ id: 'n1_n2', visible: true }); + expect(aggregates(result)).toHaveLength(0); + }); + + it('does not create an aggregate for a single remapped edge (collapse anchors handle it)', () => { + const edges = [edge('n1', 'n3')]; + const result = aggregate(edges, nodes); + expect(aggregates(result)).toHaveLength(0); + expect(visibleEdges(result)).toHaveLength(1); + expect(result[0].visible).toBe(true); + }); + + it('aggregates parallel edges between the same collapsed groups', () => { + const edges = [edge('n1', 'n3'), edge('n2', 'n4')]; + const result = aggregate(edges, nodes); + const aggs = aggregates(result); + expect(aggs).toHaveLength(1); + expect(aggs[0]).toMatchObject({ + source: 'g1', + target: 'g2', + children: ['n1_n3', 'n2_n4'] + }); + expect(aggs[0].data.count).toBe(2); + expect(visibleEdges(result).map((e) => e.id)).toEqual(['aggregate_g1_g2']); + }); + + it('hides edges internal to a collapsed group', () => { + const edges = [edge('n1', 'n2')]; + const result = aggregate(edges, nodes); + expect(result[0].visible).toBe(false); + expect(aggregates(result)).toHaveLength(0); + }); + + it('marks aggregates bidirectional when directions differ', () => { + const edges = [edge('n1', 'n3'), edge('n4', 'n2')]; + const result = aggregate(edges, nodes); + const agg = aggregates(result)[0]; + expect(agg.data.bidirectional).toBe(true); + expect(agg.data.forwardEdgeIds).toEqual(['n1_n3']); + expect(agg.data.reverseEdgeIds).toEqual(['n4_n2']); + }); + + it('does not mark same-direction parallel edges as bidirectional', () => { + const edges = [edge('n1', 'n3'), edge('n2', 'n4')]; + const result = aggregate(edges, nodes); + const agg = aggregates(result)[0]; + expect(agg.data.bidirectional).toBe(false); + expect(agg.data.forwardEdgeIds).toEqual(['n1_n3', 'n2_n4']); + expect(agg.data.reverseEdgeIds).toEqual([]); + }); + + it('can disable collapsed aggregation', () => { + const edges = [edge('n1', 'n3'), edge('n2', 'n4')]; + const result = aggregate(edges, nodes, { collapsedGroups: false }); + expect(aggregates(result)).toHaveLength(0); + expect(visibleEdges(result)).toHaveLength(2); + }); + }); + + describe('groupEdges', () => { + /** + * Graph + * ├── Group A + * │ ├── n1, n2 + * │ └── SubA (n3) + * ├── Group B (n4, n5) + * ├── Group C + * │ └── SubC (n6) + * └── n7 (ungrouped) + */ + const nodes = [ + node('n1'), + node('n2'), + node('n3'), + node('n4'), + node('n5'), + node('n6'), + node('n7'), + group('SubA', ['n3']), + group('A', ['n1', 'n2', 'SubA']), + group('B', ['n4', 'n5']), + group('SubC', ['n6']), + group('C', ['SubC']) + ]; + + const opts: AggregateEdgesOptions = { collapsedGroups: false, groupEdges: true }; + + it('splits a cross-group edge into exit, bridge, and entry segments', () => { + const edges = [edge('n1', 'n4')]; + const result = aggregate(edges, nodes, opts); + const visible = visibleEdges(result); + + expect(result.find((e) => e.id === 'n1_n4')?.visible).toBe(false); + expect(byRole(result, 'exit')).toEqual([ + expect.objectContaining({ source: 'n1', target: 'A', data: expect.objectContaining({ role: 'exit' }) }) + ]); + expect(byRole(result, 'bridge')).toEqual([ + expect.objectContaining({ + source: 'A', + target: 'B', + data: expect.objectContaining({ role: 'bridge', count: 1 }) + }) + ]); + expect(byRole(result, 'entry')).toEqual([ + expect.objectContaining({ source: 'B', target: 'n4', data: expect.objectContaining({ role: 'entry' }) }) + ]); + expect(visible).toHaveLength(3); + }); + + it('merges bridges and shared stubs across parallel leaf edges', () => { + const edges = [edge('n1', 'n4'), edge('n2', 'n5'), edge('n1', 'n5')]; + const result = aggregate(edges, nodes, opts); + + expect(byRole(result, 'bridge')).toHaveLength(1); + expect(byRole(result, 'bridge')[0].data.count).toBe(3); + expect(byRole(result, 'bridge')[0].data.aggregatedEdgeIds).toEqual(['n1_n4', 'n2_n5', 'n1_n5']); + // Same direction only — must not look bidirectional just because leaf sources differ. + expect(byRole(result, 'bridge')[0].data.bidirectional).toBe(false); + expect(byRole(result, 'bridge')[0].data.forwardEdgeIds).toEqual(['n1_n4', 'n2_n5', 'n1_n5']); + expect(byRole(result, 'bridge')[0].data.reverseEdgeIds).toEqual([]); + + // Exits: n1→A (2 leafs), n2→A (1 leaf) + const exits = byRole(result, 'exit'); + expect(exits).toHaveLength(2); + expect(exits.find((e) => e.source === 'n1')?.data.count).toBe(2); + expect(exits.find((e) => e.source === 'n2')?.data.count).toBe(1); + + // Entries: B→n4, B→n5 + const entries = byRole(result, 'entry'); + expect(entries).toHaveLength(2); + expect(entries.find((e) => e.target === 'n4')?.data.count).toBe(1); + expect(entries.find((e) => e.target === 'n5')?.data.count).toBe(2); + }); + + it('marks group bridges bidirectional only when leaf directions oppose', () => { + const edges = [edge('n1', 'n4'), edge('n5', 'n2')]; + const result = aggregate(edges, nodes, opts); + const bridge = byRole(result, 'bridge')[0]; + expect(bridge.data.bidirectional).toBe(true); + expect(bridge.data.forwardEdgeIds).toEqual(['n1_n4']); + expect(bridge.data.reverseEdgeIds).toEqual(['n5_n2']); + }); + + it('keeps exit/entry stubs separate per bridge peer', () => { + // n1 → n4 (bridge A-B) and n1 → n6 (bridge A-SubC) must not share an exit stub. + const edges = [edge('n1', 'n4'), edge('n1', 'n6')]; + const result = aggregate(edges, nodes, opts); + const exitsFromN1 = byRole(result, 'exit').filter((e) => e.source === 'n1'); + expect(exitsFromN1).toHaveLength(2); + expect(new Set(exitsFromN1.map((e) => e.data.bridgeKey)).size).toBe(2); + expect(byRole(result, 'bridge')).toHaveLength(2); + }); + + it('leaves edges between sibling nodes in the same group unchanged', () => { + const edges = [edge('n1', 'n2')]; + const result = aggregate(edges, nodes, opts); + expect(aggregates(result)).toHaveLength(0); + expect(result[0]).toMatchObject({ id: 'n1_n2', visible: true, source: 'n1', target: 'n2' }); + }); + + it('aggregates ungrouped node to group contents (bridge + entry, no exit)', () => { + const edges = [edge('n7', 'n4'), edge('n7', 'n5')]; + const result = aggregate(edges, nodes, opts); + + expect(byRole(result, 'exit')).toHaveLength(0); + expect(byRole(result, 'bridge')).toEqual([ + expect.objectContaining({ source: 'n7', target: 'B', data: expect.objectContaining({ count: 2 }) }) + ]); + expect(byRole(result, 'entry')).toHaveLength(2); + }); + + it('links nested subgroup member to sibling via SubA → A bridge', () => { + // n3 in SubA under A; n1 direct child of A + const edges = [edge('n3', 'n1')]; + const result = aggregate(edges, nodes, opts); + + expect(byRole(result, 'exit')[0]).toMatchObject({ source: 'n3', target: 'SubA' }); + expect(byRole(result, 'bridge')[0]).toMatchObject({ source: 'SubA', target: 'A' }); + expect(byRole(result, 'entry')[0]).toMatchObject({ source: 'A', target: 'n1' }); + }); + + it('uses immediate parents for nested cross-group edges', () => { + // n3 (SubA) → n4 (B): exit n3→SubA, bridge SubA→B, entry B→n4 + const edges = [edge('n3', 'n4')]; + const result = aggregate(edges, nodes, opts); + + expect(byRole(result, 'exit')[0]).toMatchObject({ source: 'n3', target: 'SubA' }); + expect(byRole(result, 'bridge')[0]).toMatchObject({ source: 'SubA', target: 'B' }); + expect(byRole(result, 'entry')[0]).toMatchObject({ source: 'B', target: 'n4' }); + }); + + it('aggregates subgroup ↔ subgroup across different parents via immediate parents', () => { + const edges = [edge('n3', 'n6')]; + const result = aggregate(edges, nodes, opts); + + expect(byRole(result, 'bridge')[0]).toMatchObject({ source: 'SubA', target: 'SubC' }); + expect(byRole(result, 'exit')[0]).toMatchObject({ source: 'n3', target: 'SubA' }); + expect(byRole(result, 'entry')[0]).toMatchObject({ source: 'SubC', target: 'n6' }); + }); + + it('hides edges from a node to its ancestor group', () => { + const edges = [edge('n3', 'A')]; + const result = aggregate(edges, nodes, opts); + expect(result.find((e) => e.id === 'n3_A')?.visible).toBe(false); + expect(aggregates(result)).toHaveLength(0); + }); + + it('keeps a direct edge from an ungrouped node to a top-level group', () => { + // Both ends sit at graph level — already the desired node→group link. + const edges = [edge('n7', 'B')]; + const result = aggregate(edges, nodes, opts); + expect(aggregates(result)).toHaveLength(0); + expect(result[0]).toMatchObject({ id: 'n7_B', visible: true, source: 'n7', target: 'B' }); + }); + + it('treats a nested group id as a bridge terminus (no entry past the group)', () => { + const edges = [edge('n7', 'SubC')]; + const result = aggregate(edges, nodes, opts); + + expect(byRole(result, 'exit')).toHaveLength(0); + expect(byRole(result, 'entry')).toHaveLength(0); + expect(byRole(result, 'bridge')).toEqual([ + expect.objectContaining({ + source: 'n7', + target: 'SubC', + data: expect.objectContaining({ role: 'bridge' }) + }) + ]); + }); + + it('does not remap edges between ungrouped nodes', () => { + const nodesWithExtra = [...nodes, node('n8')]; + const edges = [edge('n7', 'n8')]; + const result = aggregate(edges, nodesWithExtra, opts); + expect(aggregates(result)).toHaveLength(0); + expect(result[0].visible).toBe(true); + }); + + it('stores bridgeId on stubs for O(1) bridge lookup', () => { + const edges = [edge('n1', 'n4')]; + const result = aggregate(edges, nodes, opts); + const exit = byRole(result, 'exit')[0]; + const bridge = byRole(result, 'bridge')[0]; + const entry = byRole(result, 'entry')[0]; + + expect(exit.data.bridgeId).toBe(bridge.id); + expect(entry.data.bridgeId).toBe(bridge.id); + expect(bridge.data.bridgeId).toBeUndefined(); + }); + + it('places a leaf label on the bridge so multi-part paths keep one label', () => { + const edges = [{ ...edge('n1', 'n4'), label: 'traffic' }]; + const result = aggregate(edges, nodes, opts); + + const bridge = byRole(result, 'bridge')[0]; + expect(bridge.label).toBe('traffic'); + expect(bridge.data.labels).toEqual(['traffic']); + expect(byRole(result, 'exit')[0].label).toBeUndefined(); + expect(byRole(result, 'entry')[0].label).toBeUndefined(); + }); + + it('merges distinct leaf labels onto the shared bridge without dropping them for count', () => { + const edges = [ + { ...edge('n1', 'n4'), label: 'traffic' }, + { ...edge('n2', 'n5'), label: 'sync' }, + edge('n1', 'n5') + ]; + const result = aggregate(edges, nodes, opts); + const bridge = byRole(result, 'bridge')[0]; + + expect(bridge.data.count).toBe(3); + expect(bridge.label).toBe('traffic, sync'); + expect(bridge.data.labels).toEqual(['traffic', 'sync']); + }); + }); + + describe('collapsedGroups + groupEdges together', () => { + it('applies collapse then splits remaining cross-group path', () => { + const nodes = [ + node('n1'), + node('n2'), + node('n3'), + node('n4'), + group('SubB', ['n3', 'n4'], { collapsed: true }), + group('A', ['n1', 'n2']), + group('B', ['SubB']) + ]; + // After collapse, n3/n4 display as SubB (a group). Edge n1→SubB: + // exit n1→A, bridge A→SubB (group terminus, no entry into B). + const edges = [edge('n1', 'n3'), edge('n2', 'n4')]; + const result = aggregate(edges, nodes, { collapsedGroups: true, groupEdges: true }); + + expect(byRole(result, 'bridge')).toHaveLength(1); + expect(byRole(result, 'bridge')[0]).toMatchObject({ + source: 'A', + target: 'SubB', + data: expect.objectContaining({ count: 2 }) + }); + expect(byRole(result, 'exit')).toHaveLength(2); + expect(byRole(result, 'entry')).toHaveLength(0); + }); + + it('bridges parallel edges between two collapsed top-level groups', () => { + const nodes = [ + node('n1'), + node('n2'), + node('n3'), + node('n4'), + group('g1', ['n1', 'n2'], { collapsed: true }), + group('g2', ['n3', 'n4'], { collapsed: true }) + ]; + const edges = [edge('n1', 'n3'), edge('n2', 'n4')]; + const result = aggregate(edges, nodes, { collapsedGroups: true, groupEdges: true }); + + expect(byRole(result, 'exit')).toHaveLength(0); + expect(byRole(result, 'entry')).toHaveLength(0); + expect(byRole(result, 'bridge')).toEqual([ + expect.objectContaining({ + source: 'g1', + target: 'g2', + data: expect.objectContaining({ role: 'bridge', count: 2 }) + }) + ]); + expect(visibleEdges(result).map((e) => e.id)).toEqual([expect.stringMatching(/^aggregate_bridge_/)]); + }); + }); + + describe('edge cases', () => { + it('returns empty array for undefined edges', () => { + expect(createAggregateEdges('aggregate-edge', undefined, [])).toEqual([]); + }); + + it('returns empty array when nodes are missing', () => { + const edges = [edge('n1', 'n2')]; + expect(createAggregateEdges('aggregate-edge', edges, undefined)).toEqual([]); + expect(createAggregateEdges('aggregate-edge', edges, [])).toEqual([]); + }); + }); +}); diff --git a/packages/module/src/utils/createAggregateEdges.ts b/packages/module/src/utils/createAggregateEdges.ts index 2c015473..41dd9cce 100644 --- a/packages/module/src/utils/createAggregateEdges.ts +++ b/packages/module/src/utils/createAggregateEdges.ts @@ -1,92 +1,432 @@ import { EdgeModel, NodeModel } from '../types'; -const getNodeParent = (nodeId: string, nodes: NodeModel[]): NodeModel | undefined => - nodes.find((n) => (n.children ? n.children.includes(nodeId) : null)); +export type AggregateEdgeRole = 'exit' | 'bridge' | 'entry'; -const getDisplayedNodeForNode = (nodeId: string | undefined, nodes: NodeModel[] | undefined): string => { - if (!nodeId || !nodes) { - return ''; +export interface AggregateEdgesOptions { + /** + * Remap edge endpoints to their topmost collapsed ancestor and merge + * parallel remapped edges into aggregate edges. + * Defaults to `true`. + */ + collapsedGroups?: boolean; + /** + * Split cross-group edges into an exit stub (node → parent group), a bridge + * between parent groups (merged across leaf edges), and an entry stub + * (parent group → node). Defaults to `false`. + */ + groupEdges?: boolean; +} + +interface PathSegment { + source: string; + target: string; + role: AggregateEdgeRole; + /** When true, merge undirected (A→B same as B→A). Exit/entry stay directed. */ + undirected: boolean; + /** + * Stable key for the bridge this segment belongs to (sorted endpoint pair). + * Exit/entry stubs are scoped to a bridge so paths to different peers stay separate. + */ + bridgeKey: string; +} + +type ParentIndex = Map; + +const buildParentIndex = (nodes: NodeModel[]): ParentIndex => { + const parentOf: ParentIndex = new Map(); + nodes.forEach((n) => { + n.children?.forEach((childId) => { + parentOf.set(childId, n.id); + }); + }); + return parentOf; +}; + +const getAncestorChain = (nodeId: string, parentOf: ParentIndex): string[] => { + const chain: string[] = []; + let current: string | undefined = nodeId; + while (current) { + chain.push(current); + current = parentOf.get(current); } + return chain; +}; + +const isAncestorOf = (ancestorId: string, nodeId: string, parentOf: ParentIndex): boolean => + getAncestorChain(nodeId, parentOf).includes(ancestorId); - let displayedNode = nodes && nodes.find((n) => n.id === nodeId); - let parent = displayedNode ? getNodeParent(displayedNode.id, nodes) : null; - while (parent) { - if (parent.collapsed) { - displayedNode = parent; +const makeBridgeKey = (a: string, b: string): string => [a, b].sort((x, y) => x.localeCompare(y)).join('__'); + +/** + * Walk up to the topmost collapsed ancestor (or the node itself if none). + * Mirrors runtime `getTopCollapsedParent` against the declarative model. + */ +const getCollapsedDisplayedNode = (nodeId: string, parentOf: ParentIndex, collapsedIds: Set): string => { + let displayedNodeId = nodeId; + let parentId = parentOf.get(nodeId); + while (parentId) { + if (collapsedIds.has(parentId)) { + displayedNodeId = parentId; } - parent = getNodeParent(parent.id, nodes); + parentId = parentOf.get(parentId); } - return displayedNode ? displayedNode.id : ''; + return displayedNodeId; }; -const createAggregateEdges = ( - aggregateEdgeType: string, - edges: EdgeModel[] | undefined, - nodes: NodeModel[] | undefined -): EdgeModel[] => { - if (!edges) { +/** + * Decompose a cross-group leaf edge into exit / bridge / entry segments. + * + * Example (2-1 in Group2 → 3-1 in Subgroup3): + * exit: 2-1 → Group2 + * bridge: Group2 → Subgroup3 + * entry: Subgroup3 → 3-1 + * + * When an endpoint is already a group, that group is the bridge terminus + * (no exit/entry stub beyond it). + */ +const getGroupPathSegments = ( + sourceId: string, + targetId: string, + nodesById: Map, + parentOf: ParentIndex +): PathSegment[] | null => { + if (sourceId === targetId) { + return null; + } + + if (isAncestorOf(sourceId, targetId, parentOf) || isAncestorOf(targetId, sourceId, parentOf)) { + return null; // node ↔ ancestor: hide, no segments + } + + const sourceModel = nodesById.get(sourceId); + const targetModel = nodesById.get(targetId); + const sourceIsGroup = !!sourceModel?.group; + const targetIsGroup = !!targetModel?.group; + + const sourceParent = parentOf.get(sourceId); + const targetParent = parentOf.get(targetId); + + // Leaf siblings (same parent) or both graph-level non-group ends — keep the original edge. + // Group↔group at the same level (incl. two top-level / collapsed groups) still needs a bridge. + if (sourceParent === targetParent && !sourceIsGroup && !targetIsGroup) { return []; } - const aggregateEdges: EdgeModel[] = []; - return edges.reduce((newEdges: EdgeModel[], edge: EdgeModel) => { - const source = getDisplayedNodeForNode(edge.source, nodes); - const target = getDisplayedNodeForNode(edge.target, nodes); + // Groups are terminals: do not step past them into a parent stub. + const bridgeSource = sourceIsGroup ? sourceId : sourceParent || sourceId; + const bridgeTarget = targetIsGroup ? targetId : targetParent || targetId; + const bridgeKey = makeBridgeKey(bridgeSource, bridgeTarget); + const segments: PathSegment[] = []; + + if (!sourceIsGroup && sourceParent && sourceParent !== targetId) { + segments.push({ source: sourceId, target: sourceParent, role: 'exit', undirected: false, bridgeKey }); + } + + if (bridgeSource !== bridgeTarget) { + segments.push({ source: bridgeSource, target: bridgeTarget, role: 'bridge', undirected: true, bridgeKey }); + } + + if (!targetIsGroup && targetParent && targetParent !== sourceId) { + segments.push({ source: targetParent, target: targetId, role: 'entry', undirected: false, bridgeKey }); + } + + // Single bridge identical to the leaf with both ends at graph level (e.g. ungrouped → + // top-level group) — keep the leaf. Nested-group targets and group↔group still emit a + // bridge so parallel edges can merge (incl. collapsed top-level groups). + if ( + segments.length === 1 && + segments[0].role === 'bridge' && + segments[0].source === sourceId && + segments[0].target === targetId && + !sourceParent && + !targetParent && + !(sourceIsGroup && targetIsGroup) + ) { + return []; + } + + return segments; +}; + +const segmentId = (segment: PathSegment, legacyBridgeId = false): string => { + if (segment.role === 'bridge') { + if (legacyBridgeId) { + return `aggregate_${segment.source}_${segment.target}`; + } + return `aggregate_bridge_${segment.bridgeKey}`; + } + // Scope stubs to their bridge so paths to different peers do not share selection/geometry. + return `aggregate_${segment.role}_${segment.source}_${segment.target}_${segment.bridgeKey}`; +}; + +const segmentLookupKey = (aggregateEdgeType: string, segment: PathSegment): string => { + if (segment.undirected) { + return `${aggregateEdgeType}|${segment.role}|${segment.bridgeKey}`; + } + return `${aggregateEdgeType}|${segment.role}|${segment.bridgeKey}|${segment.source}->${segment.target}`; +}; + +/** Prefer the bridge for labels so multi-part paths show the label once along the path. */ +const isLabelBearer = (segments: PathSegment[], segment: PathSegment): boolean => { + const bearer = segments.find((s) => s.role === 'bridge') || segments[0]; + return ( + !!bearer && bearer.role === segment.role && bearer.source === segment.source && bearer.target === segment.target + ); +}; - // Make sure visible is defined so that changes override what could already be in the element +const applyLeafLabel = (aggregate: EdgeModel, leafLabel: string | undefined, carryLabel: boolean): void => { + if (!carryLabel || !leafLabel) { + return; + } + const labels: string[] = aggregate.data?.labels ? [...aggregate.data.labels] : []; + if (!labels.includes(leafLabel)) { + labels.push(leafLabel); + } + aggregate.data = { ...aggregate.data, labels }; + aggregate.label = labels.join(', '); +}; + +const createSegmentEdge = ( + aggregateEdgeType: string, + segment: PathSegment, + leafEdgeId: string, + legacyBridgeId = false, + leafLabel?: string, + carryLabel = false +): EdgeModel => { + const model: EdgeModel = { + id: segmentId(segment, legacyBridgeId), + type: aggregateEdgeType, + source: segment.source, + target: segment.target, + data: { + role: segment.role, + bridgeKey: segment.bridgeKey, + // O(1) bridge lookup for exit/entry stub snapping (avoids scanning all graph edges). + ...(segment.role !== 'bridge' ? { bridgeId: `aggregate_bridge_${segment.bridgeKey}` } : {}), + bidirectional: false, + count: 1, + aggregatedEdgeIds: [leafEdgeId], + // Bridge orientation follows the first leaf; later opposite leaves go in reverseEdgeIds. + ...(segment.role === 'bridge' ? { forwardEdgeIds: [leafEdgeId], reverseEdgeIds: [] as string[] } : {}) + } + }; + applyLeafLabel(model, leafLabel, carryLabel); + return model; +}; + +const mergeSegment = ( + existing: EdgeModel, + leafEdgeId: string, + segment: PathSegment, + leafLabel?: string, + carryLabel = false +): void => { + const ids: string[] = existing.data?.aggregatedEdgeIds ? [...existing.data.aggregatedEdgeIds] : []; + if (!ids.includes(leafEdgeId)) { + ids.push(leafEdgeId); + } + + let bidirectional = !!existing.data?.bidirectional; + let forwardEdgeIds: string[] | undefined = existing.data?.forwardEdgeIds; + let reverseEdgeIds: string[] | undefined = existing.data?.reverseEdgeIds; + + if (segment.role === 'bridge') { + const forward: string[] = forwardEdgeIds ? [...forwardEdgeIds] : []; + const reverse: string[] = reverseEdgeIds ? [...reverseEdgeIds] : []; + // Compare segment orientation to the stored bridge, not leafSource (leaf is never the group id). + const isReverse = existing.source !== segment.source; + if (isReverse) { + if (!reverse.includes(leafEdgeId)) { + reverse.push(leafEdgeId); + } + } else if (!forward.includes(leafEdgeId)) { + forward.push(leafEdgeId); + } + forwardEdgeIds = forward; + reverseEdgeIds = reverse; + bidirectional = reverse.length > 0; + } + + existing.data = { + ...existing.data, + role: segment.role, + bridgeKey: segment.bridgeKey, + count: ids.length, + aggregatedEdgeIds: ids, + bidirectional, + ...(segment.role === 'bridge' ? { forwardEdgeIds, reverseEdgeIds } : {}) + }; + applyLeafLabel(existing, leafLabel, carryLabel); +}; + +/** + * Collapse-only aggregation (historical behavior): remap endpoints to collapsed + * ancestors and create a single aggregate when 2+ parallel remapped edges exist. + */ +const aggregateByCollapsedGroups = ( + aggregateEdgeType: string, + edges: EdgeModel[], + parentOf: ParentIndex, + collapsedIds: Set +): EdgeModel[] => { + const segmentIndex = new Map(); + + return edges.reduce((newEdges: EdgeModel[], edge: EdgeModel) => { edge.visible = 'visible' in edge ? edge.visible : true; - if (source !== edge.source || target !== edge.target) { - if (source !== target) { - const existing = aggregateEdges.find( - (e) => (e.source === source || e.source === target) && (e.target === target || e.target === source) - ); - - if (existing) { - // At least one other edge, add this edge and add the aggregate edge to the edges - - // Add this edge to the aggregate and set it not visible - existing.children && existing.children.push(edge.id); - edge.visible = false; - - // Hide edges that are depicted by this aggregate edge - existing.children?.forEach((existingChild) => { - const updateEdge = newEdges.find((newEdge) => newEdge.id === existingChild); - if (updateEdge) { - updateEdge.visible = false; - } - }); - - // Update the aggregate edges bidirectional flag - existing.data.bidirectional = existing.data.bidirectional || existing.source !== edge.source; - - // Check if this edge has already been added - if ( - !newEdges.find( - (e) => (e.source === source || e.source === target) && (e.target === target || e.target === source) - ) - ) { - newEdges.push(existing); - } - } else { - const newEdge: EdgeModel = { - data: { bidirectional: false }, - children: [edge.id], - source, - target, - id: `aggregate_${source}_${target}`, - type: aggregateEdgeType - }; - aggregateEdges.push(newEdge); + const source = getCollapsedDisplayedNode(edge.source || '', parentOf, collapsedIds); + const target = getCollapsedDisplayedNode(edge.target || '', parentOf, collapsedIds); + const remapped = source !== edge.source || target !== edge.target; + + if (!remapped) { + newEdges.push(edge); + return newEdges; + } + + if (source === target) { + edge.visible = false; + newEdges.push(edge); + return newEdges; + } + + const segment: PathSegment = { + source, + target, + role: 'bridge', + undirected: true, + bridgeKey: makeBridgeKey(source, target) + }; + const key = segmentLookupKey(aggregateEdgeType, segment); + const existing = segmentIndex.get(key); + + if (existing) { + mergeSegment(existing, edge.id, segment, edge.label, true); + // Keep children for backward compatibility with prior collapse aggregation. + existing.children = existing.data.aggregatedEdgeIds; + edge.visible = false; + // Hide all leaf edges folded into this aggregate (first leaf stays visible until merge). + existing.data.aggregatedEdgeIds.forEach((id: string) => { + const leafEdge = newEdges.find((e) => e.id === id); + if (leafEdge) { + leafEdge.visible = false; } - } else { - // Hide edges that connect to a non-visible node to its ancestor - edge.visible = false; + }); + if (!newEdges.includes(existing)) { + newEdges.push(existing); } + } else { + // First remapped edge for this pair: keep the leaf visible (collapse anchors handle + // a single edge). Hold the aggregate in segmentIndex until a parallel edge merges. + const aggregate = createSegmentEdge(aggregateEdgeType, segment, edge.id, true, edge.label, true); + segmentIndex.set(key, aggregate); } + newEdges.push(edge); return newEdges; }, [] as EdgeModel[]); }; +/** + * Group-edge aggregation: split each cross-group leaf into exit / bridge / entry + * segments and merge bridges (and stubs for the same bridge) across leaf edges. + */ +const aggregateByGroupEdges = ( + aggregateEdgeType: string, + edges: EdgeModel[], + nodesById: Map, + parentOf: ParentIndex, + collapsedIds: Set, + collapsedGroups: boolean +): EdgeModel[] => { + const result: EdgeModel[] = []; + const segmentIndex = new Map(); + + edges.forEach((edge) => { + edge.visible = 'visible' in edge ? edge.visible : true; + + let source = edge.source || ''; + let target = edge.target || ''; + + if (collapsedGroups) { + source = getCollapsedDisplayedNode(source, parentOf, collapsedIds); + target = getCollapsedDisplayedNode(target, parentOf, collapsedIds); + } + + if (source === target) { + edge.visible = false; + result.push(edge); + return; + } + + const segments = getGroupPathSegments(source, target, nodesById, parentOf); + + if (segments === null) { + // Ancestor relationship — hide. + edge.visible = false; + result.push(edge); + return; + } + + if (segments.length === 0) { + // Same group siblings / both ungrouped — keep leaf as-is. + result.push(edge); + return; + } + + edge.visible = false; + result.push(edge); + + segments.forEach((segment) => { + const carryLabel = isLabelBearer(segments, segment); + const key = segmentLookupKey(aggregateEdgeType, segment); + const existing = segmentIndex.get(key); + if (existing) { + mergeSegment(existing, edge.id, segment, edge.label, carryLabel); + } else { + const created = createSegmentEdge(aggregateEdgeType, segment, edge.id, false, edge.label, carryLabel); + segmentIndex.set(key, created); + result.push(created); + } + }); + }); + + return result; +}; + +/** + * Create aggregate edges that replace sets of leaf edges with visible summary edges. + * + * @param aggregateEdgeType Type string for created aggregate edges (for component factories). + * @param edges Leaf edges to process. + * @param nodes Full node model (including groups) used to resolve parents and collapse. + * @param options Aggregation modes. Defaults: `{ collapsedGroups: true, groupEdges: false }`. + */ +const createAggregateEdges = ( + aggregateEdgeType: string, + edges: EdgeModel[] | undefined, + nodes: NodeModel[] | undefined, + options: AggregateEdgesOptions = {} +): EdgeModel[] => { + if (!edges?.length || !nodes?.length) { + return []; + } + + const collapsedGroups = options.collapsedGroups ?? true; + const groupEdges = options.groupEdges ?? false; + const parentOf = buildParentIndex(nodes); + const nodesById = new Map(nodes.map((n) => [n.id, n])); + const collapsedIds = new Set(nodes.filter((n) => n.collapsed).map((n) => n.id)); + + if (groupEdges) { + return aggregateByGroupEdges(aggregateEdgeType, edges, nodesById, parentOf, collapsedIds, collapsedGroups); + } + + if (collapsedGroups) { + return aggregateByCollapsedGroups(aggregateEdgeType, edges, parentOf, collapsedIds); + } + + return edges; +}; + export { createAggregateEdges };