LayeredDiagramLayoutService.java
package com.taxonomy.export;
import com.taxonomy.diagram.DiagramEdge;
import com.taxonomy.diagram.DiagramModel;
import com.taxonomy.diagram.DiagramNode;
import com.taxonomy.diagram.DiagramScene;
import com.taxonomy.diagram.DiagramSceneEdge;
import com.taxonomy.diagram.DiagramSceneNode;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Deterministic layered layout for architecture diagrams.
*
* <p>The renderer deliberately has no browser or Spring dependency. The same
* coordinates are returned to the browser and used by SVG/PDF exporters.</p>
*/
public class LayeredDiagramLayoutService {
static final double MARGIN = 52.0;
static final double NODE_WIDTH = 238.0;
static final double NODE_HEIGHT = 82.0;
static final double COLUMN_GAP = 92.0;
static final double SUBCOLUMN_GAP = 24.0;
static final double ROW_GAP = 34.0;
static final int MAX_ROWS_PER_COLUMN = 5;
static final double MIN_WIDTH = 760.0;
static final double MIN_HEIGHT = 420.0;
public DiagramScene layout(DiagramModel model) {
if (model == null || model.nodes() == null || model.nodes().isEmpty()) {
return new DiagramScene(
model != null ? model.title() : "Architecture",
MIN_WIDTH,
MIN_HEIGHT,
direction(model),
List.of(),
List.of());
}
List<DiagramNode> ordered = model.nodes().stream()
.sorted(Comparator
.comparingInt(DiagramNode::layer)
.thenComparing(node -> safe(node.type()))
.thenComparing(node -> safe(node.id())))
.toList();
Map<Integer, List<DiagramNode>> byLayer = new LinkedHashMap<>();
for (DiagramNode node : ordered) {
byLayer.computeIfAbsent(node.layer(), ignored -> new ArrayList<>()).add(node);
}
int maximumRows = byLayer.values().stream()
.mapToInt(layer -> rowCount(layer.size()))
.max()
.orElse(1);
double contentHeight = rowsHeight(maximumRows);
List<DiagramSceneNode> sceneNodes = new ArrayList<>(ordered.size());
Map<String, DiagramSceneNode> byId = new LinkedHashMap<>();
double layerStartX = MARGIN;
for (List<DiagramNode> layerNodes : byLayer.values()) {
int rows = rowCount(layerNodes.size());
double layerHeight = rowsHeight(rows);
double firstRowY = MARGIN + (contentHeight - layerHeight) / 2.0;
for (int index = 0; index < layerNodes.size(); index++) {
DiagramNode node = layerNodes.get(index);
int subcolumn = index / rows;
int row = index % rows;
DiagramSceneNode sceneNode = new DiagramSceneNode(
node.id(),
node.label(),
node.type(),
node.relevance(),
node.anchor(),
node.layer(),
node.depth(),
node.selectedForImpact(),
node.parentId(),
node.container(),
layerStartX + subcolumn * (NODE_WIDTH + SUBCOLUMN_GAP),
firstRowY + row * (NODE_HEIGHT + ROW_GAP),
NODE_WIDTH,
NODE_HEIGHT);
sceneNodes.add(sceneNode);
byId.put(sceneNode.id(), sceneNode);
}
layerStartX += layerWidth(layerNodes.size()) + COLUMN_GAP;
}
List<DiagramSceneEdge> sceneEdges = new ArrayList<>();
if (model.edges() != null) {
for (DiagramEdge edge : model.edges().stream()
.sorted(Comparator.comparing(item -> safe(item.id())))
.toList()) {
DiagramSceneNode source = byId.get(edge.sourceId());
DiagramSceneNode target = byId.get(edge.targetId());
if (source == null || target == null) {
continue;
}
boolean leftToRight = source.x() <= target.x();
double sourceX = leftToRight ? source.x() + source.width() : source.x();
double targetX = leftToRight ? target.x() : target.x() + target.width();
sceneEdges.add(new DiagramSceneEdge(
edge.id(),
edge.sourceId(),
edge.targetId(),
edge.relationType(),
edge.relevance(),
edge.relationCategory(),
sourceX,
source.y() + source.height() / 2.0,
targetX,
target.y() + target.height() / 2.0));
}
}
double layerWidths = byLayer.values().stream()
.mapToDouble(layer -> layerWidth(layer.size()))
.sum();
double width = Math.max(
MIN_WIDTH,
2 * MARGIN + layerWidths
+ Math.max(0, byLayer.size() - 1) * COLUMN_GAP);
double height = Math.max(MIN_HEIGHT, 2 * MARGIN + contentHeight);
return new DiagramScene(
model.title(),
width,
height,
direction(model),
sceneNodes,
sceneEdges);
}
private static int subcolumnCount(int nodeCount) {
int boundedCount = Math.max(1, nodeCount);
return Math.max(1, (boundedCount + MAX_ROWS_PER_COLUMN - 1) / MAX_ROWS_PER_COLUMN);
}
private static int rowCount(int nodeCount) {
int boundedCount = Math.max(1, nodeCount);
int subcolumns = subcolumnCount(boundedCount);
return Math.max(1, (boundedCount + subcolumns - 1) / subcolumns);
}
private static double layerWidth(int nodeCount) {
int subcolumns = subcolumnCount(nodeCount);
return subcolumns * NODE_WIDTH + Math.max(0, subcolumns - 1) * SUBCOLUMN_GAP;
}
private static double rowsHeight(int rows) {
int boundedRows = Math.max(1, rows);
return boundedRows * NODE_HEIGHT + Math.max(0, boundedRows - 1) * ROW_GAP;
}
private static String direction(DiagramModel model) {
return model != null && model.layout() != null && model.layout().direction() != null
? model.layout().direction()
: "LR";
}
private static String safe(String value) {
return value == null ? "" : value;
}
}