Skip to content

feat(edges): aggregate cross-group edges into exit/bridge/entry paths - #320

Merged
jeff-phillips-18 merged 1 commit into
patternfly:mainfrom
jpinsonneau:feat/aggregate-group-edges
Aug 3, 2026
Merged

feat(edges): aggregate cross-group edges into exit/bridge/entry paths#320
jeff-phillips-18 merged 1 commit into
patternfly:mainfrom
jpinsonneau:feat/aggregate-group-edges

Conversation

@jpinsonneau

@jpinsonneau jpinsonneau commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Extends createAggregateEdges with a groupEdges option that splits cross-group leaf edges into exit → bridge → entry segments and merges parallel bridges between the same parent groups (addresses Topology - Aggregating edges between node groups #17 / PF-1994).
  • Preserves existing collapsedGroups aggregation behavior; adds leaf-id tracking (aggregatedEdgeIds, count, optional label carry onto the bridge).
  • Adds unit coverage and an Aggregate Edges demo (selection by leaf path, custom labels vs summed metric tags).

Test plan

  • yarn test packages/module/src/utils/__tests__/createAggregateEdges.spec.ts
  • Run demo app → Aggregate Edges
    • Toggle Aggregate edges between groups: cross-group clutter collapses to shared bridges with stubs
    • Click an exit stub (e.g. 2-3): selects that leaf’s path only, not sibling leaves on the same bridge
    • Click the bridge: selects all stubs for leaves on that bridge
    • Collapse Group 2 & Subgroup 3: remapping + aggregation still works
    • Custom edge labels appear on the bridge without being replaced by merge count
    • Metric tags (summed Bps) show a single summed rate on the bridge
  • Collapsible Groups demo still behaves as before

Made with Cursor

@jeff-phillips-18 jeff-phillips-18 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @jpinsonneau. Looking real good. A few suggestions.

Comment thread packages/demo-app-ts/src/demos/AggregateEdges.tsx Outdated
Comment thread packages/demo-app-ts/src/demos/AggregateEdges.tsx Outdated
Comment thread packages/demo-app-ts/src/demos/AggregateEdges.tsx Outdated
Comment thread packages/demo-app-ts/src/demos/AggregateEdges.tsx Outdated
@jpinsonneau

Copy link
Copy Markdown
Collaborator Author

Thanks for the feedback @jeff-phillips-18 🥳

Split demo into packages/demo-app-ts/src/demos/aggregateEdges/:

  • AggregateEdges.tsx - main demo
  • AggregateEdge.tsx - exit/bridge/entry rendering + selection
  • LabeledDefaultEdge.tsx - leaf edges with labels/tags
  • AggregateGroup.tsx - DefaultGroup with built-in collapse
  • model.ts, DemoContext.tsx

Replaced DemoDefaultGroup + “Collapse Group 2 & Subgroup 3” checkbox with collapsible DefaultGroup; collapse rebuilds aggregates from leaf edges via context

@jeff-phillips-18

Copy link
Copy Markdown
Member

@jpinsonneau I'm seeing a couple of issues:

installHook.js:1 [MobX] Since strict-mode is enabled, changing (observed) observable values without using an action is not allowed. Tried to modify: BaseNode@96.collapsed

BaseElement.ts:89 Uncaught Error: GraphElement with ID 'aggregate_entry_Group 2_21_Group 1__Group 2' has no controller.

For the first, it looks like an existing issue in DefaultGroupInner. The handleCollapse function should be wrapped in an action.

The second issue looks to be due to handling the collapsed state in the view. I don't think that is needed as it is held by by the collapsed value in the NodeModel.

However, after making those changes, I am seeing an odd edge arrow after collapsing:

image

@jpinsonneau

Copy link
Copy Markdown
Collaborator Author

Thanks @jeff-phillips-18 — addressed in the latest commit:

  1. MobX strict modeDefaultGroup handleCollapse now wraps setDimensions / setCollapsed in action().
  2. No controller / view collapsed state — removed the React collapsedIds mirror. Collapse stays on the Node; onCollapseChange only rebuilds aggregates by reading live isCollapsed() from the controller.
  3. Odd arrows after collapse — reused bridge/stub edges were keeping setStartPoint/setEndPoint overrides from the expanded hull. Those are cleared on rebuild and when bounds change so terminals re-anchor to the collapsed shape.

@jpinsonneau
jpinsonneau force-pushed the feat/aggregate-group-edges branch 2 times, most recently from 4879125 to e4dd81a Compare July 27, 2026 14:20
Comment thread packages/demo-app-ts/src/demos/aggregateEdges/AggregateEdges.tsx
@jeff-phillips-18

Copy link
Copy Markdown
Member

When I select the edge from 2-1 that goes to 3-1 and 3-2, it highlights the aggregated edges showing the path from 2-2 to both 3-1 and 3-2 connected first to subgroup 3. But, it is highlighting the aggregated edge between Group 2 and Subgroup 3 which is bidirectional so the flow looks like it is coming back to Group 2 but it really isn't. That arrow head should not be highlighted, it makes it hard to understand the correct flow:

image

You can see that there is no flow back to 2-2 from Subgroup 3:

image

@jpinsonneau

Copy link
Copy Markdown
Collaborator Author

When I select the edge from 2-1 that goes to 3-1 and 3-2, it highlights the aggregated edges showing the path from 2-2 to both 3-1 and 3-2 connected first to subgroup 3. But, it is highlighting the aggregated edge between Group 2 and Subgroup 3 which is bidirectional so the flow looks like it is coming back to Group 2 but it really isn't. That arrow head should not be highlighted, it makes it hard to understand the correct flow:

Good catch @jeff-phillips-18

I have fixed that case in 4da96ea

image image image

@jeff-phillips-18

Copy link
Copy Markdown
Member

These new edges created with roles will rely on the application creating an edge component very much like the AggregateEdge component in the demo. I think we should create a DefaultAggregatedEdge component in the toolkit that handles much of what AggregateEdge is doing (keeping out the demo related things like useAggregateEdgesDemo and its values.

I'm still not exactly sure what the snapGeneration is doing and I'm not sure how that could be handled by the DefaultAggregatedEdge component.

@jpinsonneau

Copy link
Copy Markdown
Collaborator Author

These new edges created with roles will rely on the application creating an edge component very much like the AggregateEdge component in the demo. I think we should create a DefaultAggregatedEdge component in the toolkit that handles much of what AggregateEdge is doing (keeping out the demo related things like useAggregateEdgesDemo and its values.

Sure, added the DefaultAggregatedEdge and an util file in 7f38f8d

I'm still not exactly sure what the snapGeneration is doing and I'm not sure how that could be handled by the DefaultAggregatedEdge component.

Aggregate edges pin endpoints with setStartPoint / setEndPoint. After animation (such in Cola) finishes or after collapse, those pins are often wrong, and bounds may not change enough for the snap effect to re-run.

  • Layout end: handled inside DefaultAggregatedEdge via GRAPH_LAYOUT_END_EVENT.
  • Collapse / model rebuild: app clears stale endpoints, then bumps snapGeneration so the effect force-resnaps (threshold 0). Demo still does that in onCollapseChange.

Comment thread packages/module/src/components/edges/DefaultAggregatedEdge.tsx Outdated
Comment thread packages/demo-app-ts/src/components/DemoDefaultGroup.tsx
Comment thread packages/demo-app-ts/src/demos/aggregateEdges/AggregateEdges.tsx Outdated
Comment thread packages/module/src/components/edges/DefaultAggregatedEdge.tsx Outdated
Comment thread packages/module/src/components/groups/DefaultGroup.tsx Outdated
Comment thread packages/module/src/components/edges/DefaultAggregatedEdge.tsx Outdated
Comment thread packages/module/src/components/edges/DefaultAggregatedEdge.tsx

@jeff-phillips-18 jeff-phillips-18 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @jpinsonneau. LGTM!

@jpinsonneau

Copy link
Copy Markdown
Collaborator Author

Thanks @jpinsonneau. LGTM!

Thanks for your quick feedback ! I appreciate 👍

Comment on lines +364 to +366
action(() => {
state[SELECTION_STATE] = selectedIds;
})();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this necessary? We shouldn't modify the controller's state directly and the following fireEvent will update the state correctly.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, let me fix that

},
forceSnap ? 0 : AGGREGATE_HULL_SETTLE_MS
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jpinsonneau Could you explain these two timer functions? Why do we need both? It looks like the timeout function is supposed to happen after the requestAnimationFrame function but on forceSnap it will occur before. Is that purposeful?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While geometry is moving we use two phases: requestAnimationFrame for a cheap approx snap (keeps stubs attached during Cola ticks), then a settle timeout (~100ms) for a precise hull snap. On forceSnap (layout end / collapse) that delay-0 race was real.

I'll skip approx and run one precise snap on requestAnimationFrame only.

Comment on lines +143 to +149
if (!edge.hasController()) {
return;
}
const plan = computeSnapPlan(edge, role, false);
if (plan) {
applySnapPlan(edge, plan, moveThreshold, true);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is largely repeated below with the only change being the threshold passed to applySnapPlan. We could have one utility function that does this and just takes the edge, role, and threshold. I don't see applySnapPlan ever being called with the clearUnset parameter set to false.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure let me create a function and drop cleanUnser

Comment on lines +286 to +287
start?: XY;
end?: XY;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
start?: XY;
end?: XY;
start: XY | null;
end: XY | null;

Comment on lines +292 to +293
const startFixed = plan.start != null;
const endFixed = plan.end != null;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const startFixed = plan.start != null;
const endFixed = plan.end != null;
const startFixed = plan.start !== null;
const endFixed = plan.end !== null;

return;
}
const startMoved = startFixed
? significantlyMoved(edge.getStartPoint(), plan.start!.x, plan.start!.y, threshold)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
? significantlyMoved(edge.getStartPoint(), plan.start!.x, plan.start!.y, threshold)
? significantlyMoved(edge.getStartPoint(), plan.start.x, plan.start.y, threshold)

Remove all non-null assertions.

if (!peer) {
return undefined;
}
const plan: SnapPlan = {};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const plan: SnapPlan = {};
const plan: SnapPlan = {
start: null,
end: null
};

Comment on lines +400 to +405
if (!edges?.length) {
return [];
}
if (!nodes?.length) {
return edges;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (!edges?.length) {
return [];
}
if (!nodes?.length) {
return edges;
}
if (!edges?.length || !nodes?.length) {
return [];
}

If there are no nodes, we should have no edges.

});
vis.fromModel(
getModel({
groupEdges: true,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
groupEdges: true,
groupEdges: false,

Should match the initial demo context value

@jeff-phillips-18

jeff-phillips-18 commented Jul 29, 2026

Copy link
Copy Markdown
Member

@jpinsonneau Sorry, went through it again and found some issues I missed before. Also, could you squash all the previous commits?

@jpinsonneau
jpinsonneau force-pushed the feat/aggregate-group-edges branch from 321ab9f to 517e755 Compare July 30, 2026 08:19
@jpinsonneau

Copy link
Copy Markdown
Collaborator Author

@jpinsonneau Sorry, went through it again and found some issues I missed before. Also, could you squash all the previous commits?

Addressed the changes and squash into a single commit

} else {
const aggregate = createSegmentEdge(aggregateEdgeType, segment, edge.id, true, edge.label, true);
aggregate.children = [edge.id];
pendingAggregates.push(aggregate);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see this being used anywhere

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Closes patternfly#17

Co-authored-by: Cursor <cursoragent@cursor.com>
@jpinsonneau
jpinsonneau force-pushed the feat/aggregate-group-edges branch from 517e755 to bf8270c Compare July 31, 2026 09:29
@jeff-phillips-18
jeff-phillips-18 merged commit 74d7dab into patternfly:main Aug 3, 2026
9 checks passed
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 6.6.0-prerelease.2 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants