fix: recursive compound nesting, fixed node width, zoom crash
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:
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user