fix: recursive compound nesting, fixed node width, zoom crash
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m0s
CI / docker (push) Successful in 52s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 36s

ELK renderer:
- Add EIP_WHEN, EIP_OTHERWISE, DO_CATCH, DO_FINALLY to COMPOUND_TYPES
  so branch body processors nest inside their containers
- Rewrite node creation and result extraction as recursive methods
  to support compound-inside-compound (CHOICE → WHEN → processors)
- Use fixed NODE_WIDTH=160 for leaf nodes instead of variable width

Frontend:
- Fix mousewheel crash: capture getBoundingClientRect() before
  setState updater (React nulls currentTarget after handler returns)
- Anchor fitToView to top-left instead of centering

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-27 14:26:35 +01:00
parent afcb7d3175
commit 20d1182259
2 changed files with 132 additions and 126 deletions

View File

@@ -45,6 +45,7 @@ public class ElkDiagramRenderer implements DiagramRenderer {
private static final int PADDING = 20;
private static final int NODE_HEIGHT = 40;
private static final int NODE_WIDTH = 160;
private static final int MIN_NODE_WIDTH = 80;
private static final int CHAR_WIDTH = 8;
private static final int LABEL_PADDING = 32;
@@ -97,8 +98,10 @@ public class ElkDiagramRenderer implements DiagramRenderer {
/** NodeTypes that act as compound containers with children. */
private static final Set<NodeType> COMPOUND_TYPES = EnumSet.of(
NodeType.EIP_CHOICE, NodeType.EIP_SPLIT, NodeType.TRY_CATCH,
NodeType.DO_TRY, NodeType.EIP_LOOP, NodeType.EIP_MULTICAST,
NodeType.EIP_CHOICE, NodeType.EIP_WHEN, NodeType.EIP_OTHERWISE,
NodeType.EIP_SPLIT, NodeType.TRY_CATCH,
NodeType.DO_TRY, NodeType.DO_CATCH, NodeType.DO_FINALLY,
NodeType.EIP_LOOP, NodeType.EIP_MULTICAST,
NodeType.EIP_AGGREGATE, NodeType.ON_EXCEPTION, NodeType.ERROR_HANDLER
);
@@ -178,78 +181,29 @@ public class ElkDiagramRenderer implements DiagramRenderer {
rootNode.setProperty(CoreOptions.SPACING_EDGE_NODE, EDGE_SPACING);
rootNode.setProperty(CoreOptions.HIERARCHY_HANDLING, HierarchyHandling.INCLUDE_CHILDREN);
// Build index of RouteNodes
// Build index of all RouteNodes (flat list from graph + recursive children)
Map<String, RouteNode> routeNodeMap = new HashMap<>();
if (graph.getNodes() != null) {
for (RouteNode rn : graph.getNodes()) {
routeNodeMap.put(rn.getId(), rn);
indexNodeRecursive(rn, routeNodeMap);
}
}
// Identify compound node IDs and their children
Set<String> compoundNodeIds = new HashSet<>();
Map<String, String> childToParent = new HashMap<>();
for (RouteNode rn : routeNodeMap.values()) {
if (rn.getType() != null && COMPOUND_TYPES.contains(rn.getType())
&& rn.getChildren() != null && !rn.getChildren().isEmpty()) {
compoundNodeIds.add(rn.getId());
for (RouteNode child : rn.getChildren()) {
childToParent.put(child.getId(), rn.getId());
}
}
}
// Track which nodes are children of a compound (at any depth)
Set<String> childNodeIds = new HashSet<>();
// Create ELK nodes
// Create ELK nodes recursively — compounds contain their children
Map<String, ElkNode> elkNodeMap = new HashMap<>();
Map<String, Color> nodeColors = new HashMap<>();
Set<String> compoundNodeIds = new HashSet<>();
// First, create compound (parent) nodes
for (String compoundId : compoundNodeIds) {
RouteNode rn = routeNodeMap.get(compoundId);
ElkNode elkCompound = factory.createElkNode();
elkCompound.setIdentifier(rn.getId());
elkCompound.setParent(rootNode);
// Compound nodes are larger initially -- ELK will resize
elkCompound.setWidth(200);
elkCompound.setHeight(100);
// Set properties for compound layout
elkCompound.setProperty(CoreOptions.ALGORITHM, "org.eclipse.elk.layered");
elkCompound.setProperty(CoreOptions.DIRECTION, Direction.DOWN);
elkCompound.setProperty(CoreOptions.SPACING_NODE_NODE, NODE_SPACING * 0.5);
elkCompound.setProperty(CoreOptions.SPACING_EDGE_NODE, EDGE_SPACING * 0.5);
elkCompound.setProperty(CoreOptions.PADDING,
new org.eclipse.elk.core.math.ElkPadding(COMPOUND_TOP_PADDING,
COMPOUND_SIDE_PADDING, COMPOUND_SIDE_PADDING, COMPOUND_SIDE_PADDING));
elkNodeMap.put(rn.getId(), elkCompound);
nodeColors.put(rn.getId(), colorForType(rn.getType()));
// Create child nodes inside compound
for (RouteNode child : rn.getChildren()) {
ElkNode elkChild = factory.createElkNode();
elkChild.setIdentifier(child.getId());
elkChild.setParent(elkCompound);
int w = Math.max(MIN_NODE_WIDTH, (child.getLabel() != null ? child.getLabel().length() : 0) * CHAR_WIDTH + LABEL_PADDING);
elkChild.setWidth(w);
elkChild.setHeight(NODE_HEIGHT);
elkNodeMap.put(child.getId(), elkChild);
nodeColors.put(child.getId(), colorForType(child.getType()));
}
}
// Then, create non-compound, non-child nodes
for (RouteNode rn : routeNodeMap.values()) {
if (!elkNodeMap.containsKey(rn.getId())) {
ElkNode elkNode = factory.createElkNode();
elkNode.setIdentifier(rn.getId());
elkNode.setParent(rootNode);
int w = Math.max(MIN_NODE_WIDTH, (rn.getLabel() != null ? rn.getLabel().length() : 0) * CHAR_WIDTH + LABEL_PADDING);
elkNode.setWidth(w);
elkNode.setHeight(NODE_HEIGHT);
elkNodeMap.put(rn.getId(), elkNode);
nodeColors.put(rn.getId(), colorForType(rn.getType()));
// Process top-level nodes from the graph
if (graph.getNodes() != null) {
for (RouteNode rn : graph.getNodes()) {
if (!elkNodeMap.containsKey(rn.getId())) {
createElkNodeRecursive(rn, rootNode, factory, elkNodeMap, nodeColors,
compoundNodeIds, childNodeIds);
}
}
}
@@ -276,64 +230,21 @@ public class ElkDiagramRenderer implements DiagramRenderer {
RecursiveGraphLayoutEngine engine = new RecursiveGraphLayoutEngine();
engine.layout(rootNode, new BasicProgressMonitor());
// Extract results
// Extract results — only top-level nodes (children collected recursively)
List<PositionedNode> positionedNodes = new ArrayList<>();
Map<String, CompoundInfo> compoundInfos = new HashMap<>();
for (RouteNode rn : routeNodeMap.values()) {
if (childToParent.containsKey(rn.getId())) {
// Skip children -- they are collected under their parent
continue;
}
ElkNode elkNode = elkNodeMap.get(rn.getId());
if (elkNode == null) continue;
if (compoundNodeIds.contains(rn.getId())) {
// Compound node: collect children
List<PositionedNode> children = new ArrayList<>();
if (rn.getChildren() != null) {
for (RouteNode child : rn.getChildren()) {
ElkNode childElk = elkNodeMap.get(child.getId());
if (childElk != null) {
children.add(new PositionedNode(
child.getId(),
child.getLabel() != null ? child.getLabel() : "",
child.getType() != null ? child.getType().name() : "UNKNOWN",
elkNode.getX() + childElk.getX(),
elkNode.getY() + childElk.getY(),
childElk.getWidth(),
childElk.getHeight(),
List.of()
));
}
}
if (graph.getNodes() != null) {
for (RouteNode rn : graph.getNodes()) {
if (childNodeIds.contains(rn.getId())) {
// Skip — collected under its parent compound
continue;
}
ElkNode elkNode = elkNodeMap.get(rn.getId());
if (elkNode == null) continue;
positionedNodes.add(new PositionedNode(
rn.getId(),
rn.getLabel() != null ? rn.getLabel() : "",
rn.getType() != null ? rn.getType().name() : "UNKNOWN",
elkNode.getX(),
elkNode.getY(),
elkNode.getWidth(),
elkNode.getHeight(),
children
));
compoundInfos.put(rn.getId(), new CompoundInfo(
rn.getId(), colorForType(rn.getType())));
} else {
positionedNodes.add(new PositionedNode(
rn.getId(),
rn.getLabel() != null ? rn.getLabel() : "",
rn.getType() != null ? rn.getType().name() : "UNKNOWN",
elkNode.getX(),
elkNode.getY(),
elkNode.getWidth(),
elkNode.getHeight(),
List.of()
));
positionedNodes.add(extractPositionedNode(rn, elkNode, elkNodeMap,
compoundNodeIds, compoundInfos, rootNode));
}
}
@@ -487,6 +398,98 @@ public class ElkDiagramRenderer implements DiagramRenderer {
}
}
// ----------------------------------------------------------------
// Recursive node building
// ----------------------------------------------------------------
/** Index a RouteNode and all its descendants into the map. */
private void indexNodeRecursive(RouteNode node, Map<String, RouteNode> map) {
map.put(node.getId(), node);
if (node.getChildren() != null) {
for (RouteNode child : node.getChildren()) {
indexNodeRecursive(child, map);
}
}
}
/**
* Recursively create ELK nodes. Compound nodes become ELK containers
* with their children nested inside. Non-compound nodes become leaf nodes.
*/
private void createElkNodeRecursive(
RouteNode rn, ElkNode parentElk, ElkGraphFactory factory,
Map<String, ElkNode> elkNodeMap, Map<String, Color> nodeColors,
Set<String> compoundNodeIds, Set<String> childNodeIds) {
boolean isCompound = rn.getType() != null && COMPOUND_TYPES.contains(rn.getType())
&& rn.getChildren() != null && !rn.getChildren().isEmpty();
ElkNode elkNode = factory.createElkNode();
elkNode.setIdentifier(rn.getId());
elkNode.setParent(parentElk);
if (isCompound) {
compoundNodeIds.add(rn.getId());
elkNode.setWidth(200);
elkNode.setHeight(100);
elkNode.setProperty(CoreOptions.ALGORITHM, "org.eclipse.elk.layered");
elkNode.setProperty(CoreOptions.DIRECTION, Direction.DOWN);
elkNode.setProperty(CoreOptions.SPACING_NODE_NODE, NODE_SPACING * 0.5);
elkNode.setProperty(CoreOptions.SPACING_EDGE_NODE, EDGE_SPACING * 0.5);
elkNode.setProperty(CoreOptions.PADDING,
new org.eclipse.elk.core.math.ElkPadding(COMPOUND_TOP_PADDING,
COMPOUND_SIDE_PADDING, COMPOUND_SIDE_PADDING, COMPOUND_SIDE_PADDING));
// Recursively create children inside this compound
for (RouteNode child : rn.getChildren()) {
childNodeIds.add(child.getId());
createElkNodeRecursive(child, elkNode, factory, elkNodeMap, nodeColors,
compoundNodeIds, childNodeIds);
}
} else {
elkNode.setWidth(NODE_WIDTH);
elkNode.setHeight(NODE_HEIGHT);
}
elkNodeMap.put(rn.getId(), elkNode);
nodeColors.put(rn.getId(), colorForType(rn.getType()));
}
/**
* Recursively extract a PositionedNode from the ELK layout result.
* Compound nodes include their children with absolute coordinates.
*/
private PositionedNode extractPositionedNode(
RouteNode rn, ElkNode elkNode, Map<String, ElkNode> elkNodeMap,
Set<String> compoundNodeIds, Map<String, CompoundInfo> compoundInfos,
ElkNode rootNode) {
double absX = getAbsoluteX(elkNode, rootNode);
double absY = getAbsoluteY(elkNode, rootNode);
List<PositionedNode> children = List.of();
if (compoundNodeIds.contains(rn.getId()) && rn.getChildren() != null) {
children = new ArrayList<>();
for (RouteNode child : rn.getChildren()) {
ElkNode childElk = elkNodeMap.get(child.getId());
if (childElk != null) {
children.add(extractPositionedNode(child, childElk, elkNodeMap,
compoundNodeIds, compoundInfos, rootNode));
}
}
compoundInfos.put(rn.getId(), new CompoundInfo(rn.getId(), colorForType(rn.getType())));
}
return new PositionedNode(
rn.getId(),
rn.getLabel() != null ? rn.getLabel() : "",
rn.getType() != null ? rn.getType().name() : "UNKNOWN",
absX, absY,
elkNode.getWidth(), elkNode.getHeight(),
children
);
}
// ----------------------------------------------------------------
// ELK graph helpers
// ----------------------------------------------------------------
@@ -545,8 +548,8 @@ public class ElkDiagramRenderer implements DiagramRenderer {
List<PositionedNode> all = new ArrayList<>();
for (PositionedNode n : nodes) {
all.add(n);
if (n.children() != null) {
all.addAll(n.children());
if (n.children() != null && !n.children().isEmpty()) {
all.addAll(allNodes(n.children()));
}
}
return all;

View File

@@ -40,12 +40,16 @@ export function useZoomPan() {
const direction = e.deltaY < 0 ? 1 : -1;
const factor = 1 + direction * ZOOM_STEP;
// Capture rect and cursor position before entering setState updater,
// because React clears e.currentTarget after the event handler returns.
const rect = (e.currentTarget as SVGSVGElement).getBoundingClientRect();
const clientX = e.clientX;
const clientY = e.clientY;
setState(prev => {
const newScale = clampScale(prev.scale * factor);
// Zoom centered on cursor
const rect = (e.currentTarget as SVGSVGElement).getBoundingClientRect();
const cursorX = e.clientX - rect.left;
const cursorY = e.clientY - rect.top;
const cursorX = clientX - rect.left;
const cursorY = clientY - rect.top;
const scaleRatio = newScale / prev.scale;
const newTx = cursorX - scaleRatio * (cursorX - prev.translateX);
const newTy = cursorY - scaleRatio * (cursorY - prev.translateY);
@@ -127,9 +131,8 @@ export function useZoomPan() {
const scaleX = cw / contentWidth;
const scaleY = ch / contentHeight;
const newScale = clampScale(Math.min(scaleX, scaleY));
const tx = (container.clientWidth - contentWidth * newScale) / 2;
const ty = (container.clientHeight - contentHeight * newScale) / 2;
setState({ scale: newScale, translateX: tx, translateY: ty });
// Anchor to top-left with padding
setState({ scale: newScale, translateX: FIT_PADDING, translateY: FIT_PADDING });
},
[],
);