feat(02-02): implement ELK diagram renderer with SVG/JSON content negotiation

- ElkDiagramRenderer: ELK layered layout (top-to-bottom) with JFreeSVG rendering
- Color-coded nodes: blue endpoints, green processors, red errors, purple EIPs, cyan cross-route
- Compound node support for CHOICE/SPLIT/TRY_CATCH swimlane groups
- DiagramRenderController: GET /api/v1/diagrams/{hash}/render with Accept header negotiation
- DiagramBeanConfig for Spring wiring
- 11 unit tests (layout, SVG structure, colors, compound nodes)
- 4 integration tests (SVG, JSON, 404, default format)
- Added xtext xbase lib dependency for ELK compatibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-11 16:17:13 +01:00
parent f6ff279a60
commit c1bc32d50a
6 changed files with 998 additions and 0 deletions

View File

@@ -61,6 +61,11 @@
<artifactId>org.jfree.svg</artifactId>
<version>5.0.7</version>
</dependency>
<dependency>
<groupId>org.eclipse.xtext</groupId>
<artifactId>org.eclipse.xtext.xbase.lib</artifactId>
<version>2.37.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>

View File

@@ -0,0 +1,18 @@
package com.cameleer3.server.app.config;
import com.cameleer3.server.app.diagram.ElkDiagramRenderer;
import com.cameleer3.server.core.diagram.DiagramRenderer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Creates beans for diagram rendering.
*/
@Configuration
public class DiagramBeanConfig {
@Bean
public DiagramRenderer diagramRenderer() {
return new ElkDiagramRenderer();
}
}

View File

@@ -0,0 +1,93 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.common.graph.RouteGraph;
import com.cameleer3.server.core.diagram.DiagramLayout;
import com.cameleer3.server.core.diagram.DiagramRenderer;
import com.cameleer3.server.core.storage.DiagramRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Optional;
/**
* REST endpoint for rendering route diagrams.
* <p>
* Supports content negotiation via Accept header:
* <ul>
* <li>{@code image/svg+xml} or default: returns SVG document</li>
* <li>{@code application/json}: returns JSON layout with node positions</li>
* </ul>
*/
@RestController
@RequestMapping("/api/v1/diagrams")
@Tag(name = "Diagrams", description = "Diagram rendering endpoints")
public class DiagramRenderController {
private static final MediaType SVG_MEDIA_TYPE = MediaType.valueOf("image/svg+xml");
private final DiagramRepository diagramRepository;
private final DiagramRenderer diagramRenderer;
public DiagramRenderController(DiagramRepository diagramRepository,
DiagramRenderer diagramRenderer) {
this.diagramRepository = diagramRepository;
this.diagramRenderer = diagramRenderer;
}
@GetMapping("/{contentHash}/render")
@Operation(summary = "Render a route diagram",
description = "Returns SVG (default) or JSON layout based on Accept header")
@ApiResponse(responseCode = "200", description = "Diagram rendered successfully")
@ApiResponse(responseCode = "404", description = "Diagram not found")
public ResponseEntity<?> renderDiagram(
@PathVariable String contentHash,
HttpServletRequest request) {
Optional<RouteGraph> 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.
* <p>
* 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, *&#47;*" 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);
}
}

View File

@@ -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}.
* <p>
* 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<NodeType> ENDPOINT_TYPES = EnumSet.of(
NodeType.ENDPOINT, NodeType.TO, NodeType.TO_DYNAMIC, NodeType.DIRECT, NodeType.SEDA
);
private static final Set<NodeType> 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<NodeType> 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<NodeType> 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<NodeType> 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<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_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<String, CompoundInfo> 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<String, RouteNode> 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<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());
}
}
}
// Create ELK nodes
Map<String, ElkNode> elkNodeMap = new HashMap<>();
Map<String, Color> 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<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()
));
}
}
}
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<PositionedEdge> 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<double[]> 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<double[]> 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<ElkEdge> collectAllEdges(ElkNode node) {
List<ElkEdge> edges = new ArrayList<>(node.getContainedEdges());
for (ElkNode child : node.getChildren()) {
edges.addAll(collectAllEdges(child));
}
return edges;
}
private PositionedNode findNode(List<PositionedNode> nodes, String id) {
for (PositionedNode n : nodes) {
if (n.id().equals(id)) return n;
}
return null;
}
private List<PositionedNode> allNodes(List<PositionedNode> nodes) {
List<PositionedNode> 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<String, Color> nodeColors,
Map<String, CompoundInfo> compoundInfos
) {}
private record CompoundInfo(String nodeId, Color color) {}
}

View File

@@ -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<String> 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("<svg");
}
@Test
void getJson_withAcceptHeader_returnsJson() {
HttpHeaders headers = new HttpHeaders();
headers.set("Accept", "application/json");
headers.set("X-Cameleer-Protocol-Version", "1");
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/diagrams/{hash}/render",
HttpMethod.GET,
new HttpEntity<>(headers),
String.class,
contentHash);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).contains("nodes");
assertThat(response.getBody()).contains("edges");
}
@Test
void getNonExistentHash_returns404() {
HttpHeaders headers = new HttpHeaders();
headers.set("Accept", "image/svg+xml");
headers.set("X-Cameleer-Protocol-Version", "1");
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/diagrams/{hash}/render",
HttpMethod.GET,
new HttpEntity<>(headers),
String.class,
"nonexistent-hash-12345");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
void getWithNoAcceptHeader_defaultsToSvg() {
HttpHeaders headers = new HttpHeaders();
headers.set("X-Cameleer-Protocol-Version", "1");
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/diagrams/{hash}/render",
HttpMethod.GET,
new HttpEntity<>(headers),
String.class,
contentHash);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).contains("<svg");
}
}

View File

@@ -0,0 +1,183 @@
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.PositionedNode;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for {@link ElkDiagramRenderer}.
* No Spring context needed -- pure unit test.
*/
class ElkDiagramRendererTest {
private ElkDiagramRenderer renderer;
@BeforeEach
void setUp() {
renderer = new ElkDiagramRenderer();
}
/**
* Build a simple 3-node route: from(endpoint) -> process(bean) -> to(endpoint)
*/
private RouteGraph buildSimpleGraph() {
RouteGraph graph = new RouteGraph("test-route");
graph.setExtractedAt(Instant.now());
graph.setVersion(1);
RouteNode from = new RouteNode("node-1", NodeType.ENDPOINT, "timer:tick");
RouteNode process = new RouteNode("node-2", NodeType.BEAN, "myProcessor");
RouteNode to = new RouteNode("node-3", NodeType.TO, "log:output");
graph.setNodes(List.of(from, process, to));
graph.setEdges(List.of(
new RouteEdge("node-1", "node-2", RouteEdge.EdgeType.FLOW),
new RouteEdge("node-2", "node-3", RouteEdge.EdgeType.FLOW)
));
return graph;
}
/**
* Build a compound graph: from -> choice -> (when, otherwise) -> to
*/
private RouteGraph buildCompoundGraph() {
RouteGraph graph = new RouteGraph("compound-route");
graph.setExtractedAt(Instant.now());
graph.setVersion(1);
RouteNode from = new RouteNode("node-1", NodeType.ENDPOINT, "direct:start");
RouteNode choice = new RouteNode("node-2", NodeType.EIP_CHOICE, "choice");
RouteNode when = new RouteNode("node-3", NodeType.EIP_WHEN, "when(simple)");
RouteNode otherwise = new RouteNode("node-4", NodeType.EIP_OTHERWISE, "otherwise");
RouteNode to = new RouteNode("node-5", NodeType.TO, "log:result");
// Set children on the choice node
choice.setChildren(List.of(when, otherwise));
graph.setNodes(List.of(from, choice, when, otherwise, to));
graph.setEdges(List.of(
new RouteEdge("node-1", "node-2", RouteEdge.EdgeType.FLOW),
new RouteEdge("node-2", "node-3", RouteEdge.EdgeType.FLOW),
new RouteEdge("node-2", "node-4", RouteEdge.EdgeType.FLOW),
new RouteEdge("node-3", "node-5", RouteEdge.EdgeType.FLOW),
new RouteEdge("node-4", "node-5", RouteEdge.EdgeType.FLOW)
));
return graph;
}
@Test
void renderSvg_simpleGraph_producesValidSvg() {
String svg = renderer.renderSvg(buildSimpleGraph());
assertNotNull(svg);
assertTrue(svg.contains("<svg"), "SVG should contain <svg element");
assertTrue(svg.contains("</svg>"), "SVG should be properly closed");
}
@Test
void renderSvg_simpleGraph_containsNodeShapes() {
String svg = renderer.renderSvg(buildSimpleGraph());
// Should contain rect elements for nodes
assertTrue(svg.contains("<rect") || svg.contains("<path"),
"SVG should contain rect or path elements for nodes");
}
@Test
void renderSvg_simpleGraph_containsNodeLabels() {
String svg = renderer.renderSvg(buildSimpleGraph());
assertTrue(svg.contains("timer:tick"), "SVG should contain endpoint label");
assertTrue(svg.contains("myProcessor"), "SVG should contain processor label");
assertTrue(svg.contains("log:output"), "SVG should contain to label");
}
@Test
void renderSvg_endpointNodes_haveBlueColor() {
String svg = renderer.renderSvg(buildSimpleGraph());
// Endpoint nodes should have blue fill (#3B82F6)
assertTrue(svg.contains("#3B82F6") || svg.contains("#3b82f6") ||
svg.contains("rgb(59,130,246)") || svg.contains("rgb(59, 130, 246)"),
"Endpoint nodes should have blue fill color (#3B82F6)");
}
@Test
void renderSvg_containsEdgeLines() {
String svg = renderer.renderSvg(buildSimpleGraph());
// Edges should be drawn as lines or paths
assertTrue(svg.contains("<line") || svg.contains("<polyline") || svg.contains("<path"),
"SVG should contain line/path elements for edges");
}
@Test
void layoutJson_simpleGraph_returnsCorrectNodeCount() {
DiagramLayout layout = renderer.layoutJson(buildSimpleGraph());
assertNotNull(layout);
assertEquals(3, layout.nodes().size(), "Should have 3 positioned nodes");
}
@Test
void layoutJson_simpleGraph_nodesHavePositiveCoordinates() {
DiagramLayout layout = renderer.layoutJson(buildSimpleGraph());
for (PositionedNode node : layout.nodes()) {
assertTrue(node.x() >= 0, "Node x should be >= 0: " + node.id());
assertTrue(node.y() >= 0, "Node y should be >= 0: " + node.id());
assertTrue(node.width() > 0, "Node width should be > 0: " + node.id());
assertTrue(node.height() > 0, "Node height should be > 0: " + node.id());
}
}
@Test
void layoutJson_simpleGraph_hasPositiveDimensions() {
DiagramLayout layout = renderer.layoutJson(buildSimpleGraph());
assertTrue(layout.width() > 0, "Layout width should be positive");
assertTrue(layout.height() > 0, "Layout height should be positive");
}
@Test
void layoutJson_simpleGraph_hasEdges() {
DiagramLayout layout = renderer.layoutJson(buildSimpleGraph());
assertEquals(2, layout.edges().size(), "Should have 2 edges");
}
@Test
void layoutJson_compoundGraph_choiceNodeHasChildren() {
DiagramLayout layout = renderer.layoutJson(buildCompoundGraph());
PositionedNode choiceNode = layout.nodes().stream()
.filter(n -> "node-2".equals(n.id()))
.findFirst()
.orElseThrow(() -> new AssertionError("Choice node not found"));
assertNotNull(choiceNode.children(), "Choice node should have children");
assertFalse(choiceNode.children().isEmpty(), "Choice node should have non-empty children");
assertEquals(2, choiceNode.children().size(), "Choice node should have 2 children (when, otherwise)");
}
@Test
void renderSvg_compoundGraph_producesValidSvg() {
String svg = renderer.renderSvg(buildCompoundGraph());
assertNotNull(svg);
assertTrue(svg.contains("<svg"), "Compound SVG should contain <svg element");
assertTrue(svg.contains("choice"), "SVG should contain choice label");
}
}