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 0126e429..89ae853e 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 @@ -9,12 +9,12 @@ import com.cameleer3.server.core.diagram.DiagramRenderer; import com.cameleer3.server.core.diagram.PositionedEdge; import com.cameleer3.server.core.diagram.PositionedNode; import org.eclipse.elk.alg.layered.options.LayeredMetaDataProvider; +import org.eclipse.elk.alg.layered.options.NodePlacementStrategy; import org.eclipse.elk.core.RecursiveGraphLayoutEngine; import org.eclipse.elk.core.options.CoreOptions; import org.eclipse.elk.core.options.Direction; import org.eclipse.elk.core.options.EdgeRouting; import org.eclipse.elk.core.options.HierarchyHandling; -import org.eclipse.elk.alg.layered.options.NodePlacementStrategy; import org.eclipse.elk.core.util.BasicProgressMonitor; import org.eclipse.elk.graph.ElkBendPoint; import org.eclipse.elk.graph.ElkEdge; @@ -40,17 +40,20 @@ import java.util.Set; /** * ELK + JFreeSVG implementation of {@link DiagramRenderer}. *

- * Uses Eclipse ELK layered algorithm for top-to-bottom layout computation + * Uses Eclipse ELK layered algorithm for layout computation * and JFreeSVG for SVG document generation with color-coded nodes. */ public class ElkDiagramRenderer implements DiagramRenderer { + // Register ELK provider once (not per-instance) + static { + org.eclipse.elk.core.data.LayoutMetaDataService.getInstance() + .registerLayoutMetaDataProviders(new LayeredMetaDataProvider()); + } + 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; private static final int COMPOUND_TOP_PADDING = 30; private static final int COMPOUND_SIDE_PADDING = 10; private static final int CORNER_RADIUS = 8; @@ -108,20 +111,11 @@ public class ElkDiagramRenderer implements DiagramRenderer { NodeType.ON_COMPLETION ); - /** Top-level handler types that are laid out in their own separate ELK graph - * to prevent them from affecting the main flow's node positioning. */ + /** Top-level handler types laid out in their own separate ELK graph. */ private static final Set HANDLER_SECTION_TYPES = EnumSet.of( NodeType.ON_EXCEPTION, NodeType.ERROR_HANDLER, NodeType.ON_COMPLETION ); - public ElkDiagramRenderer() { - // Ensure the layered algorithm meta data provider is registered. - // LayoutMetaDataService uses ServiceLoader, but explicit registration - // guarantees availability regardless of classpath ordering. - org.eclipse.elk.core.data.LayoutMetaDataService.getInstance() - .registerLayoutMetaDataProviders(new LayeredMetaDataProvider()); - } - @Override public String renderSvg(RouteGraph graph) { LayoutResult result = computeLayout(graph, Direction.DOWN); @@ -144,7 +138,17 @@ public class ElkDiagramRenderer implements DiagramRenderer { Font labelFont = new Font("SansSerif", Font.PLAIN, 12); g2.setFont(labelFont); - // Draw compound containers first (background) + // Collect IDs of nodes drawn inside compounds (to avoid double-drawing) + Set compoundChildIds = new HashSet<>(); + for (PositionedNode node : allNodes(layout.nodes())) { + if (result.compoundInfos.containsKey(node.id()) && node.children() != null) { + for (PositionedNode child : node.children()) { + collectAllIds(child, compoundChildIds); + } + } + } + + // Draw compound containers first (background + children inside) for (Map.Entry entry : result.compoundInfos.entrySet()) { CompoundInfo ci = entry.getValue(); PositionedNode pn = findNode(layout.nodes(), ci.nodeId); @@ -153,8 +157,9 @@ public class ElkDiagramRenderer implements DiagramRenderer { } } - // Draw leaf nodes + // Draw leaf nodes (skip compounds and their children — already drawn above) for (PositionedNode node : allNodes(layout.nodes())) { + if (compoundChildIds.contains(node.id())) continue; if (!result.compoundInfos.containsKey(node.id()) || node.children().isEmpty()) { drawNode(g2, node, result.nodeColors.getOrDefault(node.id(), PURPLE)); } @@ -181,7 +186,7 @@ public class ElkDiagramRenderer implements DiagramRenderer { private LayoutResult computeLayout(RouteGraph graph, Direction rootDirection) { ElkGraphFactory factory = ElkGraphFactory.eINSTANCE; - // Create root node + // Create root node for main flow ElkNode rootNode = factory.createElkNode(); rootNode.setIdentifier("root"); rootNode.setProperty(CoreOptions.ALGORITHM, "org.eclipse.elk.layered"); @@ -193,14 +198,6 @@ public class ElkDiagramRenderer implements DiagramRenderer { rootNode.setProperty(org.eclipse.elk.alg.layered.options.LayeredOptions.NODE_PLACEMENT_STRATEGY, NodePlacementStrategy.LINEAR_SEGMENTS); - // Build index of all RouteNodes (flat list from graph + recursive children) - Map routeNodeMap = new HashMap<>(); - if (graph.getNodes() != null) { - for (RouteNode rn : graph.getNodes()) { - indexNodeRecursive(rn, routeNodeMap); - } - } - // Track which nodes are children of a compound (at any depth) Set childNodeIds = new HashSet<>(); @@ -209,9 +206,7 @@ public class ElkDiagramRenderer implements DiagramRenderer { Map nodeColors = new HashMap<>(); Set compoundNodeIds = new HashSet<>(); - // Separate handler sections (ON_EXCEPTION, ON_COMPLETION, ERROR_HANDLER) - // from main flow nodes. Handler sections are laid out in their own ELK - // graphs to prevent them from affecting the main flow's Y positioning. + // Separate handler sections from main flow nodes List mainNodes = new ArrayList<>(); List handlerNodes = new ArrayList<>(); if (graph.getNodes() != null) { @@ -233,9 +228,7 @@ public class ElkDiagramRenderer implements DiagramRenderer { } } - // Process handler sections into their OWN separate ELK root graphs. - // This prevents them from affecting the main flow's Y positioning. - // Each handler gets its own independent layout. + // Process handler sections into their OWN separate ELK root graphs List handlerRoots = new ArrayList<>(); for (RouteNode rn : handlerNodes) { ElkNode handlerRoot = factory.createElkNode(); @@ -245,6 +238,7 @@ public class ElkDiagramRenderer implements DiagramRenderer { handlerRoot.setProperty(CoreOptions.SPACING_NODE_NODE, NODE_SPACING * 0.5); handlerRoot.setProperty(CoreOptions.SPACING_EDGE_NODE, EDGE_SPACING * 0.5); handlerRoot.setProperty(CoreOptions.HIERARCHY_HANDLING, HierarchyHandling.INCLUDE_CHILDREN); + handlerRoot.setProperty(CoreOptions.EDGE_ROUTING, EdgeRouting.POLYLINE); handlerRoot.setProperty(org.eclipse.elk.alg.layered.options.LayeredOptions.NODE_PLACEMENT_STRATEGY, NodePlacementStrategy.LINEAR_SEGMENTS); @@ -254,7 +248,6 @@ public class ElkDiagramRenderer implements DiagramRenderer { } // Create ELK edges — skip edges that cross between different ELK root graphs - // (e.g., main flow → handler section). These cannot be laid out by ELK. if (graph.getEdges() != null) { for (RouteEdge re : graph.getEdges()) { ElkNode sourceElk = elkNodeMap.get(re.getSource()); @@ -270,9 +263,7 @@ public class ElkDiagramRenderer implements DiagramRenderer { continue; } - // Determine the containing node for the edge ElkNode containingNode = findCommonParent(sourceElk, targetElk); - ElkEdge elkEdge = factory.createElkEdge(); elkEdge.setContainingNode(containingNode); elkEdge.getSources().add(sourceElk); @@ -280,46 +271,32 @@ public class ElkDiagramRenderer implements DiagramRenderer { } } - // Run layout — main flow + // Run layout RecursiveGraphLayoutEngine engine = new RecursiveGraphLayoutEngine(); engine.layout(rootNode, new BasicProgressMonitor()); - - // Debug: log root dimensions and children - System.out.println("[ELK DEBUG] rootNode: " + rootNode.getWidth() + "x" + rootNode.getHeight() - + " children=" + rootNode.getChildren().size()); - for (ElkNode child : rootNode.getChildren()) { - System.out.println("[ELK DEBUG] child " + child.getIdentifier() - + " x=" + child.getX() + " y=" + child.getY() - + " w=" + child.getWidth() + " h=" + child.getHeight()); - } - - // Run layout — each handler section independently for (ElkNode handlerRoot : handlerRoots) { engine.layout(handlerRoot, new BasicProgressMonitor()); } - // Extract results — only top-level nodes (children collected recursively) + // Extract positioned nodes List positionedNodes = new ArrayList<>(); Map compoundInfos = new HashMap<>(); 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; - // Use the correct root for coordinate calculation: - // handler nodes use their handler root, main flow uses rootNode ElkNode coordRoot = getElkRoot(elkNode); positionedNodes.add(extractPositionedNode(rn, elkNode, elkNodeMap, compoundNodeIds, compoundInfos, coordRoot)); } } - // Extract edges from main root + all handler roots + // Extract edges from main root + handler roots List positionedEdges = new ArrayList<>(); List allEdges = collectAllEdges(rootNode); for (ElkNode hr : handlerRoots) { @@ -329,7 +306,6 @@ public class ElkDiagramRenderer implements DiagramRenderer { String sourceId = elkEdge.getSources().isEmpty() ? "" : elkEdge.getSources().get(0).getIdentifier(); String targetId = elkEdge.getTargets().isEmpty() ? "" : elkEdge.getTargets().get(0).getIdentifier(); - // Determine which root this edge belongs to for coordinate calculation ElkNode edgeRoot = getElkRoot(elkEdge.getContainingNode()); List points = new ArrayList<>(); @@ -350,7 +326,6 @@ public class ElkDiagramRenderer implements DiagramRenderer { }); } - // Find label from original edge String label = ""; if (graph.getEdges() != null) { for (RouteEdge re : graph.getEdges()) { @@ -364,23 +339,24 @@ public class ElkDiagramRenderer implements DiagramRenderer { positionedEdges.add(new PositionedEdge(sourceId, targetId, label, points)); } - // Compute bounding box from actual positioned node coordinates - // (not rootNode dimensions, which ignore handler roots) + // Normalize: shift all nodes and edges so bounding box starts at (0, 0). + // ELK can place nodes at arbitrary positions within the root graph; + // normalizing ensures the output starts at the origin regardless. + normalizePositions(positionedNodes, positionedEdges); + + // Compute bounding box from all positioned nodes and edges double totalWidth = 0; double totalHeight = 0; - for (PositionedNode pn : positionedNodes) { + for (PositionedNode pn : allNodes(positionedNodes)) { double right = pn.x() + pn.width(); double bottom = pn.y() + pn.height(); if (right > totalWidth) totalWidth = right; if (bottom > totalHeight) totalHeight = bottom; - // Also check children bounds for compounds - if (pn.children() != null) { - for (PositionedNode child : pn.children()) { - double cr = pn.x() + child.x() + child.width(); - double cb = pn.y() + child.y() + child.height(); - if (cr > totalWidth) totalWidth = cr; - if (cb > totalHeight) totalHeight = cb; - } + } + for (PositionedEdge pe : positionedEdges) { + for (double[] pt : pe.points()) { + if (pt[0] > totalWidth) totalWidth = pt[0]; + if (pt[1] > totalHeight) totalHeight = pt[1]; } } @@ -388,6 +364,61 @@ public class ElkDiagramRenderer implements DiagramRenderer { return new LayoutResult(layout, nodeColors, compoundInfos); } + /** + * Shift all positioned nodes and edge points so the bounding box starts at (0, 0). + * This compensates for ELK placing nodes at non-zero origins within its root graph. + */ + private void normalizePositions(List nodes, List edges) { + // Find minimum x/y across all nodes (recursively including children) + double minX = Double.MAX_VALUE; + double minY = Double.MAX_VALUE; + for (PositionedNode pn : allNodes(nodes)) { + if (pn.x() < minX) minX = pn.x(); + if (pn.y() < minY) minY = pn.y(); + } + for (PositionedEdge pe : edges) { + for (double[] pt : pe.points()) { + if (pt[0] < minX) minX = pt[0]; + if (pt[1] < minY) minY = pt[1]; + } + } + + if (minX <= 0 && minY <= 0) return; // Already at or past origin + + // Shift nodes — PositionedNode is a record, so we must rebuild + double shiftX = minX > 0 ? minX : 0; + double shiftY = minY > 0 ? minY : 0; + + for (int i = 0; i < nodes.size(); i++) { + nodes.set(i, shiftNode(nodes.get(i), shiftX, shiftY)); + } + + // Shift edge points in-place + for (PositionedEdge pe : edges) { + for (double[] pt : pe.points()) { + pt[0] -= shiftX; + pt[1] -= shiftY; + } + } + } + + /** Recursively shift a PositionedNode and its children by (dx, dy). */ + private PositionedNode shiftNode(PositionedNode pn, double dx, double dy) { + List shiftedChildren = List.of(); + if (pn.children() != null && !pn.children().isEmpty()) { + shiftedChildren = new ArrayList<>(); + for (PositionedNode child : pn.children()) { + shiftedChildren.add(shiftNode(child, dx, dy)); + } + } + return new PositionedNode( + pn.id(), pn.label(), pn.type(), + pn.x() - dx, pn.y() - dy, + pn.width(), pn.height(), + shiftedChildren + ); + } + // ---------------------------------------------------------------- // SVG drawing helpers // ---------------------------------------------------------------- @@ -410,7 +441,7 @@ public class ElkDiagramRenderer implements DiagramRenderer { private void drawCompoundContainer(SVGGraphics2D g2, PositionedNode node, Color color) { // Semi-transparent background - Color bg = new Color(color.getRed(), color.getGreen(), color.getBlue(), 38); // ~15% alpha + Color bg = new Color(color.getRed(), color.getGreen(), color.getBlue(), 38); g2.setColor(bg); g2.fill(new RoundRectangle2D.Double( node.x(), node.y(), node.width(), node.height(), @@ -425,7 +456,6 @@ public class ElkDiagramRenderer implements DiagramRenderer { // Label at top g2.setColor(color); - FontMetrics fm = g2.getFontMetrics(); float labelX = (float) (node.x() + COMPOUND_SIDE_PADDING); float labelY = (float) (node.y() + 18); g2.drawString(node.label(), labelX, labelY); @@ -479,7 +509,7 @@ public class ElkDiagramRenderer implements DiagramRenderer { if (ENDPOINT_TYPES.contains(type)) return BLUE; if (PROCESSOR_TYPES.contains(type)) return GREEN; if (ERROR_TYPES.contains(type)) return RED; - if (EIP_TYPES.contains(type)) return EIP_TYPES.contains(type) ? PURPLE : PURPLE; + if (EIP_TYPES.contains(type)) return PURPLE; if (CROSS_ROUTE_TYPES.contains(type)) return CYAN; return PURPLE; } @@ -497,16 +527,6 @@ 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. @@ -535,7 +555,6 @@ public class ElkDiagramRenderer implements DiagramRenderer { 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, @@ -552,7 +571,7 @@ public class ElkDiagramRenderer implements DiagramRenderer { /** * Recursively extract a PositionedNode from the ELK layout result. - * Compound nodes include their children with absolute coordinates. + * All coordinates are absolute (relative to the ELK root). */ private PositionedNode extractPositionedNode( RouteNode rn, ElkNode elkNode, Map elkNodeMap, @@ -600,14 +619,12 @@ public class ElkDiagramRenderer implements DiagramRenderer { /** Proper lowest common ancestor of two ELK nodes. */ private ElkNode findCommonParent(ElkNode a, ElkNode b) { - // Collect all ancestors of 'a' (including a itself) Set ancestorsOfA = new HashSet<>(); ElkNode current = a; while (current != null) { ancestorsOfA.add(current); current = current.getParent(); } - // Walk up from 'b' until we find a common ancestor current = b; while (current != null) { if (ancestorsOfA.contains(current)) { @@ -615,7 +632,6 @@ public class ElkDiagramRenderer implements DiagramRenderer { } current = current.getParent(); } - // Fallback: root of 'a' return getElkRoot(a); } @@ -647,13 +663,19 @@ public class ElkDiagramRenderer implements DiagramRenderer { return edges; } + /** Recursively find a PositionedNode by ID. */ private PositionedNode findNode(List nodes, String id) { for (PositionedNode n : nodes) { if (n.id().equals(id)) return n; + if (n.children() != null) { + PositionedNode found = findNode(n.children(), id); + if (found != null) return found; + } } return null; } + /** Recursively flatten a PositionedNode tree. */ private List allNodes(List nodes) { List all = new ArrayList<>(); for (PositionedNode n : nodes) { @@ -665,6 +687,16 @@ public class ElkDiagramRenderer implements DiagramRenderer { return all; } + /** Recursively collect all node IDs from a tree. */ + private void collectAllIds(PositionedNode node, Set ids) { + ids.add(node.id()); + if (node.children() != null) { + for (PositionedNode child : node.children()) { + collectAllIds(child, ids); + } + } + } + // ---------------------------------------------------------------- // Internal data classes // ----------------------------------------------------------------