From 04439b5a1ae25948e69bba02585bf43bce8d65e1 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Thu, 10 Sep 2026 16:50:44 +0800 Subject: [PATCH] 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. --- apps/web/src/components/DiagramEditorPane.tsx | 11 +- apps/web/src/lib/diagram-layout.test.mjs | 74 +++++ packages/shared/src/diagram-layout.ts | 281 ++++++++++++++---- packages/shared/src/diagram-view.ts | 16 +- packages/shared/src/diagram.test.mjs | 3 + 5 files changed, 315 insertions(+), 70 deletions(-) diff --git a/apps/web/src/components/DiagramEditorPane.tsx b/apps/web/src/components/DiagramEditorPane.tsx index a7eea945..0b790443 100644 --- a/apps/web/src/components/DiagramEditorPane.tsx +++ b/apps/web/src/components/DiagramEditorPane.tsx @@ -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> = { 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); diff --git a/apps/web/src/lib/diagram-layout.test.mjs b/apps/web/src/lib/diagram-layout.test.mjs index b460aaec..6fc3fa38 100644 --- a/apps/web/src/lib/diagram-layout.test.mjs +++ b/apps/web/src/lib/diagram-layout.test.mjs @@ -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", diff --git a/packages/shared/src/diagram-layout.ts b/packages/shared/src/diagram-layout.ts index 2d34f024..65d379f8 100644 --- a/packages/shared/src/diagram-layout.ts +++ b/packages/shared/src/diagram-layout.ts @@ -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, +) => { + let current = nodeById.get(nodeId); + let boundaryId: string | undefined; + const visited = new Set(); + 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(); - 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(); + 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(); + 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(); + 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 = { diff --git a/packages/shared/src/diagram-view.ts b/packages/shared/src/diagram-view.ts index 923cbdb6..d718c6e2 100644 --- a/packages/shared/src/diagram-view.ts +++ b/packages/shared/src/diagram-view.ts @@ -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" ? { diff --git a/packages/shared/src/diagram.test.mjs b/packages/shared/src/diagram.test.mjs index 4708786e..a09cf612 100644 --- a/packages/shared/src/diagram.test.mjs +++ b/packages/shared/src/diagram.test.mjs @@ -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", () => {