graphOpt = diagramRepository.findByContentHash(contentHash);
+ if (graphOpt.isEmpty()) {
+ return ResponseEntity.notFound().build();
+ }
+
+ RouteGraph graph = graphOpt.get();
+ String accept = request.getHeader("Accept");
+
+ // Return JSON only when the client explicitly requests application/json
+ // without also accepting everything (*/*). This means "application/json"
+ // must appear and wildcards must not dominate the preference.
+ if (accept != null && isJsonPreferred(accept)) {
+ DiagramLayout layout = diagramRenderer.layoutJson(graph);
+ return ResponseEntity.ok()
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(layout);
+ }
+
+ // Default to SVG for image/svg+xml, */* or no Accept header
+ String svg = diagramRenderer.renderSvg(graph);
+ return ResponseEntity.ok()
+ .contentType(SVG_MEDIA_TYPE)
+ .body(svg);
+ }
+
+ /**
+ * Determine if JSON is the explicitly preferred format.
+ *
+ * Returns true only when the first media type in the Accept header is
+ * "application/json". Clients sending broad Accept lists like
+ * "text/plain, application/json, */*" are treated as unspecific
+ * and receive the SVG default.
+ */
+ private boolean isJsonPreferred(String accept) {
+ String[] parts = accept.split(",");
+ if (parts.length == 0) return false;
+ String first = parts[0].trim().split(";")[0].trim();
+ return "application/json".equalsIgnoreCase(first);
+ }
+}
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
new file mode 100644
index 00000000..44fdb598
--- /dev/null
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/diagram/ElkDiagramRenderer.java
@@ -0,0 +1,560 @@
+package com.cameleer3.server.app.diagram;
+
+import com.cameleer3.common.graph.NodeType;
+import com.cameleer3.common.graph.RouteEdge;
+import com.cameleer3.common.graph.RouteGraph;
+import com.cameleer3.common.graph.RouteNode;
+import com.cameleer3.server.core.diagram.DiagramLayout;
+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.core.RecursiveGraphLayoutEngine;
+import org.eclipse.elk.core.options.CoreOptions;
+import org.eclipse.elk.core.options.Direction;
+import org.eclipse.elk.core.options.HierarchyHandling;
+import org.eclipse.elk.core.util.BasicProgressMonitor;
+import org.eclipse.elk.graph.ElkBendPoint;
+import org.eclipse.elk.graph.ElkEdge;
+import org.eclipse.elk.graph.ElkEdgeSection;
+import org.eclipse.elk.graph.ElkGraphFactory;
+import org.eclipse.elk.graph.ElkNode;
+import org.jfree.svg.SVGGraphics2D;
+
+import java.awt.BasicStroke;
+import java.awt.Color;
+import java.awt.Font;
+import java.awt.FontMetrics;
+import java.awt.geom.GeneralPath;
+import java.awt.geom.RoundRectangle2D;
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * ELK + JFreeSVG implementation of {@link DiagramRenderer}.
+ *
+ * Uses Eclipse ELK layered algorithm for top-to-bottom layout computation
+ * and JFreeSVG for SVG document generation with color-coded nodes.
+ */
+public class ElkDiagramRenderer implements DiagramRenderer {
+
+ private static final int PADDING = 20;
+ private static final int NODE_HEIGHT = 40;
+ 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;
+ private static final double NODE_SPACING = 40.0;
+ private static final double EDGE_SPACING = 20.0;
+
+ // Blue: endpoints
+ private static final Color BLUE = Color.decode("#3B82F6");
+ // Green: processors
+ private static final Color GREEN = Color.decode("#22C55E");
+ // Red: error handling
+ private static final Color RED = Color.decode("#EF4444");
+ // Purple: EIP patterns
+ private static final Color PURPLE = Color.decode("#A855F7");
+ // Cyan: cross-route
+ private static final Color CYAN = Color.decode("#06B6D4");
+ // Gray: edges
+ private static final Color EDGE_GRAY = Color.decode("#9CA3AF");
+
+ private static final Set ENDPOINT_TYPES = EnumSet.of(
+ NodeType.ENDPOINT, NodeType.TO, NodeType.TO_DYNAMIC, NodeType.DIRECT, NodeType.SEDA
+ );
+
+ private static final Set PROCESSOR_TYPES = EnumSet.of(
+ NodeType.PROCESSOR, NodeType.BEAN, NodeType.LOG,
+ NodeType.SET_HEADER, NodeType.SET_BODY, NodeType.TRANSFORM,
+ NodeType.MARSHAL, NodeType.UNMARSHAL
+ );
+
+ private static final Set ERROR_TYPES = EnumSet.of(
+ NodeType.ERROR_HANDLER, NodeType.ON_EXCEPTION, NodeType.TRY_CATCH,
+ NodeType.DO_TRY, NodeType.DO_CATCH, NodeType.DO_FINALLY
+ );
+
+ private static final Set EIP_TYPES = EnumSet.of(
+ NodeType.EIP_CHOICE, NodeType.EIP_WHEN, NodeType.EIP_OTHERWISE,
+ NodeType.EIP_SPLIT, NodeType.EIP_AGGREGATE, NodeType.EIP_MULTICAST,
+ NodeType.EIP_FILTER, NodeType.EIP_RECIPIENT_LIST, NodeType.EIP_ROUTING_SLIP,
+ NodeType.EIP_DYNAMIC_ROUTER, NodeType.EIP_LOAD_BALANCE, NodeType.EIP_THROTTLE,
+ NodeType.EIP_DELAY, NodeType.EIP_LOOP, NodeType.EIP_IDEMPOTENT_CONSUMER,
+ NodeType.EIP_CIRCUIT_BREAKER, NodeType.EIP_PIPELINE
+ );
+
+ private static final Set CROSS_ROUTE_TYPES = EnumSet.of(
+ NodeType.EIP_WIRE_TAP, NodeType.EIP_ENRICH, NodeType.EIP_POLL_ENRICH
+ );
+
+ /** 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_AGGREGATE
+ );
+
+ 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);
+ DiagramLayout layout = result.layout;
+
+ int svgWidth = (int) Math.ceil(layout.width()) + 2 * PADDING;
+ int svgHeight = (int) Math.ceil(layout.height()) + 2 * PADDING;
+
+ SVGGraphics2D g2 = new SVGGraphics2D(svgWidth, svgHeight);
+ g2.translate(PADDING, PADDING);
+
+ // Draw edges first (behind nodes)
+ g2.setStroke(new BasicStroke(2.0f));
+ g2.setColor(EDGE_GRAY);
+ for (PositionedEdge edge : layout.edges()) {
+ drawEdge(g2, edge);
+ }
+
+ // Draw nodes
+ Font labelFont = new Font("SansSerif", Font.PLAIN, 12);
+ g2.setFont(labelFont);
+
+ // Draw compound containers first (background)
+ for (Map.Entry entry : result.compoundInfos.entrySet()) {
+ CompoundInfo ci = entry.getValue();
+ PositionedNode pn = findNode(layout.nodes(), ci.nodeId);
+ if (pn != null) {
+ drawCompoundContainer(g2, pn, ci.color);
+ }
+ }
+
+ // Draw leaf nodes
+ for (PositionedNode node : allNodes(layout.nodes())) {
+ if (!result.compoundInfos.containsKey(node.id()) || node.children().isEmpty()) {
+ drawNode(g2, node, result.nodeColors.getOrDefault(node.id(), PURPLE));
+ }
+ }
+
+ return g2.getSVGDocument();
+ }
+
+ @Override
+ public DiagramLayout layoutJson(RouteGraph graph) {
+ return computeLayout(graph).layout;
+ }
+
+ // ----------------------------------------------------------------
+ // Layout computation
+ // ----------------------------------------------------------------
+
+ private LayoutResult computeLayout(RouteGraph graph) {
+ ElkGraphFactory factory = ElkGraphFactory.eINSTANCE;
+
+ // Create root node
+ ElkNode rootNode = factory.createElkNode();
+ rootNode.setIdentifier("root");
+ rootNode.setProperty(CoreOptions.ALGORITHM, "org.eclipse.elk.layered");
+ rootNode.setProperty(CoreOptions.DIRECTION, Direction.DOWN);
+ rootNode.setProperty(CoreOptions.SPACING_NODE_NODE, NODE_SPACING);
+ rootNode.setProperty(CoreOptions.SPACING_EDGE_NODE, EDGE_SPACING);
+ rootNode.setProperty(CoreOptions.HIERARCHY_HANDLING, HierarchyHandling.INCLUDE_CHILDREN);
+
+ // Build index of RouteNodes
+ Map routeNodeMap = new HashMap<>();
+ if (graph.getNodes() != null) {
+ for (RouteNode rn : graph.getNodes()) {
+ routeNodeMap.put(rn.getId(), rn);
+ }
+ }
+
+ // 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());
+ }
+ }
+ }
+
+ // Create ELK nodes
+ Map elkNodeMap = new HashMap<>();
+ Map nodeColors = new HashMap<>();
+
+ // 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()));
+ }
+ }
+
+ // Create ELK edges
+ if (graph.getEdges() != null) {
+ for (RouteEdge re : graph.getEdges()) {
+ ElkNode sourceElk = elkNodeMap.get(re.getSource());
+ ElkNode targetElk = elkNodeMap.get(re.getTarget());
+ if (sourceElk == null || targetElk == null) {
+ continue;
+ }
+
+ // Determine the containing node for the edge
+ ElkNode containingNode = findCommonParent(sourceElk, targetElk);
+
+ ElkEdge elkEdge = factory.createElkEdge();
+ elkEdge.setContainingNode(containingNode);
+ elkEdge.getSources().add(sourceElk);
+ elkEdge.getTargets().add(targetElk);
+ }
+ }
+
+ // Run layout
+ RecursiveGraphLayoutEngine engine = new RecursiveGraphLayoutEngine();
+ engine.layout(rootNode, new BasicProgressMonitor());
+
+ // Extract results
+ 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()
+ ));
+ }
+ }
+ }
+
+ 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()
+ ));
+ }
+ }
+
+ // Extract edges
+ List positionedEdges = new ArrayList<>();
+ for (ElkEdge elkEdge : collectAllEdges(rootNode)) {
+ String sourceId = elkEdge.getSources().isEmpty() ? "" : elkEdge.getSources().get(0).getIdentifier();
+ String targetId = elkEdge.getTargets().isEmpty() ? "" : elkEdge.getTargets().get(0).getIdentifier();
+
+ List points = new ArrayList<>();
+ for (ElkEdgeSection section : elkEdge.getSections()) {
+ points.add(new double[]{
+ section.getStartX() + getAbsoluteX(elkEdge.getContainingNode(), rootNode),
+ section.getStartY() + getAbsoluteY(elkEdge.getContainingNode(), rootNode)
+ });
+ for (ElkBendPoint bp : section.getBendPoints()) {
+ points.add(new double[]{
+ bp.getX() + getAbsoluteX(elkEdge.getContainingNode(), rootNode),
+ bp.getY() + getAbsoluteY(elkEdge.getContainingNode(), rootNode)
+ });
+ }
+ points.add(new double[]{
+ section.getEndX() + getAbsoluteX(elkEdge.getContainingNode(), rootNode),
+ section.getEndY() + getAbsoluteY(elkEdge.getContainingNode(), rootNode)
+ });
+ }
+
+ // Find label from original edge
+ String label = "";
+ if (graph.getEdges() != null) {
+ for (RouteEdge re : graph.getEdges()) {
+ if (re.getSource().equals(sourceId) && re.getTarget().equals(targetId)) {
+ label = re.getLabel() != null ? re.getLabel() : "";
+ break;
+ }
+ }
+ }
+
+ positionedEdges.add(new PositionedEdge(sourceId, targetId, label, points));
+ }
+
+ double totalWidth = rootNode.getWidth();
+ double totalHeight = rootNode.getHeight();
+
+ DiagramLayout layout = new DiagramLayout(totalWidth, totalHeight, positionedNodes, positionedEdges);
+ return new LayoutResult(layout, nodeColors, compoundInfos);
+ }
+
+ // ----------------------------------------------------------------
+ // SVG drawing helpers
+ // ----------------------------------------------------------------
+
+ private void drawNode(SVGGraphics2D g2, PositionedNode node, Color color) {
+ g2.setColor(color);
+ g2.fill(new RoundRectangle2D.Double(
+ node.x(), node.y(), node.width(), node.height(),
+ CORNER_RADIUS, CORNER_RADIUS));
+
+ // White label
+ g2.setColor(Color.WHITE);
+ FontMetrics fm = g2.getFontMetrics();
+ String label = node.label();
+ int labelWidth = fm.stringWidth(label);
+ float labelX = (float) (node.x() + (node.width() - labelWidth) / 2.0);
+ float labelY = (float) (node.y() + 24);
+ g2.drawString(label, labelX, labelY);
+ }
+
+ 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
+ g2.setColor(bg);
+ g2.fill(new RoundRectangle2D.Double(
+ node.x(), node.y(), node.width(), node.height(),
+ CORNER_RADIUS, CORNER_RADIUS));
+
+ // Border
+ g2.setColor(color);
+ g2.setStroke(new BasicStroke(1.5f));
+ g2.draw(new RoundRectangle2D.Double(
+ node.x(), node.y(), node.width(), node.height(),
+ CORNER_RADIUS, CORNER_RADIUS));
+
+ // 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);
+
+ // Draw children inside
+ for (PositionedNode child : node.children()) {
+ Color childColor = colorForTypeName(child.type());
+ drawNode(g2, child, childColor);
+ }
+ }
+
+ private void drawEdge(SVGGraphics2D g2, PositionedEdge edge) {
+ List points = edge.points();
+ if (points.size() < 2) return;
+
+ GeneralPath path = new GeneralPath();
+ path.moveTo(points.get(0)[0], points.get(0)[1]);
+ for (int i = 1; i < points.size(); i++) {
+ path.lineTo(points.get(i)[0], points.get(i)[1]);
+ }
+ g2.draw(path);
+
+ // Arrowhead at the last point
+ if (points.size() >= 2) {
+ double[] end = points.get(points.size() - 1);
+ double[] prev = points.get(points.size() - 2);
+ drawArrowhead(g2, prev[0], prev[1], end[0], end[1]);
+ }
+ }
+
+ private void drawArrowhead(SVGGraphics2D g2, double fromX, double fromY, double toX, double toY) {
+ double angle = Math.atan2(toY - fromY, toX - fromX);
+ int arrowSize = 8;
+
+ GeneralPath arrow = new GeneralPath();
+ arrow.moveTo(toX, toY);
+ arrow.lineTo(toX - arrowSize * Math.cos(angle - Math.PI / 6),
+ toY - arrowSize * Math.sin(angle - Math.PI / 6));
+ arrow.lineTo(toX - arrowSize * Math.cos(angle + Math.PI / 6),
+ toY - arrowSize * Math.sin(angle + Math.PI / 6));
+ arrow.closePath();
+ g2.fill(arrow);
+ }
+
+ // ----------------------------------------------------------------
+ // Color mapping
+ // ----------------------------------------------------------------
+
+ private Color colorForType(NodeType type) {
+ if (type == null) return PURPLE;
+ 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 (CROSS_ROUTE_TYPES.contains(type)) return CYAN;
+ return PURPLE;
+ }
+
+ private Color colorForTypeName(String typeName) {
+ try {
+ NodeType type = NodeType.valueOf(typeName);
+ return colorForType(type);
+ } catch (IllegalArgumentException e) {
+ return PURPLE;
+ }
+ }
+
+ // ----------------------------------------------------------------
+ // ELK graph helpers
+ // ----------------------------------------------------------------
+
+ private ElkNode findCommonParent(ElkNode a, ElkNode b) {
+ if (a.getParent() == b.getParent()) {
+ return a.getParent();
+ }
+ // If one is the parent of the other
+ if (a.getParent() != null && a.getParent() == b) return b;
+ if (b.getParent() != null && b.getParent() == a) return a;
+ // Default: root (grandparent)
+ ElkNode parent = a.getParent();
+ while (parent != null && parent.getParent() != null) {
+ parent = parent.getParent();
+ }
+ return parent != null ? parent : a.getParent();
+ }
+
+ private double getAbsoluteX(ElkNode node, ElkNode root) {
+ double x = 0;
+ ElkNode current = node;
+ while (current != null && current != root) {
+ x += current.getX();
+ current = current.getParent();
+ }
+ return x;
+ }
+
+ private double getAbsoluteY(ElkNode node, ElkNode root) {
+ double y = 0;
+ ElkNode current = node;
+ while (current != null && current != root) {
+ y += current.getY();
+ current = current.getParent();
+ }
+ return y;
+ }
+
+ private List collectAllEdges(ElkNode node) {
+ List edges = new ArrayList<>(node.getContainedEdges());
+ for (ElkNode child : node.getChildren()) {
+ edges.addAll(collectAllEdges(child));
+ }
+ return edges;
+ }
+
+ private PositionedNode findNode(List nodes, String id) {
+ for (PositionedNode n : nodes) {
+ if (n.id().equals(id)) return n;
+ }
+ return null;
+ }
+
+ private List allNodes(List nodes) {
+ List all = new ArrayList<>();
+ for (PositionedNode n : nodes) {
+ all.add(n);
+ if (n.children() != null) {
+ all.addAll(n.children());
+ }
+ }
+ return all;
+ }
+
+ // ----------------------------------------------------------------
+ // Internal data classes
+ // ----------------------------------------------------------------
+
+ private record LayoutResult(
+ DiagramLayout layout,
+ Map nodeColors,
+ Map compoundInfos
+ ) {}
+
+ private record CompoundInfo(String nodeId, Color color) {}
+}
diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DiagramRenderControllerIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DiagramRenderControllerIT.java
new file mode 100644
index 00000000..2f5995ba
--- /dev/null
+++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DiagramRenderControllerIT.java
@@ -0,0 +1,139 @@
+package com.cameleer3.server.app.controller;
+
+import com.cameleer3.server.app.AbstractClickHouseIT;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+/**
+ * Integration tests for {@link DiagramRenderController}.
+ * Seeds a diagram via the ingestion endpoint, then tests rendering.
+ */
+class DiagramRenderControllerIT extends AbstractClickHouseIT {
+
+ @Autowired
+ private TestRestTemplate restTemplate;
+
+ private String contentHash;
+
+ /**
+ * Seed a diagram and compute its content hash for render tests.
+ */
+ @BeforeEach
+ void seedDiagram() {
+ String json = """
+ {
+ "routeId": "render-test-route",
+ "description": "Render test",
+ "version": 1,
+ "nodes": [
+ {"id": "n1", "type": "ENDPOINT", "label": "timer:tick"},
+ {"id": "n2", "type": "BEAN", "label": "myBean"},
+ {"id": "n3", "type": "TO", "label": "log:out"}
+ ],
+ "edges": [
+ {"source": "n1", "target": "n2", "edgeType": "FLOW"},
+ {"source": "n2", "target": "n3", "edgeType": "FLOW"}
+ ],
+ "processorNodeMapping": {}
+ }
+ """;
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ headers.set("X-Cameleer-Protocol-Version", "1");
+
+ restTemplate.postForEntity(
+ "/api/v1/data/diagrams",
+ new HttpEntity<>(json, headers),
+ String.class);
+
+ // Wait for flush to ClickHouse and retrieve the content hash
+ await().atMost(10, SECONDS).untilAsserted(() -> {
+ String hash = jdbcTemplate.queryForObject(
+ "SELECT content_hash FROM route_diagrams WHERE route_id = 'render-test-route' LIMIT 1",
+ String.class);
+ assertThat(hash).isNotNull();
+ contentHash = hash;
+ });
+ }
+
+ @Test
+ void getSvg_withAcceptHeader_returnsSvg() {
+ HttpHeaders headers = new HttpHeaders();
+ headers.set("Accept", "image/svg+xml");
+ headers.set("X-Cameleer-Protocol-Version", "1");
+
+ ResponseEntity response = restTemplate.exchange(
+ "/api/v1/diagrams/{hash}/render",
+ HttpMethod.GET,
+ new HttpEntity<>(headers),
+ String.class,
+ contentHash);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getHeaders().getContentType().toString()).contains("svg");
+ assertThat(response.getBody()).contains("