fix(diagram): keep architecture auto layout grouped and compact
Lay out each architecture boundary independently, place ungrouped terminals in the group-level graph, stack groups when a left-to-right row would overflow, and route architecture edges orthogonally.
This commit is contained in:
1 parent
88f8f80b4c
commit
04439b5a1a
5 files changed
+315
-70
No files matched your search
@@ -484,6 +484,7 @@ const FLOW_QUICK_CREATE_WIDTH = 330;
|
||||
const FLOW_QUICK_CREATE_HEIGHT = 132;
|
||||
const createId = (prefix: string) => `${prefix}-${crypto.randomUUID()}`;
|
||||
const isConnectableDiagram = (kind: DiagramDocument["kind"]) => kind !== "mind-map";
|
||||
const usesOrthogonalDiagramEdges = (kind: DiagramDocument["kind"]) => kind === "flowchart" || kind === "architecture";
|
||||
const architectureNodeLabel = (shape: DiagramNodeShape, t: (key: string) => string) => {
|
||||
const labels: Partial<Record<DiagramNodeShape, string>> = {
|
||||
client: t("diagram.newClient"),
|
||||
@@ -1029,7 +1030,7 @@ const edgeMetadata = (
|
||||
id: edge.id,
|
||||
source: { cell: edge.source },
|
||||
target: { cell: edge.target },
|
||||
router: kind === "flowchart" ? FLOWCHART_EDGE_ROUTER : undefined,
|
||||
router: usesOrthogonalDiagramEdges(kind) ? FLOWCHART_EDGE_ROUTER : undefined,
|
||||
connector: kind === "mind-map"
|
||||
? { name: MIND_MAP_CONNECTOR_NAME, args: { sourceWidth: mindEdge?.sourceWidth, targetWidth: mindEdge?.targetWidth, structure } }
|
||||
: { name: "rounded", args: { radius: 10 } },
|
||||
@@ -1579,7 +1580,7 @@ export const DiagramEditorPane = ({
|
||||
allowMulti: false,
|
||||
highlight: isConnectableDiagram(document.kind),
|
||||
snap: { radius: 24 },
|
||||
router: document.kind === "flowchart" ? FLOWCHART_EDGE_ROUTER : "normal",
|
||||
router: usesOrthogonalDiagramEdges(document.kind) ? FLOWCHART_EDGE_ROUTER : "normal",
|
||||
connector: document.kind === "mind-map" ? MIND_MAP_CONNECTOR_NAME : "rounded",
|
||||
validateConnection: ({ sourceCell, targetCell, sourcePort, targetPort }) => {
|
||||
if (!isConnectableDiagram(document.kind) || !sourceCell || !sourcePort) return false;
|
||||
@@ -1647,7 +1648,7 @@ export const DiagramEditorPane = ({
|
||||
}
|
||||
}
|
||||
graph.addEdges(document.edges.map((edge) => edgeMetadata(edge, document.kind, documentTheme, appearance, documentStructure)));
|
||||
if (document.kind === "flowchart") applyFlowchartEdgePorts(graph);
|
||||
if (usesOrthogonalDiagramEdges(document.kind)) applyFlowchartEdgePorts(graph);
|
||||
applyGraphPalette(graph, documentTheme, document.kind, appearance, documentStructure);
|
||||
graph.on("scale", () => setZoomPercent(Math.round(graph.scale().sx * 100)));
|
||||
graph.cleanHistory();
|
||||
@@ -1818,7 +1819,7 @@ export const DiagramEditorPane = ({
|
||||
target: { cell: currentCell.id, ...(currentPort ? { port: currentPort } : {}) },
|
||||
});
|
||||
graph.stopBatch("connect");
|
||||
if (document.kind === "flowchart") applyFlowchartEdgePorts(graph);
|
||||
if (usesOrthogonalDiagramEdges(document.kind)) applyFlowchartEdgePorts(graph);
|
||||
return;
|
||||
}
|
||||
if (!currentPoint || !containerRef.current) {
|
||||
@@ -2393,7 +2394,7 @@ export const DiagramEditorPane = ({
|
||||
node.resize(geometry.width, geometry.height);
|
||||
}
|
||||
}
|
||||
if (document.kind === "flowchart") applyFlowchartEdgePorts(graph);
|
||||
if (usesOrthogonalDiagramEdges(document.kind)) applyFlowchartEdgePorts(graph);
|
||||
if (document.kind === "mind-map") applyMindMapHierarchy(graph, themeRef.current, appearanceRef.current, structureRef.current);
|
||||
graph.stopBatch("layout");
|
||||
ensureDiagramPaperContainsNodes(graph);
|
||||
|
||||
@@ -238,6 +238,80 @@ describe("diagram auto layout", () => {
|
||||
expect(result.viewport.minScale).toBe(0.64);
|
||||
});
|
||||
|
||||
test("keeps ungrouped architecture terminals next to the groups they connect to", () => {
|
||||
const document = compileDiagramIr({
|
||||
kind: "architecture",
|
||||
nodes: [
|
||||
{ id: "client", label: "Client", type: "client" },
|
||||
{ id: "app", label: "App", type: "boundary" },
|
||||
{ id: "web", label: "Web", type: "frontend", parentId: "app" },
|
||||
{ id: "core", label: "Core", type: "boundary" },
|
||||
{ id: "api", label: "API", type: "service", parentId: "core" },
|
||||
{ id: "out", label: "CDN", type: "external" },
|
||||
],
|
||||
edges: [
|
||||
{ source: "client", target: "web" },
|
||||
{ source: "web", target: "api" },
|
||||
{ source: "api", target: "out" },
|
||||
],
|
||||
});
|
||||
const byId = Object.fromEntries(document.nodes.map((node) => [node.id, node]));
|
||||
const center = (id) => ({ x: byId[id].x + byId[id].width / 2, y: byId[id].y + byId[id].height / 2 });
|
||||
const distance = (leftId, rightId) => {
|
||||
const left = center(leftId);
|
||||
const right = center(rightId);
|
||||
return Math.hypot(left.x - right.x, left.y - right.y);
|
||||
};
|
||||
const overlaps = (leftId, rightId, gap = 8) => {
|
||||
const left = byId[leftId];
|
||||
const right = byId[rightId];
|
||||
return left.x < right.x + right.width + gap && left.x + left.width + gap > right.x
|
||||
&& left.y < right.y + right.height + gap && left.y + left.height + gap > right.y;
|
||||
};
|
||||
expect(byId.client.x).toBeLessThan(byId.web.x);
|
||||
expect(byId.web.x).toBeLessThan(byId.api.x);
|
||||
expect(byId.api.x).toBeLessThan(byId.out.x);
|
||||
expect(distance("client", "web")).toBeLessThan(420);
|
||||
expect(distance("out", "api")).toBeLessThan(420);
|
||||
expect(overlaps("client", "app")).toBeFalse();
|
||||
expect(overlaps("out", "core")).toBeFalse();
|
||||
});
|
||||
|
||||
test("lays out architecture group internals without letting cross-group back edges stretch the group", () => {
|
||||
const document = compileDiagramIr({
|
||||
kind: "architecture",
|
||||
nodes: [
|
||||
{ id: "ingress", label: "Ingress", type: "boundary" },
|
||||
{ id: "a1", label: "Parse", type: "frontend", parentId: "ingress" },
|
||||
{ id: "a2", label: "Guard", type: "security", parentId: "ingress" },
|
||||
{ id: "runtime", label: "Runtime", type: "boundary" },
|
||||
{ id: "b1", label: "Encode", type: "storage", parentId: "runtime" },
|
||||
{ id: "b2", label: "Attend", type: "service", parentId: "runtime" },
|
||||
{ id: "b3", label: "Project", type: "service", parentId: "runtime" },
|
||||
{ id: "loop", label: "Cache", type: "database" },
|
||||
],
|
||||
edges: [
|
||||
{ source: "a1", target: "a2" },
|
||||
{ source: "a2", target: "b1" },
|
||||
{ source: "b1", target: "b2" },
|
||||
{ source: "b2", target: "b3" },
|
||||
{ source: "b3", target: "loop" },
|
||||
{ source: "loop", target: "b1" },
|
||||
],
|
||||
});
|
||||
const byId = Object.fromEntries(document.nodes.map((node) => [node.id, node]));
|
||||
const centerY = (id) => byId[id].y + byId[id].height / 2;
|
||||
expect(byId.a1.x).toBeLessThan(byId.a2.x);
|
||||
expect(Math.abs(centerY("a1") - centerY("a2"))).toBeLessThanOrEqual(24);
|
||||
expect(byId.b1.x).toBeLessThan(byId.b2.x);
|
||||
expect(byId.b2.x).toBeLessThan(byId.b3.x);
|
||||
expect(Math.abs(centerY("b1") - centerY("b3"))).toBeLessThanOrEqual(24);
|
||||
expect(byId.runtime.height).toBeLessThan(280);
|
||||
const loopCenter = { x: byId.loop.x + byId.loop.width / 2, y: byId.loop.y + byId.loop.height / 2 };
|
||||
const b3Center = { x: byId.b3.x + byId.b3.width / 2, y: byId.b3.y + byId.b3.height / 2 };
|
||||
expect(Math.hypot(loopCenter.x - b3Center.x, loopCenter.y - b3Center.y)).toBeLessThan(420);
|
||||
});
|
||||
|
||||
test("keeps flowchart viewports at reading size instead of shrinking the whole stack", () => {
|
||||
expect(getDiagramLayoutViewport("flowchart")).toEqual({
|
||||
anchor: "center",
|
||||
|
||||
@@ -112,6 +112,11 @@ const FLOWCHART_DETACHED_ROW_WIDTH = 960;
|
||||
const ARCHITECTURE_LAYOUT_ROW_WIDTH = 1480;
|
||||
const ARCHITECTURE_GROUP_HORIZONTAL_GAP = 72;
|
||||
const ARCHITECTURE_GROUP_VERTICAL_GAP = 88;
|
||||
const ARCHITECTURE_GROUP_PAD_X = 36;
|
||||
const ARCHITECTURE_GROUP_PAD_Y = 56;
|
||||
const ARCHITECTURE_LAYOUT_SPACING = { rank: 96, node: 40 };
|
||||
const ARCHITECTURE_META_SPACING = { rank: 88, node: 56 };
|
||||
const ARCHITECTURE_ORIGIN = 32;
|
||||
|
||||
export const compactArchitectureNodeSize = (
|
||||
shape: DiagramNodeShape,
|
||||
@@ -441,25 +446,62 @@ const computeMindMapLayout = (
|
||||
return layoutBranchMap(mindMapIsOneSided(document.structure), MIND_MAP_HORIZONTAL_GAP, "center");
|
||||
};
|
||||
|
||||
const architectureTopLevelBoundaryId = (
|
||||
nodeId: string,
|
||||
nodeById: Map<string, DiagramDocument["nodes"][number]>,
|
||||
) => {
|
||||
let current = nodeById.get(nodeId);
|
||||
let boundaryId: string | undefined;
|
||||
const visited = new Set<string>();
|
||||
while (current?.parentId && !visited.has(current.parentId)) {
|
||||
visited.add(current.parentId);
|
||||
const parent = nodeById.get(current.parentId);
|
||||
if (!parent) break;
|
||||
if (parent.shape === "boundary") boundaryId = parent.id;
|
||||
current = parent;
|
||||
}
|
||||
return boundaryId;
|
||||
};
|
||||
|
||||
const layoutDagreGraph = (
|
||||
nodes: Array<{ id: string; width: number; height: number }>,
|
||||
edges: Array<{ source: string; target: string }>,
|
||||
direction: "left-to-right" | "top-to-bottom",
|
||||
spacing: { rank: number; node: number } = { rank: 96, node: 40 },
|
||||
margin = { x: 32, y: 32 },
|
||||
): DiagramLayoutPositions => {
|
||||
if (nodes.length === 0) return {};
|
||||
const layoutGraph = new graphlib.Graph();
|
||||
layoutGraph.setGraph({
|
||||
rankdir: direction === "top-to-bottom" ? "TB" : "LR",
|
||||
ranksep: spacing.rank,
|
||||
nodesep: spacing.node,
|
||||
marginx: margin.x,
|
||||
marginy: margin.y,
|
||||
});
|
||||
layoutGraph.setDefaultEdgeLabel(() => ({}));
|
||||
const layoutNodeIds = new Set(nodes.map((node) => node.id));
|
||||
for (const node of nodes) layoutGraph.setNode(node.id, { width: node.width, height: node.height });
|
||||
for (const edge of edges) {
|
||||
if (edge.source === edge.target) continue;
|
||||
if (layoutNodeIds.has(edge.source) && layoutNodeIds.has(edge.target)) layoutGraph.setEdge(edge.source, edge.target);
|
||||
}
|
||||
runDagreLayout(layoutGraph);
|
||||
return Object.fromEntries(nodes.flatMap((node) => {
|
||||
const position = layoutGraph.node(node.id) as { x: number; y: number } | undefined;
|
||||
if (!position) return [];
|
||||
return [[node.id, {
|
||||
x: Math.round(position.x - node.width / 2),
|
||||
y: Math.round(position.y - node.height / 2),
|
||||
}]];
|
||||
}));
|
||||
};
|
||||
|
||||
const wrapArchitectureGroups = (
|
||||
document: DiagramDocument,
|
||||
positions: DiagramLayoutPositions,
|
||||
) => {
|
||||
const nodeById = new Map(document.nodes.map((node) => [node.id, node]));
|
||||
const topLevelBoundaryId = (nodeId: string) => {
|
||||
let current = nodeById.get(nodeId);
|
||||
let boundaryId: string | undefined;
|
||||
const visited = new Set<string>();
|
||||
while (current?.parentId && !visited.has(current.parentId)) {
|
||||
visited.add(current.parentId);
|
||||
const parent = nodeById.get(current.parentId);
|
||||
if (!parent) break;
|
||||
if (parent.shape === "boundary") boundaryId = parent.id;
|
||||
current = parent;
|
||||
}
|
||||
return boundaryId;
|
||||
};
|
||||
|
||||
const topLevelBoundaries = document.nodes.filter((node) => (
|
||||
node.shape === "boundary" && (!node.parentId || nodeById.get(node.parentId)?.shape !== "boundary")
|
||||
));
|
||||
@@ -467,7 +509,9 @@ const wrapArchitectureGroups = (
|
||||
|
||||
const groups = topLevelBoundaries.flatMap((boundary) => {
|
||||
const members = document.nodes.filter((node) => (
|
||||
node.shape !== "boundary" && topLevelBoundaryId(node.id) === boundary.id && positions[node.id]
|
||||
node.shape !== "boundary"
|
||||
&& architectureTopLevelBoundaryId(node.id, nodeById) === boundary.id
|
||||
&& positions[node.id]
|
||||
));
|
||||
if (members.length === 0) return [];
|
||||
const left = Math.min(...members.map((node) => positions[node.id].x));
|
||||
@@ -476,30 +520,32 @@ const wrapArchitectureGroups = (
|
||||
const bottom = Math.max(...members.map((node) => positions[node.id].y + node.height));
|
||||
return [{
|
||||
boundary,
|
||||
members: document.nodes.filter((node) => node.id === boundary.id || topLevelBoundaryId(node.id) === boundary.id),
|
||||
members: document.nodes.filter((node) => (
|
||||
node.id === boundary.id || architectureTopLevelBoundaryId(node.id, nodeById) === boundary.id
|
||||
)),
|
||||
left,
|
||||
top,
|
||||
width: Math.max(260, right - left + 72),
|
||||
height: Math.max(180, bottom - top + 92),
|
||||
width: Math.max(260, right - left + ARCHITECTURE_GROUP_PAD_X * 2),
|
||||
height: Math.max(180, bottom - top + ARCHITECTURE_GROUP_PAD_Y + ARCHITECTURE_GROUP_PAD_X),
|
||||
}];
|
||||
}).sort((left, right) => left.left - right.left || left.top - right.top || left.boundary.id.localeCompare(right.boundary.id));
|
||||
if (groups.length < 2) return positions;
|
||||
|
||||
const contentLeft = Math.min(...groups.map((group) => group.left - 36));
|
||||
const contentRight = Math.max(...groups.map((group) => group.left - 36 + group.width));
|
||||
const contentLeft = Math.min(...groups.map((group) => group.left - ARCHITECTURE_GROUP_PAD_X));
|
||||
const contentRight = Math.max(...groups.map((group) => group.left - ARCHITECTURE_GROUP_PAD_X + group.width));
|
||||
if (contentRight - contentLeft <= ARCHITECTURE_LAYOUT_ROW_WIDTH) return positions;
|
||||
|
||||
let cursorX = 32;
|
||||
let cursorY = 32;
|
||||
let cursorX = ARCHITECTURE_ORIGIN;
|
||||
let cursorY = ARCHITECTURE_ORIGIN;
|
||||
let rowHeight = 0;
|
||||
for (const group of groups) {
|
||||
if (cursorX > 32 && cursorX + group.width > 32 + ARCHITECTURE_LAYOUT_ROW_WIDTH) {
|
||||
cursorX = 32;
|
||||
if (cursorX > ARCHITECTURE_ORIGIN && cursorX + group.width > ARCHITECTURE_ORIGIN + ARCHITECTURE_LAYOUT_ROW_WIDTH) {
|
||||
cursorX = ARCHITECTURE_ORIGIN;
|
||||
cursorY += rowHeight + ARCHITECTURE_GROUP_VERTICAL_GAP;
|
||||
rowHeight = 0;
|
||||
}
|
||||
const deltaX = cursorX + 36 - group.left;
|
||||
const deltaY = cursorY + 56 - group.top;
|
||||
const deltaX = cursorX + ARCHITECTURE_GROUP_PAD_X - group.left;
|
||||
const deltaY = cursorY + ARCHITECTURE_GROUP_PAD_Y - group.top;
|
||||
for (const node of group.members) {
|
||||
const position = positions[node.id] ?? { x: node.x, y: node.y };
|
||||
positions[node.id] = { x: position.x + deltaX, y: position.y + deltaY };
|
||||
@@ -510,41 +556,59 @@ const wrapArchitectureGroups = (
|
||||
return positions;
|
||||
};
|
||||
|
||||
const placeUnpositionedArchitectureNodes = (
|
||||
document: DiagramDocument,
|
||||
positions: DiagramLayoutPositions,
|
||||
) => {
|
||||
const nodeById = new Map(document.nodes.map((node) => [node.id, node]));
|
||||
const detached = document.nodes.filter((node) => (
|
||||
node.shape !== "boundary"
|
||||
&& !positions[node.id]
|
||||
&& !architectureTopLevelBoundaryId(node.id, nodeById)
|
||||
)).sort((left, right) => left.y - right.y || left.x - right.x || left.id.localeCompare(right.id));
|
||||
if (detached.length === 0) return positions;
|
||||
|
||||
const placed = document.nodes.filter((node) => node.shape !== "boundary" && positions[node.id]);
|
||||
const contentLeft = placed.length > 0
|
||||
? Math.min(...placed.map((node) => positions[node.id].x))
|
||||
: ARCHITECTURE_ORIGIN;
|
||||
const contentBottom = placed.length > 0
|
||||
? Math.max(...placed.map((node) => positions[node.id].y + node.height))
|
||||
: ARCHITECTURE_ORIGIN;
|
||||
let cursorX = contentLeft;
|
||||
let cursorY = contentBottom + FLOWCHART_DETACHED_GAP;
|
||||
let rowHeight = 0;
|
||||
for (const node of detached) {
|
||||
if (cursorX > contentLeft && cursorX + node.width > contentLeft + FLOWCHART_DETACHED_ROW_WIDTH) {
|
||||
cursorX = contentLeft;
|
||||
cursorY += rowHeight + FLOWCHART_DETACHED_ROW_GAP;
|
||||
rowHeight = 0;
|
||||
}
|
||||
positions[node.id] = { x: cursorX, y: cursorY };
|
||||
cursorX += node.width + 40;
|
||||
rowHeight = Math.max(rowHeight, node.height);
|
||||
}
|
||||
return positions;
|
||||
};
|
||||
|
||||
const computeDagreLayout = (
|
||||
document: DiagramDocument,
|
||||
options: DiagramLayoutOptions,
|
||||
excludeBoundaries: boolean,
|
||||
spacing: { rank: number; node: number } = { rank: 96, node: 40 },
|
||||
): DiagramLayoutPositions => {
|
||||
const layoutGraph = new graphlib.Graph();
|
||||
layoutGraph.setGraph({
|
||||
rankdir: options.direction === "top-to-bottom" ? "TB" : "LR",
|
||||
ranksep: spacing.rank,
|
||||
nodesep: spacing.node,
|
||||
marginx: 32,
|
||||
marginy: 32,
|
||||
});
|
||||
layoutGraph.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
const layoutNodes = excludeBoundaries
|
||||
? document.nodes.filter((node) => node.shape !== "boundary")
|
||||
: document.nodes;
|
||||
const layoutNodeIds = new Set(layoutNodes.map((node) => node.id));
|
||||
for (const node of layoutNodes) layoutGraph.setNode(node.id, { width: node.width, height: node.height });
|
||||
for (const edge of document.edges) {
|
||||
if (layoutNodeIds.has(edge.source) && layoutNodeIds.has(edge.target)) layoutGraph.setEdge(edge.source, edge.target);
|
||||
const positions = layoutDagreGraph(
|
||||
layoutNodes,
|
||||
document.edges,
|
||||
options.direction === "top-to-bottom" ? "top-to-bottom" : "left-to-right",
|
||||
spacing,
|
||||
);
|
||||
for (const node of document.nodes) {
|
||||
if (node.shape === "boundary" && !positions[node.id]) positions[node.id] = { x: node.x, y: node.y };
|
||||
}
|
||||
|
||||
runDagreLayout(layoutGraph);
|
||||
const positions = Object.fromEntries(document.nodes.flatMap((node) => {
|
||||
if (node.shape === "boundary") return [[node.id, { x: node.x, y: node.y }]];
|
||||
const position = layoutGraph.node(node.id) as { x: number; y: number } | undefined;
|
||||
if (!position) return [];
|
||||
return [[node.id, {
|
||||
x: Math.round(position.x - node.width / 2),
|
||||
y: Math.round(position.y - node.height / 2),
|
||||
}]];
|
||||
}));
|
||||
return positions;
|
||||
};
|
||||
|
||||
@@ -651,12 +715,105 @@ const computeFlowchartLayout = (document: DiagramDocument, options: DiagramLayou
|
||||
return placeDetachedFlowchartNodes(document, positions);
|
||||
};
|
||||
|
||||
const computeArchitectureLayout = (document: DiagramDocument, options: DiagramLayoutOptions) => (
|
||||
wrapArchitectureGroups(
|
||||
document,
|
||||
computeDagreLayout(document, { ...options, direction: options.direction ?? "left-to-right" }, true),
|
||||
)
|
||||
);
|
||||
const computeArchitectureLayout = (document: DiagramDocument, options: DiagramLayoutOptions) => {
|
||||
const direction = options.direction ?? "left-to-right";
|
||||
const nodeById = new Map(document.nodes.map((node) => [node.id, node]));
|
||||
const topLevelBoundaries = document.nodes.filter((node) => (
|
||||
node.shape === "boundary" && (!node.parentId || nodeById.get(node.parentId)?.shape !== "boundary")
|
||||
));
|
||||
const groups = topLevelBoundaries.flatMap((boundary) => {
|
||||
const members = document.nodes.filter((node) => (
|
||||
node.shape !== "boundary" && architectureTopLevelBoundaryId(node.id, nodeById) === boundary.id
|
||||
));
|
||||
return members.length > 0 ? [{ boundary, members }] : [];
|
||||
});
|
||||
if (groups.length === 0) {
|
||||
return computeDagreLayout(document, { ...options, direction }, true, ARCHITECTURE_LAYOUT_SPACING);
|
||||
}
|
||||
|
||||
const innerByGroup = new Map<string, DiagramLayoutPositions>();
|
||||
const groupMetaNodes = groups.map((group) => {
|
||||
const memberIds = new Set(group.members.map((node) => node.id));
|
||||
const intraEdges = document.edges.filter((edge) => memberIds.has(edge.source) && memberIds.has(edge.target));
|
||||
const inner = layoutDagreGraph(group.members, intraEdges, direction, ARCHITECTURE_LAYOUT_SPACING, { x: 0, y: 0 });
|
||||
const left = Math.min(...group.members.map((node) => inner[node.id].x));
|
||||
const top = Math.min(...group.members.map((node) => inner[node.id].y));
|
||||
const normalized = Object.fromEntries(group.members.map((node) => [node.id, {
|
||||
x: inner[node.id].x - left,
|
||||
y: inner[node.id].y - top,
|
||||
}]));
|
||||
innerByGroup.set(group.boundary.id, normalized);
|
||||
const width = Math.max(...group.members.map((node) => normalized[node.id].x + node.width));
|
||||
const height = Math.max(...group.members.map((node) => normalized[node.id].y + node.height));
|
||||
return {
|
||||
id: group.boundary.id,
|
||||
width: Math.max(260, width + ARCHITECTURE_GROUP_PAD_X * 2),
|
||||
height: Math.max(180, height + ARCHITECTURE_GROUP_PAD_Y + ARCHITECTURE_GROUP_PAD_X),
|
||||
};
|
||||
});
|
||||
|
||||
const connectedIds = new Set<string>();
|
||||
for (const edge of document.edges) {
|
||||
connectedIds.add(edge.source);
|
||||
connectedIds.add(edge.target);
|
||||
}
|
||||
const connectedFree = document.nodes.filter((node) => (
|
||||
node.shape !== "boundary"
|
||||
&& !architectureTopLevelBoundaryId(node.id, nodeById)
|
||||
&& connectedIds.has(node.id)
|
||||
));
|
||||
const metaNodes = [
|
||||
...groupMetaNodes,
|
||||
...connectedFree.map((node) => ({ id: node.id, width: node.width, height: node.height })),
|
||||
];
|
||||
const metaNodeIds = new Set(metaNodes.map((node) => node.id));
|
||||
const metaId = (nodeId: string) => {
|
||||
const groupId = architectureTopLevelBoundaryId(nodeId, nodeById);
|
||||
if (groupId && metaNodeIds.has(groupId)) return groupId;
|
||||
return metaNodeIds.has(nodeId) ? nodeId : undefined;
|
||||
};
|
||||
const seenMetaEdges = new Set<string>();
|
||||
const metaEdges: Array<{ source: string; target: string }> = [];
|
||||
for (const edge of document.edges) {
|
||||
const sourceId = metaId(edge.source);
|
||||
const targetId = metaId(edge.target);
|
||||
if (!sourceId || !targetId || sourceId === targetId) continue;
|
||||
const key = `${sourceId}\0${targetId}`;
|
||||
if (seenMetaEdges.has(key)) continue;
|
||||
seenMetaEdges.add(key);
|
||||
metaEdges.push({ source: sourceId, target: targetId });
|
||||
}
|
||||
|
||||
const metaMargin = { x: ARCHITECTURE_ORIGIN, y: ARCHITECTURE_ORIGIN };
|
||||
const metaLr = layoutDagreGraph(metaNodes, metaEdges, direction, ARCHITECTURE_META_SPACING, metaMargin);
|
||||
const lrWidth = Math.max(...metaNodes.map((node) => metaLr[node.id].x + node.width))
|
||||
- Math.min(...metaNodes.map((node) => metaLr[node.id].x));
|
||||
const metaPositions = groups.length > 1 && lrWidth > ARCHITECTURE_LAYOUT_ROW_WIDTH
|
||||
? layoutDagreGraph(metaNodes, metaEdges, "top-to-bottom", ARCHITECTURE_META_SPACING, metaMargin)
|
||||
: metaLr;
|
||||
|
||||
const positions: DiagramLayoutPositions = Object.fromEntries(
|
||||
document.nodes.filter((node) => node.shape === "boundary").map((node) => [node.id, { x: node.x, y: node.y }]),
|
||||
);
|
||||
for (const group of groups) {
|
||||
const inner = innerByGroup.get(group.boundary.id)!;
|
||||
const origin = metaPositions[group.boundary.id];
|
||||
for (const member of group.members) {
|
||||
positions[member.id] = {
|
||||
x: origin.x + ARCHITECTURE_GROUP_PAD_X + inner[member.id].x,
|
||||
y: origin.y + ARCHITECTURE_GROUP_PAD_Y + inner[member.id].y,
|
||||
};
|
||||
}
|
||||
}
|
||||
for (const node of connectedFree) {
|
||||
const position = metaPositions[node.id];
|
||||
if (position) positions[node.id] = position;
|
||||
}
|
||||
|
||||
wrapArchitectureGroups(document, positions);
|
||||
placeUnpositionedArchitectureNodes(document, positions);
|
||||
return positions;
|
||||
};
|
||||
|
||||
const irNodeShape = (kind: DiagramKind, type: DiagramIrNodeType | undefined): DiagramNodeShape => {
|
||||
if (kind === "mind-map") return "topic";
|
||||
@@ -723,6 +880,16 @@ const finalizeArchitectureLayout = (document: DiagramDocument) => {
|
||||
}
|
||||
placed.push(boundary);
|
||||
}
|
||||
|
||||
const minX = Math.min(...document.nodes.map((node) => node.x));
|
||||
const minY = Math.min(...document.nodes.map((node) => node.y));
|
||||
const deltaX = minX < ARCHITECTURE_ORIGIN ? ARCHITECTURE_ORIGIN - minX : 0;
|
||||
const deltaY = minY < ARCHITECTURE_ORIGIN ? ARCHITECTURE_ORIGIN - minY : 0;
|
||||
if (deltaX === 0 && deltaY === 0) return;
|
||||
for (const node of document.nodes) {
|
||||
node.x += deltaX;
|
||||
node.y += deltaY;
|
||||
}
|
||||
};
|
||||
|
||||
export type DiagramLayoutStrategy = {
|
||||
|
||||
@@ -165,24 +165,24 @@ export const diagramDocumentToX6Cells = (
|
||||
?? mindEdge?.stroke
|
||||
?? flowchartSurface?.edge
|
||||
?? palette.flowEdge;
|
||||
const flowchartPorts = document.kind === "flowchart" && sourceNode && targetNode
|
||||
const orthogonalPorts = (document.kind === "flowchart" || document.kind === "architecture") && sourceNode && targetNode
|
||||
? flowchartEdgePorts(sourceNode, targetNode)
|
||||
: null;
|
||||
const flowchartStraight = Boolean(flowchartPorts && sourceNode && targetNode && flowchartEdgeIsStraight(sourceNode, targetNode));
|
||||
const orthogonalStraight = Boolean(orthogonalPorts && sourceNode && targetNode && flowchartEdgeIsStraight(sourceNode, targetNode));
|
||||
return {
|
||||
id: edge.id,
|
||||
source: document.kind === "mind-map"
|
||||
? { cell: edge.source, ...(sourceTerminal ?? { anchor: { name: sides.source } }) }
|
||||
: flowchartPorts
|
||||
? { cell: edge.source, port: flowchartPorts.source }
|
||||
: orthogonalPorts
|
||||
? { cell: edge.source, port: orthogonalPorts.source }
|
||||
: { cell: edge.source },
|
||||
target: document.kind === "mind-map"
|
||||
? { cell: edge.target, ...(targetTerminal ?? { anchor: { name: sides.target } }) }
|
||||
: flowchartPorts
|
||||
? { cell: edge.target, port: flowchartPorts.target }
|
||||
: orthogonalPorts
|
||||
? { cell: edge.target, port: orthogonalPorts.target }
|
||||
: { cell: edge.target },
|
||||
router: document.kind === "flowchart"
|
||||
? (flowchartStraight ? { name: "normal" } : FLOWCHART_EDGE_ROUTER)
|
||||
router: document.kind === "flowchart" || document.kind === "architecture"
|
||||
? (orthogonalStraight ? { name: "normal" } : FLOWCHART_EDGE_ROUTER)
|
||||
: undefined,
|
||||
connector: document.kind === "mind-map"
|
||||
? {
|
||||
|
||||
@@ -184,6 +184,9 @@ describe("diagram document", () => {
|
||||
expect(dark.nodes.find((node) => node.id === "api").attrs.body.fill).not.toBe(
|
||||
dark.nodes.find((node) => node.id === "database").attrs.body.fill,
|
||||
);
|
||||
expect(projected.edges[0].source.port).toBeDefined();
|
||||
expect(projected.edges[0].target.port).toBeDefined();
|
||||
expect(["normal", "manhattan"]).toContain(projected.edges[0].router.name);
|
||||
});
|
||||
|
||||
test("rejects malformed and dangling graph data", () => {
|
||||
|
||||
Reference in new issue
Block a user