feat: add interactive ProcessDiagram SVG component (sub-project 1/3)
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m0s
CI / docker (push) Successful in 56s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 38s

New interactive route diagram component with SVG rendering using
server-computed ELK layout coordinates. TIBCO BW5-inspired top-bar
card node style with zoom/pan, hover toolbars, config badges, and
error handler sections below the main flow.

Backend: add direction query parameter (LR/TB) to diagram render
endpoints, defaulting to left-to-right layout.

Frontend: 14-file ProcessDiagram component in ui/src/components/
with DiagramNode, CompoundNode, DiagramEdge, ConfigBadge, NodeToolbar,
ErrorSection, ZoomControls, and supporting hooks. Dev test page at
/dev/diagram for validation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-27 13:55:29 +01:00
parent 78e12f5cf9
commit ac32396a57
24 changed files with 7264 additions and 18 deletions

View File

@@ -62,6 +62,7 @@ public class DiagramRenderController {
@ApiResponse(responseCode = "404", description = "Diagram not found")
public ResponseEntity<?> renderDiagram(
@PathVariable String contentHash,
@RequestParam(defaultValue = "LR") String direction,
HttpServletRequest request) {
Optional<RouteGraph> graphOpt = diagramStore.findByContentHash(contentHash);
@@ -76,7 +77,7 @@ public class DiagramRenderController {
// 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);
DiagramLayout layout = diagramRenderer.layoutJson(graph, direction);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(layout);
@@ -96,7 +97,8 @@ public class DiagramRenderController {
@ApiResponse(responseCode = "404", description = "No diagram found for the given application and route")
public ResponseEntity<DiagramLayout> findByApplicationAndRoute(
@RequestParam String application,
@RequestParam String routeId) {
@RequestParam String routeId,
@RequestParam(defaultValue = "LR") String direction) {
List<String> agentIds = registryService.findByApplication(application).stream()
.map(AgentInfo::id)
.toList();
@@ -115,7 +117,7 @@ public class DiagramRenderController {
return ResponseEntity.notFound().build();
}
DiagramLayout layout = diagramRenderer.layoutJson(graphOpt.get());
DiagramLayout layout = diagramRenderer.layoutJson(graphOpt.get(), direction);
return ResponseEntity.ok(layout);
}

View File

@@ -112,7 +112,7 @@ public class ElkDiagramRenderer implements DiagramRenderer {
@Override
public String renderSvg(RouteGraph graph) {
LayoutResult result = computeLayout(graph);
LayoutResult result = computeLayout(graph, Direction.DOWN);
DiagramLayout layout = result.layout;
int svgWidth = (int) Math.ceil(layout.width()) + 2 * PADDING;
@@ -153,21 +153,27 @@ public class ElkDiagramRenderer implements DiagramRenderer {
@Override
public DiagramLayout layoutJson(RouteGraph graph) {
return computeLayout(graph).layout;
return computeLayout(graph, Direction.RIGHT).layout;
}
@Override
public DiagramLayout layoutJson(RouteGraph graph, String direction) {
Direction dir = "TB".equalsIgnoreCase(direction) ? Direction.DOWN : Direction.RIGHT;
return computeLayout(graph, dir).layout;
}
// ----------------------------------------------------------------
// Layout computation
// ----------------------------------------------------------------
private LayoutResult computeLayout(RouteGraph graph) {
private LayoutResult computeLayout(RouteGraph graph, Direction rootDirection) {
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.DIRECTION, rootDirection);
rootNode.setProperty(CoreOptions.SPACING_NODE_NODE, NODE_SPACING);
rootNode.setProperty(CoreOptions.SPACING_EDGE_NODE, EDGE_SPACING);
rootNode.setProperty(CoreOptions.HIERARCHY_HANDLING, HierarchyHandling.INCLUDE_CHILDREN);