VisioDiagramService.java
package com.taxonomy.export;
import com.taxonomy.diagram.DiagramEdge;
import com.taxonomy.diagram.DiagramModel;
import com.taxonomy.diagram.DiagramNode;
import com.taxonomy.visio.VisioConnect;
import com.taxonomy.visio.VisioExportMetadata;
import com.taxonomy.visio.VisioProperty;
import com.taxonomy.visio.VisioLoss;
import com.taxonomy.visio.VisioDocument;
import com.taxonomy.visio.VisioPage;
import com.taxonomy.visio.VisioShape;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Converts a neutral {@link DiagramModel} into a {@link VisioDocument} by laying
* out shapes by layer, with an optional second anchor/impact selection page.
*/
public class VisioDiagramService {
private static final Logger log = LoggerFactory.getLogger(VisioDiagramService.class);
private static final double SHAPE_WIDTH = 2.0;
private static final double SHAPE_HEIGHT = 0.75;
private static final double H_GAP = 3.0;
private static final double V_GAP = 1.2;
private static final double MARGIN_X = 1.5;
private static final double MARGIN_Y = 1.5;
/**
* Converts a {@link DiagramModel} without snapshot authority metadata.
*/
public VisioDocument convert(DiagramModel model) {
return convert(model, VisioExportMetadata.unbound());
}
public VisioDocument convert(DiagramModel model, VisioExportMetadata metadata) {
validateGraph(model);
VisioDocument doc = new VisioDocument();
doc.getProperties().putAll(metadata.document());
doc.getProperties().put("taxonomy.exportProfile", VisioProperty.text(VisioHandoffProfile.ID));
doc.getProperties().put("taxonomy.scoreSemantics", VisioProperty.text("relevance is a normalized selection score in [0,1], not a probability"));
doc.getLosses().addAll(metadata.losses());
doc.getLosses().add(new VisioLoss("document", "", "layout", "MAPPED",
"Generated layer layout in inches; browser geometry, routing and typography are not copied."));
VisioPage page = new VisioPage("0", model.title() != null ? model.title() : "Architecture View");
doc.getPages().add(page);
// Group nodes by layer for layout
List<DiagramNode> sortedNodes = model.nodes().stream().sorted(Comparator.comparing(DiagramNode::id)).toList();
Map<Integer, List<DiagramNode>> layerGroups = new LinkedHashMap<>();
for (DiagramNode node : sortedNodes) {
layerGroups.computeIfAbsent(node.layer(), k -> new java.util.ArrayList<>()).add(node);
}
// Assign positions: layers left-to-right, nodes top-to-bottom within a layer
Map<String, String> nodeIdToShapeId = new LinkedHashMap<>();
int shapeIdx = 0;
for (DiagramNode node : sortedNodes) {
nodeIdToShapeId.put(node.id(), Integer.toString(++shapeIdx));
}
int layerIdx = 0;
for (var entry : layerGroups.entrySet().stream()
.sorted(Comparator.comparingInt(Map.Entry::getKey)).toList()) {
List<DiagramNode> layerNodes = entry.getValue();
for (int i = 0; i < layerNodes.size(); i++) {
DiagramNode node = layerNodes.get(i);
String shapeId = nodeIdToShapeId.get(node.id());
double x = MARGIN_X + layerIdx * H_GAP;
double y = MARGIN_Y + i * V_GAP;
VisioShape shape = new VisioShape(
shapeId,
node.label(),
x, y,
SHAPE_WIDTH, SHAPE_HEIGHT,
node.type(),
node.anchor());
shape.getProperties().putAll(metadata.elements().getOrDefault(node.id(), Map.of()));
var values = shape.getProperties();
values.put("taxonomy.id", VisioProperty.text(node.id()));
values.put("taxonomy.type", VisioProperty.text(node.type()));
values.put("taxonomy.relevance", VisioProperty.number(node.relevance()));
values.put("taxonomy.anchor", VisioProperty.bool(node.anchor()));
values.put("taxonomy.selectedForImpact", VisioProperty.bool(node.selectedForImpact()));
values.put("taxonomy.container", VisioProperty.bool(node.container()));
values.put("taxonomy.depth", VisioProperty.number(node.depth()));
values.put("taxonomy.layer", VisioProperty.number(node.layer()));
if (node.parentId() != null) values.put("taxonomy.parentId", VisioProperty.text(node.parentId()));
page.getShapes().add(shape);
if (node.container()) doc.getLosses().add(new VisioLoss("element", node.id(), "container", "MAPPED",
"Visual container rendered as a flat rectangle, explicitly marked non-semantic; parent identity retained in shape data."));
}
layerIdx++;
}
// Create connectors
for (DiagramEdge edge : model.edges().stream().sorted(Comparator.comparing(DiagramEdge::id)).toList()) {
String fromShape = nodeIdToShapeId.get(edge.sourceId());
String toShape = nodeIdToShapeId.get(edge.targetId());
if (fromShape != null && toShape != null) {
VisioConnect connector = new VisioConnect(fromShape, toShape, edge.relationType());
connector.getProperties().putAll(metadata.relationships().getOrDefault(edge.id(), Map.of()));
var values = connector.getProperties();
values.put("taxonomy.id", VisioProperty.text(edge.id()));
values.put("taxonomy.type", VisioProperty.text(edge.relationType()));
values.put("taxonomy.sourceId", VisioProperty.text(edge.sourceId()));
values.put("taxonomy.targetId", VisioProperty.text(edge.targetId()));
values.put("taxonomy.relevance", VisioProperty.number(edge.relevance()));
if (edge.relationCategory() != null) values.put("taxonomy.relationCategory", VisioProperty.text(edge.relationCategory()));
page.getConnects().add(connector);
}
}
log.info("VisioDiagram: {} shapes, {} connectors on page '{}'",
page.getShapes().size(), page.getConnects().size(), page.getName());
java.util.Set<String> selected = model.nodes().stream().filter(n -> n.anchor() || n.selectedForImpact())
.map(n -> nodeIdToShapeId.get(n.id())).collect(java.util.stream.Collectors.toSet());
if (!selected.isEmpty() && selected.size() < page.getShapes().size()) {
VisioPage impact = new VisioPage("1", "Anchor and impact selection");
page.getShapes().stream().filter(s -> selected.contains(s.getId())).map(VisioDiagramService::copyShape)
.forEach(impact.getShapes()::add);
page.getConnects().stream().filter(c -> selected.contains(c.getFromShape()) && selected.contains(c.getToShape()))
.map(VisioDiagramService::copyConnect).forEach(impact.getConnects()::add);
doc.getPages().add(impact);
}
return doc;
}
private static VisioShape copyShape(VisioShape source) {
VisioShape copy = new VisioShape(source.getId(), source.getText(), source.getX(), source.getY(),
source.getWidth(), source.getHeight(), source.getType(), source.isAnchor());
copy.getProperties().putAll(source.getProperties());
return copy;
}
private static VisioConnect copyConnect(VisioConnect source) {
VisioConnect copy = new VisioConnect(source.getFromShape(), source.getToShape(), source.getRelationType());
copy.getProperties().putAll(source.getProperties());
return copy;
}
private static void validateGraph(DiagramModel model) {
if (model == null || model.nodes() == null || model.edges() == null || model.layout() == null) {
throw new IllegalArgumentException("Complete canonical graph required");
}
if (model.nodes().size() > 10_000 || model.edges().size() > 30_000) {
throw new IllegalArgumentException("Visio profile limit: 10,000 elements and 30,000 relationships");
}
java.util.Set<String> ids = new java.util.HashSet<>();
for (var node : model.nodes()) {
if (node == null || node.id() == null || node.id().isBlank() || !ids.add(node.id())
|| node.type() == null || node.type().isBlank()) throw new IllegalArgumentException("Invalid or duplicate canonical element identity/type");
score(node.relevance());
}
for (var node : model.nodes()) {
if (node.parentId() != null && (!ids.contains(node.parentId()) || node.id().equals(node.parentId()))) {
throw new IllegalArgumentException("Invalid canonical parent reference");
}
}
java.util.Set<String> edgeIds = new java.util.HashSet<>();
for (var edge : model.edges()) {
if (edge == null || edge.id() == null || edge.id().isBlank() || !edgeIds.add(edge.id())
|| !ids.contains(edge.sourceId()) || !ids.contains(edge.targetId())
|| edge.relationType() == null || edge.relationType().isBlank()) {
throw new IllegalArgumentException("Invalid or duplicate canonical relationship identity/reference/type");
}
if (edge.sourceId().equals(edge.targetId())) throw new IllegalArgumentException("Visio profile does not support self-loop connector geometry: " + edge.id());
score(edge.relevance());
}
}
private static void score(double value) {
if (!Double.isFinite(value) || value < 0 || value > 1) throw new IllegalArgumentException("Relevance must be finite and in [0,1]");
}
}