diff --git a/apps/web/src/components/DiagramEditorPane.tsx b/apps/web/src/components/DiagramEditorPane.tsx index 85d473de..f12c3ead 100644 --- a/apps/web/src/components/DiagramEditorPane.tsx +++ b/apps/web/src/components/DiagramEditorPane.tsx @@ -116,6 +116,7 @@ import { ARCHITECTURE_NODE_LINE_HEIGHT, ARCHITECTURE_SHAPE_RESOURCE, architectureEdgePorts, + architectureEdgeRouter, architectureEdgeVisual, architectureIconOffset, architectureNodeVisual, @@ -1348,7 +1349,9 @@ const edgeMetadata = ( id: edge.id, source: { cell: edge.source }, target: { cell: edge.target }, - router: usesOrthogonalDiagramEdges(kind) ? FLOWCHART_EDGE_ROUTER : undefined, + router: kind === "architecture" + ? architectureEdgeRouter(edge.source, edge.target, []) + : 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 } }, @@ -1370,6 +1373,9 @@ const edgeMetadata = ( }; const applyOrthogonalEdgePorts = (graph: Graph, kind: DiagramDocument["kind"]) => { + const boundaryIds = kind === "architecture" + ? graph.getNodes().filter((node) => node.getData()?.shape === "boundary").map((node) => node.id) + : []; for (const edge of graph.getEdges()) { const source = edge.getSourceNode(); const target = edge.getTargetNode(); @@ -1381,7 +1387,9 @@ const applyOrthogonalEdgePorts = (graph: Graph, kind: DiagramDocument["kind"]) = : flowchartEdgePorts(sourceBox, targetBox); edge.setSource({ cell: source.id, port: ports.source }); edge.setTarget({ cell: target.id, port: ports.target }); - edge.setRouter(flowchartEdgeIsStraight(sourceBox, targetBox) ? { name: "normal" } : FLOWCHART_EDGE_ROUTER); + edge.setRouter(kind === "architecture" + ? architectureEdgeRouter(source.id, target.id, boundaryIds) + : flowchartEdgeIsStraight(sourceBox, targetBox) ? { name: "normal" } : FLOWCHART_EDGE_ROUTER); } }; @@ -1949,7 +1957,9 @@ export const DiagramEditorPane = ({ allowMulti: false, highlight: isConnectableDiagram(document.kind), snap: { radius: 24 }, - router: usesOrthogonalDiagramEdges(document.kind) ? FLOWCHART_EDGE_ROUTER : "normal", + router: document.kind === "architecture" + ? architectureEdgeRouter("", "", []) + : 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; @@ -2053,6 +2063,16 @@ export const DiagramEditorPane = ({ }, SCROLLER_AUTORESIZE_SETTLE_MS); const updateHistory = () => setHistoryState({ undo: graph.canUndo(), redo: graph.canRedo() }); + const refreshArchitecturePorts = () => { + if (document.kind !== "architecture") return; + const historyEnabled = graph.isHistoryEnabled(); + if (historyEnabled) graph.disableHistory(); + try { + applyOrthogonalEdgePorts(graph, document.kind); + } finally { + if (historyEnabled) graph.enableHistory(); + } + }; const markDirty = () => { if (viewOnlyRef.current) return; if (!readOnly) { @@ -2064,6 +2084,7 @@ export const DiagramEditorPane = ({ updateHistory(); }; const clearSelectionAfterHistory = () => { + refreshArchitecturePorts(); applyGraphPalette(graph, themeRef.current, document.kind, appearanceRef.current, structureRef.current); graph.cleanSelection(); if (isConnectableDiagram(document.kind)) setOnlyFlowNodePortsActive(graph); @@ -2100,6 +2121,10 @@ export const DiagramEditorPane = ({ }); graph.on("node:dblclick", ({ node }: { node: Node }) => beginNodeEdit(node)); graph.on("node:mouseup", () => { + if (document.kind === "architecture") { + refreshArchitecturePorts(); + return; + } if (document.kind !== "mind-map") return; const historyEnabled = graph.isHistoryEnabled(); if (historyEnabled) graph.disableHistory(); diff --git a/packages/shared/src/diagram-architecture-style.test.mjs b/packages/shared/src/diagram-architecture-style.test.mjs index 15cce1cc..8d761fd6 100644 --- a/packages/shared/src/diagram-architecture-style.test.mjs +++ b/packages/shared/src/diagram-architecture-style.test.mjs @@ -7,6 +7,7 @@ import { ARCHITECTURE_EDGE_LABEL_LINE_HEIGHT, ARCHITECTURE_SURFACES, architectureEdgePorts, + architectureEdgeRouter, architectureEdgeVisual, architectureIconElements, architectureMermaidClassDefs, @@ -42,6 +43,19 @@ describe("architecture semantic paint", () => { expect(architectureEdgePorts(lowerLeft, upperRight)).toEqual({ source: "right", target: "left" }); }); + test("exits leftward connections from the side facing their target", () => { + const source = { x: 520, y: 340, width: 156, height: 64 }; + const target = { x: 240, y: 700, width: 156, height: 64 }; + expect(architectureEdgePorts(source, target)).toEqual({ source: "left", target: "right" }); + }); + + test("keeps architecture endpoints and group containers out of obstacle routing", () => { + const router = architectureEdgeRouter("from", "to", ["group", "group"]); + expect(router.name).toBe("manhattan"); + expect(router.args.excludeNodes).toEqual(["from", "to", "group"]); + expect(router.args.padding).toBeGreaterThan(0); + }); + test("keeps every component fill distinct in light and dark", () => { for (const appearance of ["light", "dark"]) { const fills = ARCHITECTURE_COMPONENT_SHAPES.map((shape) => resolveArchitectureSurface(appearance).nodes[shape].fill); diff --git a/packages/shared/src/diagram-architecture-style.ts b/packages/shared/src/diagram-architecture-style.ts index 952c594a..5bb51919 100644 --- a/packages/shared/src/diagram-architecture-style.ts +++ b/packages/shared/src/diagram-architecture-style.ts @@ -23,6 +23,22 @@ export type ArchitectureAppearance = "light" | "dark"; export type ArchitectureEdgePortName = "top" | "right" | "bottom" | "left"; export type ArchitectureEdgeBox = { x: number; y: number; width: number; height: number }; +export const architectureEdgeRouter = ( + sourceId: string, + targetId: string, + boundaryIds: string[], +) => ({ + name: "manhattan" as const, + args: { + padding: 12, + step: 8, + maxLoopCount: 5000, + // Boundaries are visual containers; components and endpoints are the obstacles. + // Per-edge exclusions also keep X6's shared obstacle map specific to this edge. + excludeNodes: [...new Set([sourceId, targetId, ...boundaryIds])], + }, +}); + export const architectureEdgePorts = ( source: ArchitectureEdgeBox, target: ArchitectureEdgeBox, @@ -30,7 +46,7 @@ export const architectureEdgePorts = ( const sourceRight = source.x + source.width; const targetRight = target.x + target.width; if (target.x >= sourceRight) return { source: "right", target: "left" }; - if (targetRight <= source.x) return { source: "top", target: "top" }; + if (targetRight <= source.x) return { source: "left", target: "right" }; const sourceCenterY = source.y + source.height / 2; const targetCenterY = target.y + target.height / 2; diff --git a/packages/shared/src/diagram-view.ts b/packages/shared/src/diagram-view.ts index 2695770c..39332012 100644 --- a/packages/shared/src/diagram-view.ts +++ b/packages/shared/src/diagram-view.ts @@ -4,6 +4,7 @@ import { ARCHITECTURE_EDGE_LABEL_LINE_HEIGHT, ARCHITECTURE_LABEL_FONT, architectureEdgePorts, + architectureEdgeRouter, architectureEdgeVisual, architectureNodeVisual, resolveArchitectureSurface, @@ -147,6 +148,7 @@ export const diagramDocumentToX6Cells = ( }; }); const projectedById = new Map(nodes.map((node) => [node.id, node])); + const architectureBoundaryIds = document.nodes.filter((node) => node.shape === "boundary").map((node) => node.id); const edges = document.edges.map((edge) => { const edgeKind = edge.kind ?? (document.kind === "architecture" ? "dependency" : undefined); const sourceNode = projectedById.get(edge.source); @@ -183,7 +185,8 @@ export const diagramDocumentToX6Cells = ( ? flowchartEdgePorts(sourceNode, targetNode) : null : null; - const orthogonalStraight = Boolean(orthogonalPorts && sourceNode && targetNode && flowchartEdgeIsStraight(sourceNode, targetNode)); + const orthogonalStraight = document.kind === "flowchart" + && Boolean(orthogonalPorts && sourceNode && targetNode && flowchartEdgeIsStraight(sourceNode, targetNode)); return { id: edge.id, source: document.kind === "mind-map" @@ -196,9 +199,11 @@ export const diagramDocumentToX6Cells = ( : orthogonalPorts ? { cell: edge.target, port: orthogonalPorts.target } : { cell: edge.target }, - router: document.kind === "flowchart" || document.kind === "architecture" - ? (orthogonalStraight ? { name: "normal" } : FLOWCHART_EDGE_ROUTER) - : undefined, + router: document.kind === "architecture" + ? architectureEdgeRouter(edge.source, edge.target, architectureBoundaryIds) + : document.kind === "flowchart" + ? (orthogonalStraight ? { name: "normal" } : FLOWCHART_EDGE_ROUTER) + : undefined, connector: document.kind === "mind-map" ? { name: MIND_MAP_CONNECTOR_NAME, diff --git a/packages/shared/src/diagram.test.mjs b/packages/shared/src/diagram.test.mjs index e41ab1f6..49949ca8 100644 --- a/packages/shared/src/diagram.test.mjs +++ b/packages/shared/src/diagram.test.mjs @@ -194,6 +194,21 @@ describe("diagram document", () => { expect(projectedEdge.target.port).toBe("left"); }); + test("routes aligned architecture edges around intervening components", () => { + const document = createDefaultDiagramDocument("architecture"); + document.nodes.find((node) => node.id === "client").x = 32; + document.nodes.find((node) => node.id === "client").y = 32; + document.nodes.find((node) => node.id === "api").x = 32; + document.nodes.find((node) => node.id === "api").y = 400; + document.nodes.find((node) => node.id === "database").x = 32; + document.nodes.find((node) => node.id === "database").y = 216; + const projected = diagramDocumentToX6Cells(document, "light"); + const edge = projected.edges.find((item) => item.source.cell === "client" && item.target.cell === "api"); + expect(edge.router.name).toBe("manhattan"); + expect(edge.router.args.excludeNodes).toContain("system"); + expect(edge.router.args.excludeNodes).not.toContain("database"); + }); + test("projects architecture edge labels below component typography", () => { const document = createDefaultDiagramDocument("architecture"); const projectedLabel = diagramDocumentToX6Cells(document, "light").edges[0].labels[0].attrs.label;