diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/diagram/ElkDiagramRenderer.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/diagram/ElkDiagramRenderer.java index b4a40615..3f5de05e 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/diagram/ElkDiagramRenderer.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/diagram/ElkDiagramRenderer.java @@ -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 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 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 compoundNodeIds = new HashSet<>(); - Map 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 childNodeIds = new HashSet<>(); - // Create ELK nodes + // Create ELK nodes recursively — compounds contain their children Map elkNodeMap = new HashMap<>(); Map nodeColors = new HashMap<>(); + Set 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 positionedNodes = new ArrayList<>(); Map 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 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 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 elkNodeMap, Map nodeColors, + Set compoundNodeIds, Set 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 elkNodeMap, + Set compoundNodeIds, Map compoundInfos, + ElkNode rootNode) { + + double absX = getAbsoluteX(elkNode, rootNode); + double absY = getAbsoluteY(elkNode, rootNode); + + List 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 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; diff --git a/ui/src/components/ProcessDiagram/useZoomPan.ts b/ui/src/components/ProcessDiagram/useZoomPan.ts index 6598177f..ddc3aba7 100644 --- a/ui/src/components/ProcessDiagram/useZoomPan.ts +++ b/ui/src/components/ProcessDiagram/useZoomPan.ts @@ -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 }); }, [], );