From 43e5675d0a575356eca9663fcd179e9404cf11eb Mon Sep 17 00:00:00 2001 From: tianma-if Date: Sun, 4 Oct 2026 23:46:53 +0800 Subject: [PATCH] fix(diagram): balance architecture group layouts --- apps/web/src/lib/diagram-layout.test.mjs | 29 ++++++++++++++ packages/shared/src/diagram-layout.ts | 50 +++++++++++++++++++++++- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/apps/web/src/lib/diagram-layout.test.mjs b/apps/web/src/lib/diagram-layout.test.mjs index e55bae6f..498f788e 100644 --- a/apps/web/src/lib/diagram-layout.test.mjs +++ b/apps/web/src/lib/diagram-layout.test.mjs @@ -286,6 +286,35 @@ describe("diagram auto layout", () => { expect(new Set(boundaries.map((node) => node.y)).size).toBeGreaterThan(1); }); + test("uses two rows of architecture groups when a single column wastes horizontal space", () => { + const nodes = []; + const edges = []; + let previousId; + for (let groupIndex = 0; groupIndex < 4; groupIndex += 1) { + const boundaryId = `group-${groupIndex}`; + nodes.push({ id: boundaryId, label: boundaryId, type: "boundary" }); + for (let nodeIndex = 0; nodeIndex < 2; nodeIndex += 1) { + const id = `${boundaryId}-node-${nodeIndex}`; + nodes.push({ id, label: id, type: "service", parentId: boundaryId }); + if (previousId) edges.push({ source: previousId, target: id }); + previousId = id; + } + } + const document = compileDiagramIr({ kind: "architecture", nodes, edges }); + const boundaries = document.nodes.filter((node) => node.shape === "boundary"); + const width = Math.max(...boundaries.map((node) => node.x + node.width)) + - Math.min(...boundaries.map((node) => node.x)); + expect(width).toBeGreaterThan(900); + expect(width).toBeLessThanOrEqual(1480); + expect(new Set(boundaries.map((node) => node.y)).size).toBe(2); + const relayout = computeDiagramLayoutResult(document); + const relayoutWidth = Math.max(...boundaries.map((node) => relayout.nodes[node.id].x + relayout.nodes[node.id].width)) + - Math.min(...boundaries.map((node) => relayout.nodes[node.id].x)); + expect(relayoutWidth).toBeGreaterThan(900); + const vertical = computeDiagramLayoutResult(document, { direction: "top-to-bottom" }); + expect(vertical.nodes["group-1"].y).toBeGreaterThan(vertical.nodes["group-0"].y); + }); + test("returns a complete strategy result for every diagram kind", () => { for (const kind of ["mind-map", "flowchart", "architecture"]) { const document = createDefaultDiagramDocument(kind); diff --git a/packages/shared/src/diagram-layout.ts b/packages/shared/src/diagram-layout.ts index 0805936f..c8837153 100644 --- a/packages/shared/src/diagram-layout.ts +++ b/packages/shared/src/diagram-layout.ts @@ -115,6 +115,7 @@ const FLOWCHART_DETACHED_GAP = 56; const FLOWCHART_DETACHED_ROW_GAP = 24; const FLOWCHART_DETACHED_ROW_WIDTH = 960; const ARCHITECTURE_LAYOUT_ROW_WIDTH = 1480; +const ARCHITECTURE_LAYOUT_TARGET_RATIO = 1.35; const ARCHITECTURE_GROUP_HORIZONTAL_GAP = 72; const ARCHITECTURE_GROUP_VERTICAL_GAP = 88; const ARCHITECTURE_GROUP_PAD_X = 36; @@ -519,6 +520,52 @@ const layoutArchitectureGraph = ( return width(vertical) < width(positions) ? vertical : positions; }; +const layoutArchitectureMetaGraph = ( + nodes: Array<{ id: string; width: number; height: number }>, + edges: Array<{ source: string; target: string }>, + direction: "left-to-right" | "top-to-bottom", + margin: { x: number; y: number }, +): DiagramLayoutPositions => { + const vertical = layoutDagreGraph(nodes, edges, "top-to-bottom", ARCHITECTURE_META_SPACING, margin); + if (direction === "top-to-bottom" || nodes.length === 0) return vertical; + const horizontal = layoutDagreGraph(nodes, edges, "left-to-right", ARCHITECTURE_META_SPACING, margin); + const size = (positions: DiagramLayoutPositions) => ({ + width: Math.max(...nodes.map((node) => positions[node.id].x + node.width)) + - Math.min(...nodes.map((node) => positions[node.id].x)), + height: Math.max(...nodes.map((node) => positions[node.id].y + node.height)) + - Math.min(...nodes.map((node) => positions[node.id].y)), + }); + if (size(horizontal).width <= ARCHITECTURE_LAYOUT_ROW_WIDTH) return horizontal; + if (nodes.length < 3) return vertical; + + const wrapped: DiagramLayoutPositions = {}; + let x = margin.x; + let y = margin.y; + let rowHeight = 0; + for (const node of [...nodes].sort((left, right) => ( + horizontal[left.id].x - horizontal[right.id].x + || horizontal[left.id].y - horizontal[right.id].y + || left.id.localeCompare(right.id) + ))) { + if (x > margin.x && x + node.width > margin.x + ARCHITECTURE_LAYOUT_ROW_WIDTH) { + x = margin.x; + y += rowHeight + ARCHITECTURE_GROUP_VERTICAL_GAP; + rowHeight = 0; + } + wrapped[node.id] = { x, y }; + x += node.width + ARCHITECTURE_GROUP_HORIZONTAL_GAP; + rowHeight = Math.max(rowHeight, node.height); + } + const fitCost = (positions: DiagramLayoutPositions) => { + const bounds = size(positions); + const targetHeight = ARCHITECTURE_LAYOUT_ROW_WIDTH / ARCHITECTURE_LAYOUT_TARGET_RATIO; + const overflow = Math.max(bounds.width / ARCHITECTURE_LAYOUT_ROW_WIDTH, bounds.height / targetHeight); + const aspectError = Math.abs(Math.log((bounds.width / bounds.height) / ARCHITECTURE_LAYOUT_TARGET_RATIO)); + return overflow + aspectError * 0.2; + }; + return fitCost(wrapped) < fitCost(vertical) ? wrapped : vertical; +}; + const wrapArchitectureGroups = ( document: DiagramDocument, positions: DiagramLayoutPositions, @@ -869,11 +916,10 @@ const computeArchitectureLayout = (document: DiagramDocument, options: DiagramLa } const metaMargin = { x: ARCHITECTURE_ORIGIN, y: ARCHITECTURE_ORIGIN }; - const metaPositions = layoutArchitectureGraph( + const metaPositions = layoutArchitectureMetaGraph( metaNodes, metaEdges, direction, - ARCHITECTURE_META_SPACING, metaMargin, );