diff --git a/packages/merman/src/flowchart/drawing.ts b/packages/merman/src/flowchart/drawing.ts index b5717863e78..1582ad5a463 100644 --- a/packages/merman/src/flowchart/drawing.ts +++ b/packages/merman/src/flowchart/drawing.ts @@ -15,7 +15,7 @@ import { mergeDiagramLineGlyph, } from "../core/drawing.js" import { layoutFlowchartDiagram, visualLength } from "./layout.js" -import { flowchartEdgeLabelLayout } from "./labels.js" +import { flowchartRouteLabelLayout } from "./labels.js" import type { FlowchartDiagramRenderOptions } from "./options.js" import { flowchartDirectionBetween, flowchartSourceConnector } from "./routing.js" import { @@ -43,7 +43,7 @@ function mergeFlowchartCell( if (incoming.style !== "edge") return incoming if (existing.style === "label") return existing if (incoming.char === " ") return existing - if (existing.style !== "edge" || existing.char === " ") return incoming + if ((existing.style !== "edge" && existing.style !== "group") || existing.char === " ") return incoming if (DIAGRAM_ARROW_HEADS.has(existing.char) || DIAGRAM_ARROW_HEADS.has(incoming.char)) return incoming return { @@ -164,7 +164,7 @@ function drawSubgraphLabel(grid: FlowchartGrid, bounds: FlowchartSubgraphBounds) } function drawEdgeLabel(grid: FlowchartGrid, route: FlowchartEdgeRoute, style: FlowchartCellStyle): void { - const label = flowchartEdgeLabelLayout(route.points, route.edge.label, visualLength, route.labelAxis) + const label = flowchartRouteLabelLayout(route, visualLength) for (const [index, line] of parseDiagramTextLines(route.edge.label).entries()) { grid.setText(label.point.x, label.point.y + index, " ", style) const width = setRichText(grid, label.point.x + 1, label.point.y + index, line.runs, style) @@ -185,6 +185,10 @@ function drawRoutedEdge(grid: FlowchartGrid, route: FlowchartEdgeRoute): void { const end = points[points.length - 1]! const arrowFrom = points[points.length - 2]! grid.setCell(end.x, end.y, diagramArrowHeadBetween(arrowFrom, end), style) + } else { + const end = points[points.length - 1]! + const endDirection = flowchartDirectionBetween(points[points.length - 2]!, end) + if (endDirection) grid.setCell(end.x, end.y, diagramLineGlyph(new Set([endDirection])), style) } if (edge.label) { drawEdgeLabel(grid, route, "label") @@ -266,7 +270,7 @@ function drawSourceConnectors( const connectorDirection = flowchartDirectionBetween(sourcePoint, connector) if (routeDirection && connectorDirection) { const cell = grid.getCell(sourcePoint.x, sourcePoint.y) - if (cell) { + if (cell && cell.style !== "label") { grid.replaceCell( sourcePoint.x, sourcePoint.y, diff --git a/packages/merman/src/flowchart/flowchart.test.ts b/packages/merman/src/flowchart/flowchart.test.ts index 6eee30f2a98..99c8229f495 100644 --- a/packages/merman/src/flowchart/flowchart.test.ts +++ b/packages/merman/src/flowchart/flowchart.test.ts @@ -7,10 +7,12 @@ import { DEFAULT_MIN_RANK_GAP, DEFAULT_MIN_VERTICAL_RANK_GAP, layoutFlowchartDiagram as layoutParsedFlowchartDiagram, + visualLength, } from "./layout.js" -import { flowchartEdgeLabelLayout } from "./labels.js" +import { flowchartEdgeLabelLayout, flowchartRouteLabelLayout } from "./labels.js" import { parseMermaidFlowchartDiagram } from "./parser.js" import { renderFlowchartDiagram } from "./render.js" +import { flowchartSourceConnector } from "./routing.js" import { renderGridStyledText, resolveFlowchartStyleColors } from "./style.js" function drawFlowchartDiagramGrid(content: string, options?: Parameters[1]) { @@ -112,6 +114,66 @@ function boundsIntersect( ) } +function expectFlowchartRoutesAvoidUnrelatedNodes(layout: ReturnType): void { + for (const route of layout.routes) { + for (const [id, bounds] of layout.bounds) { + if (id === route.edge.from || id === route.edge.to) continue + expect(routeIntersectsBounds(route, bounds)).toBe(false) + } + } +} + +function expectFinalFlowchartLabelLayoutUnobstructed(layout: ReturnType) { + const labels = layout.routes.map((route) => { + const label = flowchartRouteLabelLayout(route, visualLength) + return { + route, + label, + bounds: { left: label.point.x, top: label.point.y, width: label.width, height: label.height }, + } + }) + + for (const [index, label] of labels.entries()) { + for (const bounds of layout.bounds.values()) expect(boundsIntersect(label.bounds, bounds)).toBe(false) + for (const [otherIndex, other] of labels.entries()) { + if (otherIndex !== index) expect(boundsIntersect(label.bounds, other.bounds)).toBe(false) + } + const textBounds = { ...label.bounds, left: label.bounds.left + 1, width: label.bounds.width - 2 } + for (const other of layout.routes) { + if (other === label.route) continue + expect(routeIntersectsBounds(other, textBounds)).toBe(false) + const source = layout.bounds.get(other.edge.from) + const sourcePoint = other.points[0] + if (!source || !sourcePoint) continue + const connector = flowchartSourceConnector(source, sourcePoint) + expect(boundsIntersect(label.bounds, { left: connector.x, top: connector.y, width: 1, height: 1 })).toBe(false) + expect(boundsIntersect(label.bounds, { left: sourcePoint.x, top: sourcePoint.y, width: 1, height: 1 })).toBe( + false, + ) + } + } + return labels +} + +function expectFlowchartLabelsUnobstructed(content: string): ReturnType { + const output = renderFlowchartDiagram(content) + const layout = layoutFlowchartDiagram(content) + const labels = expectFinalFlowchartLabelLayoutUnobstructed(layout) + const grid = drawFlowchartDiagramGrid(content) + + for (const label of labels) { + const escaped = label.route.edge.label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + expect(output.match(new RegExp(`(? { test("renders compact horizontal flowcharts with shorter routes", () => { const output = renderFlowchartDiagram( @@ -331,11 +393,248 @@ describe("FlowchartDiagram", () => { A[Source] -->|first| B[Target] A -->|second| B` const output = renderFlowchartDiagram(content) + const parallel = layoutFlowchartDiagram(content).routes expect(output).toContain("first") expect(output).toContain("second") - expect(output.match(/▶/g)).toHaveLength(1) - expect(output.match(/▲/g)).toHaveLength(1) + expect(output).toMatch(/[▲▼◀▶]/) + expect(new Set(parallel.map((route) => JSON.stringify(route.points))).size).toBe(2) + }) + + test("keeps reciprocal edge labels disjoint from nodes, labels, and other routes", () => { + const content = `flowchart TD + A[A] -->|forward_label| B[B] + B -->|backward_label| A` + + expectFlowchartLabelsUnobstructed(content) + }) + + test("keeps labeled parallel BT edges on distinct unobstructed routes", () => { + const content = `flowchart BT + F3_1[Node F3_1] + F3_4[Node F3_4] + F3_3[Node F3_3] + F3_2[Node F3_2] + F3_3 -->|flow_edge_3_0| F3_4 + F3_2 -->|flow_edge_3_1| F3_3 + F3_1 -->|flow_edge_3_5| F3_4 + F3_1 -->|flow_edge_3_6| F3_4 + F3_2 -->|flow_edge_3_7| F3_1 + F3_1 -->|flow_edge_3_8| F3_4` + const layout = expectFlowchartLabelsUnobstructed(content) + const parallel = layout.routes.filter((route) => route.edge.from === "F3_1" && route.edge.to === "F3_4") + + expect(parallel).toHaveLength(3) + expect(new Set(parallel.map((route) => JSON.stringify(route.points))).size).toBe(3) + }) + + test("keeps every label in the cyclic parallel BT fuzz fixture", () => { + const content = `flowchart BT + F3_0[Node F3_0] + F3_1[Node F3_1] + F3_2[Node F3_2] + F3_3[Node F3_3] + F3_4[Node F3_4] + F3_0 -->|flow_edge_3_0| F3_1 + F3_1 -->|flow_edge_3_1| F3_2 + F3_2 -->|flow_edge_3_2| F3_3 + F3_3 -->|flow_edge_3_3| F3_4 + F3_0 -->|flow_edge_3_4| F3_4 + F3_0 -->|flow_edge_3_5| F3_0 + F3_1 -->|flow_edge_3_6| F3_4 + F3_3 -->|flow_edge_3_7| F3_0 + F3_1 -->|flow_edge_3_8| F3_4 + F3_3 -->|flow_edge_3_9| F3_0` + + expectFlowchartLabelsUnobstructed(content) + }) + + test("keeps every label in the cyclic parallel TB fuzz fixture", () => { + const content = `flowchart TB + F9_0[Node F9_0] + F9_1[Node F9_1] + F9_2[Node F9_2] + F9_3[Node F9_3] + F9_4[Node F9_4] + F9_5[Node F9_5] + F9_6[Node F9_6] + F9_0 -->|flow_edge_9_0| F9_1 + F9_1 -->|flow_edge_9_1| F9_2 + F9_2 -->|flow_edge_9_2| F9_3 + F9_3 -->|flow_edge_9_3| F9_4 + F9_4 -->|flow_edge_9_4| F9_5 + F9_5 -->|flow_edge_9_5| F9_6 + F9_2 -->|flow_edge_9_6| F9_3 + F9_1 -->|flow_edge_9_7| F9_0 + F9_5 -->|flow_edge_9_8| F9_6 + F9_6 -->|flow_edge_9_9| F9_3 + F9_0 -->|flow_edge_9_10| F9_1 + F9_1 -->|flow_edge_9_11| F9_5` + + expectFlowchartLabelsUnobstructed(content) + }) + + test("keeps routes outside unrelated nodes in the cyclic TB fuzz fixture", () => { + const content = `flowchart TB + F7_0[Node F7_0] + F7_1[Node F7_1] + F7_2[Node F7_2] + F7_3[Node F7_3] + F7_4[Node F7_4] + F7_5[Node F7_5] + F7_6[Node F7_6] + F7_7[Node F7_7] + F7_0 -->|flow_edge_7_0| F7_1 + F7_1 -->|flow_edge_7_1| F7_2 + F7_2 -->|flow_edge_7_2| F7_3 + F7_3 -->|flow_edge_7_3| F7_4 + F7_4 -->|flow_edge_7_4| F7_5 + F7_5 -->|flow_edge_7_5| F7_6 + F7_6 -->|flow_edge_7_6| F7_7 + F7_7 -->|flow_edge_7_7| F7_2 + F7_3 -->|flow_edge_7_8| F7_3 + F7_0 -->|flow_edge_7_9| F7_4 + F7_5 -->|flow_edge_7_10| F7_6 + F7_4 -->|flow_edge_7_11| F7_2` + + expectFlowchartLabelsUnobstructed(content) + }) + + test("keeps every label clear in the cyclic BT fuzz fixture", () => { + const content = `flowchart BT + F130_0[Node F130_0] + F130_1[Node F130_1] + F130_2[Node F130_2] + F130_3[Node F130_3] + F130_4[Node F130_4] + F130_5[Node F130_5] + F130_0 -->|flow_edge_130_0| F130_1 + F130_1 -->|flow_edge_130_1| F130_2 + F130_2 -->|flow_edge_130_2| F130_3 + F130_3 -->|flow_edge_130_3| F130_4 + F130_4 -->|flow_edge_130_4| F130_5 + F130_2 -->|flow_edge_130_5| F130_1 + F130_4 -->|flow_edge_130_6| F130_3 + F130_3 -->|flow_edge_130_7| F130_1 + F130_5 -->|flow_edge_130_8| F130_5 + F130_2 -->|flow_edge_130_9| F130_0 + F130_4 -->|flow_edge_130_10| F130_0 + F130_4 -->|flow_edge_130_11| F130_0` + + expectFlowchartLabelsUnobstructed(content) + }) + + test("keeps translated top feedback labels distinct in the cyclic BT fuzz fixture", () => { + const content = `flowchart BT + F141_0[Node F141_0] + F141_1[Node F141_1] + F141_2[Node F141_2] + F141_3[Node F141_3] + F141_4[Node F141_4] + F141_5[Node F141_5] + F141_6[Node F141_6] + F141_7[Node F141_7] + F141_0 -->|flow_edge_141_0| F141_1 + F141_1 -->|flow_edge_141_1| F141_2 + F141_2 -->|flow_edge_141_2| F141_3 + F141_3 -->|flow_edge_141_3| F141_4 + F141_4 -->|flow_edge_141_4| F141_5 + F141_5 -->|flow_edge_141_5| F141_6 + F141_6 -->|flow_edge_141_6| F141_7 + F141_7 -->|flow_edge_141_7| F141_1 + F141_4 -->|flow_edge_141_8| F141_4 + F141_3 -->|flow_edge_141_9| F141_0 + F141_3 -->|flow_edge_141_10| F141_2 + F141_7 -->|flow_edge_141_11| F141_6 + F141_7 -->|flow_edge_141_12| F141_2 + F141_7 -->|flow_edge_141_13| F141_5` + + expectFlowchartLabelsUnobstructed(content) + }) + + test("keeps feedback labels outside their endpoint nodes in the cyclic BT fuzz fixture", () => { + const content = `flowchart BT + F223_0[Node F223_0] + F223_1[Node F223_1] + F223_2[Node F223_2] + F223_3[Node F223_3] + F223_4[Node F223_4] + F223_5[Node F223_5] + F223_6[Node F223_6] + F223_0 -->|flow_edge_223_0| F223_1 + F223_1 -->|flow_edge_223_1| F223_2 + F223_2 -->|flow_edge_223_2| F223_3 + F223_3 -->|flow_edge_223_3| F223_4 + F223_4 -->|flow_edge_223_4| F223_5 + F223_5 -->|flow_edge_223_5| F223_6 + F223_5 -->|flow_edge_223_6| F223_2 + F223_2 -->|flow_edge_223_7| F223_6 + F223_3 -->|flow_edge_223_8| F223_6` + + expectFlowchartLabelsUnobstructed(content) + }) + + test("keeps self-loop labels outside their node in the cyclic TB fuzz fixture", () => { + const content = `flowchart TB + F238_0[Node F238_0] + F238_1[Node F238_1] + F238_2[Node F238_2] + F238_3[Node F238_3] + F238_4[Node F238_4] + F238_5[Node F238_5] + F238_0 -->|flow_edge_238_0| F238_1 + F238_1 -->|flow_edge_238_1| F238_2 + F238_2 -->|flow_edge_238_2| F238_3 + F238_3 -->|flow_edge_238_3| F238_4 + F238_4 -->|flow_edge_238_4| F238_5 + F238_1 -->|flow_edge_238_5| F238_2 + F238_3 -->|flow_edge_238_6| F238_3 + F238_2 -->|flow_edge_238_7| F238_0 + F238_1 -->|flow_edge_238_8| F238_5 + F238_1 -->|flow_edge_238_9| F238_1 + F238_4 -->|flow_edge_238_10| F238_4 + F238_4 -->|flow_edge_238_11| F238_0 + F238_2 -->|flow_edge_238_12| F238_2` + + expectFlowchartLabelsUnobstructed(content) + }) + + test.each(["LR", "RL", "TD", "TB", "BT"] as const)( + "keeps multiple %s self-loop labels clear amid surrounding cycles", + (direction) => { + const content = `flowchart ${direction} + A[Alpha] -->|entry_${direction}| B[Beta] + B -->|loop_${direction}_0| B + B -->|loop_${direction}_1| B + B -->|loop_${direction}_2| B + B -->|exit_${direction}| C[Gamma] + C -->|cycle_${direction}| A` + const layout = expectFlowchartLabelsUnobstructed(content) + const loops = layout.routes.filter((route) => route.edge.from === "B" && route.edge.to === "B") + + expect(loops).toHaveLength(3) + }, + ) + + test.each( + (["LR", "RL", "TD", "TB", "BT"] as const).flatMap((direction) => + ([2, 3, 4] as const).map((count) => ({ direction, count })), + ), + )("keeps $count parallel $direction labels clear amid surrounding cycles", ({ direction, count }) => { + const parallel = Array.from({ length: count }, (_, index) => ` A -->|parallel_${direction}_${count}_${index}| B`) + const content = [ + `flowchart ${direction}`, + " S[Source] -->|entry| A[Alpha]", + ...parallel, + " B[Beta] -->|exit| T[Target]", + " B -->|reciprocal| A", + " T -->|cycle| S", + ].join("\n") + const layout = expectFlowchartLabelsUnobstructed(content) + const parallelRoutes = layout.routes.filter((route) => route.edge.from === "A" && route.edge.to === "B") + + expect(parallelRoutes).toHaveLength(count) + expect(new Set(parallelRoutes.map((route) => JSON.stringify(route.points))).size).toBe(count) }) test("keeps three parallel multiline edges legible in both orientations", () => { @@ -648,6 +947,49 @@ flowchart TD expectDiagram(output).toContainInOrder("Sandbox #1", "apt-get installs,", "~/.cache, /tmp") }) + test.each(["LR", "RL", "TD", "BT"] as const)( + "keeps undirected %s routes continuous up to the target", + (direction) => { + const content = `flowchart ${direction}\n A[Alpha] --- B[(Store)]` + const diagram = parseMermaidFlowchartDiagram(content) + const layout = layoutParsedFlowchartDiagram(diagram) + const grid = drawParsedFlowchartDiagramGrid(diagram) + const route = layout.routes[0]! + + expect(terminalPointsTowardBounds(route, layout.bounds.get("B")!)).toBe(true) + for (let index = 1; index < route.points.length; index++) { + const from = route.points[index - 1]! + const to = route.points[index]! + const length = Math.abs(to.x - from.x) + Math.abs(to.y - from.y) + for (let offset = 0; offset <= length; offset++) { + const x = from.x + Math.sign(to.x - from.x) * offset + const y = from.y + Math.sign(to.y - from.y) * offset + expect(grid.getCell(x, y)?.char).not.toBe(" ") + } + } + expect(grid.getCell(route.points.at(-1)!.x, route.points.at(-1)!.y)?.char).toMatch(/[─│]/) + }, + ) + + test("keeps independent horizontal fan-out groups on distinct routes", () => { + const content = `flowchart LR + A[A] -->|A to X| X[X] + A -->|A to Y| Y[Y] + B[B] -->|B to Y| Y + B -->|B to Z| Z[Z]` + const output = renderFlowchartDiagram(content) + const routes = layoutFlowchartDiagram(content).routes + const routeByLabel = new Map(routes.map((route) => [route.edge.label, route])) + + for (const label of ["A to X", "A to Y", "B to Y", "B to Z"]) { + expect(output.match(new RegExp(label, "g"))).toHaveLength(1) + } + expect(routeByLabel.get("A to X")!.points[1]!.x).toBe(routeByLabel.get("A to Y")!.points[1]!.x) + expect(routeByLabel.get("B to Y")!.points[1]!.x).toBe(routeByLabel.get("B to Z")!.points[1]!.x) + expect(routeByLabel.get("A to Y")!.points[1]!.x).not.toBe(routeByLabel.get("B to Y")!.points[1]!.x) + expect(routeByLabel.get("A to Y")!.points.at(-1)).not.toEqual(routeByLabel.get("B to Y")!.points.at(-1)) + }) + test("parses and renders inline dashed edge labels", () => { const content = `flowchart TD CS[conformance suite
same test cases pin every driver] -.verifies.-> LS @@ -1051,7 +1393,45 @@ graph LR expect(output).toContain("API") expect(output).toContain("DB") expect(output).toContain("╭─ Web App ") - expect(output.split("\n").find((line) => line.includes("API") && line.includes("DB"))).not.toContain("┼") + expect(output.split("\n").find((line) => line.includes("API") && line.includes("DB"))).toContain("┼") + }) + + test("merges horizontal routes through vertical subgraph borders", () => { + const content = `flowchart LR + Outside[Outside] --> Inside + subgraph Group + Inside[Inside] + end` + const diagram = parseMermaidFlowchartDiagram(content) + const layout = layoutParsedFlowchartDiagram(diagram) + const grid = drawParsedFlowchartDiagramGrid(diagram) + const group = layout.subgraphBounds.get("Group")! + const crossing = { x: group.left, y: layout.routes[0]!.points.at(-1)!.y } + + expect(grid.getCell(crossing.x, crossing.y)?.char).toBe("┼") + expect(grid.getCell(crossing.x - 1, crossing.y)?.char).toBe("─") + expect(grid.getCell(crossing.x, crossing.y - 1)?.char).toBe("│") + expect(grid.getCell(crossing.x, crossing.y + 1)?.char).toBe("│") + }) + + test("merges vertical routes through horizontal subgraph borders", () => { + const content = `flowchart TD + Outside[Outside] --> Inside + subgraph Outer [O] + subgraph Inner + Inside[Inside] + end + end` + const diagram = parseMermaidFlowchartDiagram(content) + const layout = layoutParsedFlowchartDiagram(diagram) + const grid = drawParsedFlowchartDiagramGrid(diagram) + const outer = layout.subgraphBounds.get("Outer")! + const crossing = { x: layout.routes[0]!.points[0]!.x, y: outer.top } + + expect(grid.getCell(crossing.x, crossing.y)?.char).toBe("┼") + expect(grid.getCell(crossing.x - 1, crossing.y)?.char).toBe("─") + expect(grid.getCell(crossing.x + 1, crossing.y)?.char).toBe("─") + expect(grid.getCell(crossing.x, crossing.y - 1)?.char).toBe("│") }) test("reserves frame rows for br-delimited subgraph labels", () => { @@ -1086,7 +1466,7 @@ graph LR expect(output).not.toContain(" { + test("merges transition lines through subgraph frame borders", () => { const output = renderFlowchartDiagram(` flowchart TD subgraph Verse [verse] @@ -1100,7 +1480,7 @@ flowchart TD const crossingLines = output.split("\n").filter((line) => line.includes("Join") || line.includes("├")) expect(output).toContain(" verse ") - expect(crossingLines.join("\n")).not.toContain("┼") + expect(crossingLines.join("\n").match(/┼/g)).toHaveLength(2) }) test("lays out subgraph-local directions independently from the outer flow", () => { @@ -1191,6 +1571,23 @@ flowchart TD }, ) + test("keeps labels on nested routes that pass translated outer siblings", () => { + const output = renderFlowchartDiagram(`flowchart TD + Input --> Parse + subgraph Outer + direction LR + subgraph Inner + direction BT + Parse[Parse] --> Validate{Valid?} + Validate -->|yes| Cache[(Cache)] + Cache --> Validate + end + Validate --> Dispatch[Dispatch] + end`) + + expect(output).toContain("yes") + }) + test("routes nested RL local edges around outer siblings", () => { const layout = layoutFlowchartDiagram( `flowchart RL diff --git a/packages/merman/src/flowchart/labels.ts b/packages/merman/src/flowchart/labels.ts index 69673a38815..270e475bb41 100644 --- a/packages/merman/src/flowchart/labels.ts +++ b/packages/merman/src/flowchart/labels.ts @@ -12,7 +12,7 @@ import { type DiagramSegment, } from "../core/geometry.js" import { splitDiagramLines } from "../core/text.js" -import type { FlowchartPoint } from "./types.js" +import type { FlowchartEdgeRoute, FlowchartPoint } from "./types.js" const LABEL_BUS_CLEARANCE = 3 const LABEL_NODE_CLEARANCE = 2 @@ -71,11 +71,13 @@ function bestLabelSegment( points: readonly FlowchartPoint[], labelWidth: number, preferredAxis?: DiagramSegment["axis"], + preferredSegment?: number, ): DiagramSegment | undefined { const segments = points.slice(1).flatMap((to, index) => { const segment = segmentBetween(points[index]!, to) return segment ? [segment] : [] }) + if (preferredSegment !== undefined && segments[preferredSegment]) return segments[preferredSegment] const preferred = preferredAxis ? segments.find((segment) => segment.axis === preferredAxis) : undefined if (preferred) return preferred @@ -97,8 +99,9 @@ function flowchartLabelPoint( labelWidth: number, labelHeight: number, preferredAxis?: DiagramSegment["axis"], + preferredSegment?: number, ): FlowchartPoint { - const segment = bestLabelSegment(points, labelWidth, preferredAxis) + const segment = bestLabelSegment(points, labelWidth, preferredAxis, preferredSegment) return segment ? segmentLabelPoint(segment, labelWidth, labelHeight) : (points[0] ?? point(0, 0)) } @@ -107,9 +110,30 @@ export function flowchartEdgeLabelLayout( label: string, measure: (text: string) => number, preferredAxis?: DiagramSegment["axis"], + preferredSegment?: number, ): FlowchartEdgeLabelLayout { const lines = splitDiagramLines(label).map(flowchartLabelText) const width = flowchartLabelWidth(label, measure) const height = lines.length - return { lines, point: flowchartLabelPoint(points, width, height, preferredAxis), width, height } + return { + lines, + point: flowchartLabelPoint(points, width, height, preferredAxis, preferredSegment), + width, + height, + } +} + +export function flowchartRouteLabelLayout( + route: Pick, + measure: (text: string) => number, +): FlowchartEdgeLabelLayout { + const lines = splitDiagramLines(route.edge.label).map(flowchartLabelText) + const width = flowchartLabelWidth(route.edge.label, measure) + const height = lines.length + return { + lines, + point: route.labelPoint ?? flowchartLabelPoint(route.points, width, height, route.labelAxis), + width, + height, + } } diff --git a/packages/merman/src/flowchart/layout.ts b/packages/merman/src/flowchart/layout.ts index c69447c2f73..b3836dc5fc3 100644 --- a/packages/merman/src/flowchart/layout.ts +++ b/packages/merman/src/flowchart/layout.ts @@ -10,6 +10,7 @@ import { flowchartEdgeLabelLayout, flowchartHorizontalLabelRankGap, flowchartLabelWidth, + flowchartRouteLabelLayout, flowchartVerticalBranchLabelGap, } from "./labels.js" import type { FlowchartDiagramRenderOptions } from "./options.js" @@ -195,6 +196,17 @@ function translateRoutes(routes: readonly FlowchartEdgeRoute[], dx: number, dy: point.x += dx point.y += dy } + if (route.labelPoint) { + route.labelPoint.x += dx + route.labelPoint.y += dy + } + } +} + +function freezeRouteLabelPoints(routes: readonly FlowchartEdgeRoute[]): void { + for (const route of routes) { + if (!route.edge.label || route.labelPoint) continue + route.labelPoint = flowchartEdgeLabelLayout(route.points, route.edge.label, visualLength, route.labelAxis).point } } @@ -317,7 +329,7 @@ function pathBounds(points: readonly { x: number; y: number }[]): FlowchartBound function labelBounds(route: FlowchartEdgeRoute): FlowchartBounds | undefined { if (!route.edge.label) return undefined - const label = flowchartEdgeLabelLayout(route.points, route.edge.label, visualLength, route.labelAxis) + const label = flowchartRouteLabelLayout(route, visualLength) const { point, width, height } = label return { left: point.x, @@ -798,6 +810,7 @@ function layoutFlowchartWithDirection( routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge), subgraphBounds) subgraphBounds = layoutSubgraphs(diagram, bounds, routes) } + freezeRouteLabelPoints(routes) const allBounds = [...bounds.values(), ...subgraphBounds.values(), ...routeRenderBounds(routes)] const dx = Math.max(0, -Math.min(0, ...allBounds.map((bound) => bound.left))) const dy = Math.max(0, -Math.min(0, ...allBounds.map((bound) => bound.top))) diff --git a/packages/merman/src/flowchart/routing.test.ts b/packages/merman/src/flowchart/routing.test.ts index 05c64d190cc..99dbec0f52a 100644 --- a/packages/merman/src/flowchart/routing.test.ts +++ b/packages/merman/src/flowchart/routing.test.ts @@ -127,18 +127,12 @@ describe("flowchart routing", () => { ]), ) - expect(routes.map((route) => route.points)).toEqual([ - [ - { x: 5, y: 1 }, - { x: 19, y: 1 }, - ], - [ - { x: 2, y: 3 }, - { x: 2, y: 6 }, - { x: 22, y: 6 }, - { x: 22, y: 3 }, - ], - ]) + const laneYs = routes.map( + (route) => route.points.slice(1).find((point, index) => point.y === route.points[index]!.y)?.y, + ) + expect(routes).toHaveLength(2) + expect(new Set(routes.map((route) => JSON.stringify(route.points))).size).toBe(2) + expect(new Set(laneYs).size).toBe(2) }) test("spaces parallel horizontal lanes for multiline labels", () => { diff --git a/packages/merman/src/flowchart/routing.ts b/packages/merman/src/flowchart/routing.ts index 3bd4af1dcfd..3c053c088d4 100644 --- a/packages/merman/src/flowchart/routing.ts +++ b/packages/merman/src/flowchart/routing.ts @@ -13,6 +13,7 @@ import { orthogonalPath, pathThrough, pathViaLane, + segmentBetween, sideForDirection, snapCoordinate, shiftPoint, @@ -23,7 +24,7 @@ import { type DiagramSide, } from "../core/geometry.js" import { diagramTextWidth, splitDiagramLines } from "../core/text.js" -import { flowchartEdgeLabelLayout, type FlowchartEdgeLabelLayout } from "./labels.js" +import { flowchartEdgeLabelLayout, flowchartRouteLabelLayout, type FlowchartEdgeLabelLayout } from "./labels.js" import type { FlowchartDiagram, FlowchartDirection, @@ -39,6 +40,7 @@ export { directionBetween as flowchartDirectionBetween } from "../core/geometry. const BUS_CLEARANCE = 3 const NODE_CLEARANCE = 2 +const ROUTING_CANDIDATE_BUDGET = 1024 type HorizontalTravel = Extract type VerticalTravel = Extract type PortRole = "source" | "target" @@ -147,10 +149,10 @@ function selfEdgePath(bounds: FlowchartNodeBounds): FlowchartPoint[] { function parallelEdgePath( from: FlowchartNodeBounds, to: FlowchartNodeBounds, - direction: FlowchartDirection, + axis: DiagramAxis, laneCoordinate: number, ): FlowchartPoint[] { - if (!isVerticalDirection(direction)) { + if (axis === "y") { const start = boundsSidePoint(from, "bottom") const end = boundsSidePoint(to, "bottom") return pathViaLane(start, lane("y", laneCoordinate), end) @@ -168,12 +170,16 @@ function labelHeight(edge: FlowchartEdge): number { function rightRenderExtent(route: FlowchartEdgeRoute): number { let right = Math.max(...route.points.map((point) => point.x)) if (route.edge.label) { - const label = flowchartEdgeLabelLayout(route.points, route.edge.label, diagramTextWidth, route.labelAxis) + const label = flowchartRouteLabelLayout(route, diagramTextWidth) right = Math.max(right, label.point.x + label.width - 1) } return right } +function parallelLaneAxis(from: FlowchartNodeBounds, to: FlowchartNodeBounds): DiagramAxis { + return Math.abs(to.centerX - from.centerX) >= Math.abs(to.centerY - from.centerY) ? "y" : "x" +} + function edgePath( from: FlowchartNodeBounds, to: FlowchartNodeBounds, @@ -210,6 +216,46 @@ function sourceFanOutLane( return keepAfter(unclamped, sourceCoordinate, travel) } +function reserveFanOutLane( + sourcePort: FlowchartPoint, + targetPorts: readonly FlowchartPoint[], + axis: DiagramAxis, + travel: DiagramDirection, + reserved: Set, +): number { + const preferred = sourceFanOutLane(sourcePort, targetPorts, axis, travel) + const boundary = beforeNearestCoordinate(targetPorts, axis, travel, NODE_CLEARANCE) + const source = coordinate(sourcePort, axis) + const available = (() => { + let checked = 0 + for (let offset = 0; offset <= Math.abs(boundary - preferred) && checked < ROUTING_CANDIDATE_BUDGET; offset++) { + checked++ + const candidate = advanceCoordinate(preferred, travel, offset) + if ( + keepBefore(candidate, boundary, travel) === candidate && + keepAfter(candidate, source, travel) === candidate && + !reserved.has(candidate) + ) { + return candidate + } + } + for (let offset = 1; offset <= Math.abs(preferred - source) && checked < ROUTING_CANDIDATE_BUDGET; offset++) { + checked++ + const candidate = advanceCoordinate(preferred, travel, -offset) + if ( + keepBefore(candidate, boundary, travel) === candidate && + keepAfter(candidate, source, travel) === candidate && + !reserved.has(candidate) + ) { + return candidate + } + } + })() + const routeLane = available ?? preferred + reserved.add(routeLane) + return routeLane +} + function targetFanInLane( sourcePorts: readonly FlowchartPoint[], targetPort: FlowchartPoint, @@ -368,19 +414,43 @@ function alignClusteredVerticalSources(records: readonly EdgeRecord[]): EdgeReco function routeHorizontalFanOut( records: readonly EdgeRecord[], + bounds: ReadonlyMap, direction: FlowchartDirection, handled: Set, routes: FlowchartEdgeRoute[], ): void { - for (const sourceRecords of groupRecords(records, (record) => record.edge.from).values()) { + const reservedBusLanes = new Set() + const targetOwners = new Map() + for (const [sourceId, sourceRecords] of groupRecords(records, (record) => record.edge.from)) { if (sourceRecords.length < 2) continue const travel = direction === "RL" ? "left" : "right" const sourcePort = sourceRecords[0]!.sourcePort const targetPorts = sourceRecords.map((record) => record.targetPort) - const busX = sourceFanOutLane(sourcePort, targetPorts, "x", travel) + const busX = reserveFanOutLane(sourcePort, targetPorts, "x", travel, reservedBusLanes) for (const record of sourceRecords) { - routes.push(fanRoute(record.edge, sourcePort, record.targetPort, lane("x", busX))) + const targetOwner = targetOwners.get(record.edge.to) + targetOwners.set(record.edge.to, targetOwner ?? sourceId) + const target = bounds.get(record.edge.to) + if (!targetOwner || targetOwner === sourceId || !target) { + routes.push(fanRoute(record.edge, sourcePort, record.targetPort, lane("x", busX))) + handled.add(record.edge) + continue + } + + const targetSide = sourcePort.y < record.targetPort.y ? "top" : "bottom" + const targetPoint = boundsSidePoint(target, targetSide) + const approach = shiftPoint(targetPoint, targetSide === "top" ? "up" : "down") + routes.push({ + edge: record.edge, + points: pathThrough([ + sourcePort, + { x: busX, y: sourcePort.y }, + { x: busX, y: approach.y }, + approach, + targetPoint, + ]), + }) handled.add(record.edge) } } @@ -452,37 +522,31 @@ function routeVerticalFanIn( function routeParallelEdges( diagram: FlowchartDiagram, bounds: Map, - directionForEdge: (edge: FlowchartEdge) => FlowchartDirection, - leftBoundary: number | undefined, handled: Set, routes: FlowchartEdgeRoute[], ): void { - const groups = groupRecords(diagram.edges, (edge) => `${directionForEdge(edge)}:${edge.from}:${edge.to}`) + const groups = groupRecords(diagram.edges, (edge) => `${edge.from}:${edge.to}`) for (const edges of groups.values()) { if (edges.length < 2) continue const from = bounds.get(edges[0]!.from) const to = bounds.get(edges[0]!.to) if (!from || !to || from.id === to.id) continue - const direction = directionForEdge(edges[0]!) - const canonicalRoute = { edge: edges[0]!, points: edgePath(from, to, direction, leftBoundary) } - routes.push(canonicalRoute) - handled.add(edges[0]!) - let previousRoute = canonicalRoute - for (let index = 1; index < edges.length; index++) { - const edge = edges[index]! - const laneCoordinate = isVerticalDirection(direction) - ? Math.max( - Math.max(boundsSidePoint(from, "right").x, boundsSidePoint(to, "right").x) + BUS_CLEARANCE, - rightRenderExtent(previousRoute) + NODE_CLEARANCE, - ) - : Math.max( - Math.max(boundsSidePoint(from, "bottom").y, boundsSidePoint(to, "bottom").y) + BUS_CLEARANCE, - Math.max(...previousRoute.points.map((point) => point.y)) + Math.max(2, labelHeight(edge) + 1), - ) + const parallelAxis = parallelLaneAxis(from, to) + let previousRoute: FlowchartEdgeRoute | undefined + for (const edge of edges) { + const height = labelHeight(edge) + const laneCoordinate = + parallelAxis === "x" + ? previousRoute + ? rightRenderExtent(previousRoute) + NODE_CLEARANCE + : Math.max(boundsSidePoint(from, "right").x, boundsSidePoint(to, "right").x) + : previousRoute + ? Math.max(...previousRoute.points.map((point) => point.y)) + (height > 1 ? height + 1 : 1) + : Math.max(boundsSidePoint(from, "bottom").y, boundsSidePoint(to, "bottom").y) + (height > 1 ? height : 0) const route: FlowchartEdgeRoute = { edge, - points: parallelEdgePath(from, to, direction, laneCoordinate), - labelAxis: isVerticalDirection(direction) ? "y" : "x", + points: parallelEdgePath(from, to, parallelAxis, laneCoordinate), + labelAxis: parallelAxis === "x" ? "y" : "x", } routes.push(route) handled.add(edge) @@ -637,7 +701,10 @@ function pathIntersectsBounds( return false } -function labelIntersectsBounds(label: FlowchartEdgeLabelLayout | undefined, bounds: FlowchartNodeBounds): boolean { +function labelIntersectsBounds( + label: FlowchartEdgeLabelLayout | undefined, + bounds: { left: number; top: number; width: number; height: number }, +): boolean { if (!label) return false return ( label.point.x <= bounds.left + bounds.width - 1 && @@ -682,30 +749,26 @@ function labelIntersectsLabels( otherLabels: readonly FlowchartEdgeLabelLayout[], ): boolean { if (!label) return false - return otherLabels.some((otherLabel) => { - return label.lines.some((line, lineIndex) => { - const textLeft = label.point.x + 1 - const textRight = label.point.x + diagramTextWidth(line) - 2 - const y = label.point.y + lineIndex - return otherLabel.lines.some((otherLine, otherLineIndex) => { - const otherLeft = otherLabel.point.x - const otherRight = otherLeft + diagramTextWidth(otherLine) - 1 - return y === otherLabel.point.y + otherLineIndex && textLeft <= otherRight && textRight >= otherLeft - }) - }) - }) + return otherLabels.some((otherLabel) => + labelIntersectsBounds(label, { + left: otherLabel.point.x, + top: otherLabel.point.y, + width: otherLabel.width, + height: otherLabel.height, + }), + ) } -function labelIntersectsLaterRoutePaths( +function labelIntersectsRoutePaths( label: FlowchartEdgeLabelLayout | undefined, - laterRoutes: readonly FlowchartEdgeRoute[], + routes: readonly FlowchartEdgeRoute[], ): boolean { if (!label) return false return label.lines.some((line, lineIndex) => { const width = diagramTextWidth(line) - 2 if (width <= 0) return false - return laterRoutes.some((other) => - pathIntersectsBounds(other.points, { + return routes.some((route) => + pathIntersectsBounds(route.points, { left: label.point.x + 1, top: label.point.y + lineIndex, width, @@ -715,6 +778,23 @@ function labelIntersectsLaterRoutePaths( }) } +function routeIntersectsLabels(route: FlowchartEdgeRoute, labels: readonly FlowchartEdgeLabelLayout[]): boolean { + return labels.some((label) => + label.lines.some((line, lineIndex) => { + const width = diagramTextWidth(line) - 2 + return ( + width > 0 && + pathIntersectsBounds(route.points, { + left: label.point.x + 1, + top: label.point.y + lineIndex, + width, + height: 1, + }) + ) + }), + ) +} + function avoidNodeObstacles( route: FlowchartEdgeRoute, routes: readonly FlowchartEdgeRoute[], @@ -724,27 +804,26 @@ function avoidNodeObstacles( ): FlowchartEdgeRoute { const allNodeBounds = [...bounds.values()] const allSubgraphBounds = [...(subgraphBounds?.values() ?? [])] - const laterRoutes = routes.slice(routeIndex + 1) - const laterLabels = laterRoutes.flatMap((laterRoute) => - laterRoute.edge.label - ? [flowchartEdgeLabelLayout(laterRoute.points, laterRoute.edge.label, diagramTextWidth, laterRoute.labelAxis)] - : [], + const otherRoutes = routes.filter((_, index) => index !== routeIndex) + const otherLabels = otherRoutes.flatMap((otherRoute) => + otherRoute.edge.label ? [flowchartRouteLabelLayout(otherRoute, diagramTextWidth)] : [], ) + const intersectsNode = (candidate: FlowchartEdgeRoute): boolean => + allNodeBounds.some((bound) => { + const isSource = bound.id === route.edge.from + const isTarget = bound.id === route.edge.to + const allowedContact = isSource && isTarget ? "both" : isSource ? "source" : isTarget ? "target" : undefined + return pathIntersectsBounds(candidate.points, bound, allowedContact) + }) const intersectsObstacle = (candidate: FlowchartEdgeRoute): boolean => { - const label = candidate.edge.label - ? flowchartEdgeLabelLayout(candidate.points, candidate.edge.label, diagramTextWidth, candidate.labelAxis) - : undefined + const label = candidate.edge.label ? flowchartRouteLabelLayout(candidate, diagramTextWidth) : undefined return ( - allNodeBounds.some((bound) => { - const isSource = bound.id === route.edge.from - const isTarget = bound.id === route.edge.to - const allowedContact = isSource && isTarget ? "both" : isSource ? "source" : isTarget ? "target" : undefined - return pathIntersectsBounds(candidate.points, bound, allowedContact) - }) || + intersectsNode(candidate) || allNodeBounds.some((bound) => labelIntersectsBounds(label, bound)) || allSubgraphBounds.some((bound) => labelIntersectsSubgraphFrame(label, bound)) || - (subgraphBounds !== undefined && - (labelIntersectsLabels(label, laterLabels) || labelIntersectsLaterRoutePaths(label, laterRoutes))) + labelIntersectsLabels(label, otherLabels) || + labelIntersectsRoutePaths(label, otherRoutes) || + routeIntersectsLabels(candidate, otherLabels) ) } if (!intersectsObstacle(route)) return route @@ -757,69 +836,267 @@ function avoidNodeObstacles( const leftBusX = Math.min(...routingBounds.map((bound) => bound.left)) - BUS_CLEARANCE const topBusY = Math.min(...routingBounds.map((bound) => bound.top)) - BUS_CLEARANCE const bottomBusY = Math.max(...routingBounds.map((bound) => bound.top + bound.height - 1)) + BUS_CLEARANCE - const start = route.points[0]! - const end = route.points.at(-1)! - const targetSide = sideForOutsidePoint(to, end) - const approach = shiftPoint( - end, - targetSide === "left" ? "left" : targetSide === "right" ? "right" : targetSide === "top" ? "up" : "down", + const rightBusXs = [ + ...new Set([ + rightBusX, + ...otherLabels.map((label) => Math.max(rightBusX, label.point.x + label.width - 1 + BUS_CLEARANCE)), + ]), + ].sort((left, right) => left - right) + const leftBusXs = [ + ...new Set([leftBusX, ...otherLabels.map((label) => Math.min(leftBusX, label.point.x - BUS_CLEARANCE))]), + ].sort((left, right) => right - left) + const topBusYs = [ + ...new Set([topBusY, ...otherLabels.map((label) => Math.min(topBusY, label.point.y - BUS_CLEARANCE))]), + ].sort((left, right) => right - left) + const bottomBusYs = [ + ...new Set([ + bottomBusY, + ...otherLabels.map((label) => Math.max(bottomBusY, label.point.y + label.height - 1 + BUS_CLEARANCE)), + ]), + ].sort((left, right) => left - right) + const busLimit = Math.max(1, Math.floor(Math.sqrt(ROUTING_CANDIDATE_BUDGET / 4))) + const candidateLeftBusXs = leftBusXs.length > busLimit ? leftBusXs.slice(0, busLimit) : leftBusXs + const candidateRightBusXs = rightBusXs.length > busLimit ? rightBusXs.slice(0, busLimit) : rightBusXs + const candidateTopBusYs = topBusYs.length > busLimit ? topBusYs.slice(0, busLimit) : topBusYs + const candidateBottomBusYs = bottomBusYs.length > busLimit ? bottomBusYs.slice(0, busLimit) : bottomBusYs + const buses = [ + ...candidateLeftBusXs.map((coordinate) => lane("x", coordinate)), + ...candidateRightBusXs.map((coordinate) => lane("x", coordinate)), + ...candidateTopBusYs.map((coordinate) => lane("y", coordinate)), + ...candidateBottomBusYs.map((coordinate) => lane("y", coordinate)), + ] + const routeViaBus = (start: FlowchartPoint, targetSide: DiagramSide, bus: DiagramLane): FlowchartEdgeRoute => { + const end = boundsSidePoint(to, targetSide) + const approach = shiftPoint( + end, + targetSide === "left" ? "left" : targetSide === "right" ? "right" : targetSide === "top" ? "up" : "down", + ) + return { + ...route, + labelAxis: route.labelAxis === undefined ? undefined : bus.axis === "x" ? "y" : "x", + points: + bus.axis === "x" + ? pathThrough([start, { x: bus.coordinate, y: start.y }, { x: bus.coordinate, y: approach.y }, approach, end]) + : pathThrough([ + start, + { x: start.x, y: bus.coordinate }, + { x: approach.x, y: bus.coordinate }, + approach, + end, + ]), + } + } + const selfLoops = + from.id !== to.id + ? [] + : [ + ...candidateRightBusXs.flatMap((busX) => + candidateBottomBusYs.map( + (busY): FlowchartEdgeRoute => ({ + ...route, + points: pathThrough([ + boundsSidePoint(from, "right"), + { x: busX, y: from.centerY }, + { x: busX, y: busY }, + { x: from.centerX, y: busY }, + boundsSidePoint(from, "bottom"), + ]), + }), + ), + ), + ...candidateBottomBusYs.flatMap((busY) => + candidateLeftBusXs.map( + (busX): FlowchartEdgeRoute => ({ + ...route, + points: pathThrough([ + boundsSidePoint(from, "bottom"), + { x: from.centerX, y: busY }, + { x: busX, y: busY }, + { x: busX, y: from.centerY }, + boundsSidePoint(from, "left"), + ]), + }), + ), + ), + ...candidateLeftBusXs.flatMap((busX) => + candidateTopBusYs.map( + (busY): FlowchartEdgeRoute => ({ + ...route, + points: pathThrough([ + boundsSidePoint(from, "left"), + { x: busX, y: from.centerY }, + { x: busX, y: busY }, + { x: from.centerX, y: busY }, + boundsSidePoint(from, "top"), + ]), + }), + ), + ), + ...candidateTopBusYs.flatMap((busY) => + candidateRightBusXs.map( + (busX): FlowchartEdgeRoute => ({ + ...route, + points: pathThrough([ + boundsSidePoint(from, "top"), + { x: from.centerX, y: busY }, + { x: busX, y: busY }, + { x: busX, y: from.centerY }, + boundsSidePoint(from, "right"), + ]), + }), + ), + ), + ] + const targetSides = ["left", "right", "top", "bottom"] satisfies DiagramSide[] + const shortest = (candidates: FlowchartEdgeRoute[], accept: (candidate: FlowchartEdgeRoute) => boolean) => + candidates.filter(accept).sort((left, right) => routeLength(left) - routeLength(right))[0] + if (from.id === to.id) + return ( + shortest(selfLoops, (candidate) => !intersectsObstacle(candidate)) ?? + shortest(selfLoops, (candidate) => !intersectsNode(candidate)) ?? + route + ) + const currentTargetSide = sideForOutsidePoint(to, route.points.at(-1)!) + const preservedTargets = buses.map((bus) => routeViaBus(route.points[0]!, currentTargetSide, bus)) + const sameSides: FlowchartEdgeRoute[] = [ + ...candidateRightBusXs.map( + (busX): FlowchartEdgeRoute => ({ + ...route, + labelAxis: route.labelAxis === undefined ? undefined : "y", + points: pathViaLane(boundsSidePoint(from, "right"), lane("x", busX), boundsSidePoint(to, "right")), + }), + ), + ...candidateLeftBusXs.map( + (busX): FlowchartEdgeRoute => ({ + ...route, + labelAxis: route.labelAxis === undefined ? undefined : "y", + points: pathViaLane(boundsSidePoint(from, "left"), lane("x", busX), boundsSidePoint(to, "left")), + }), + ), + ...candidateTopBusYs.map( + (busY): FlowchartEdgeRoute => ({ + ...route, + labelAxis: route.labelAxis === undefined ? undefined : "x", + points: pathViaLane(boundsSidePoint(from, "top"), lane("y", busY), boundsSidePoint(to, "top")), + }), + ), + ...candidateBottomBusYs.map( + (busY): FlowchartEdgeRoute => ({ + ...route, + labelAxis: route.labelAxis === undefined ? undefined : "x", + points: pathViaLane(boundsSidePoint(from, "bottom"), lane("y", busY), boundsSidePoint(to, "bottom")), + }), + ), + ] + const preservedSources = targetSides.flatMap((targetSide) => + buses.map((bus) => routeViaBus(route.points[0]!, targetSide, bus)), + ) + const attachments = targetSides.flatMap((sourceSide) => + targetSides.flatMap((targetSide) => + buses.map((bus) => routeViaBus(boundsSidePoint(from, sourceSide), targetSide, bus)), + ), ) - const preservedTargetCandidates: FlowchartEdgeRoute[] = [ - { - ...route, - labelAxis: route.labelAxis === undefined ? undefined : "y", - points: pathThrough([start, { x: leftBusX, y: start.y }, { x: leftBusX, y: approach.y }, approach, end]), - }, - { - ...route, - labelAxis: route.labelAxis === undefined ? undefined : "y", - points: pathThrough([start, { x: rightBusX, y: start.y }, { x: rightBusX, y: approach.y }, approach, end]), - }, - { - ...route, - labelAxis: route.labelAxis === undefined ? undefined : "x", - points: pathThrough([start, { x: start.x, y: topBusY }, { x: approach.x, y: topBusY }, approach, end]), - }, - { - ...route, - labelAxis: route.labelAxis === undefined ? undefined : "x", - points: pathThrough([start, { x: start.x, y: bottomBusY }, { x: approach.x, y: bottomBusY }, approach, end]), - }, - ] - const candidates: FlowchartEdgeRoute[] = [ - { - ...route, - labelAxis: route.labelAxis === undefined ? undefined : "y", - points: pathViaLane(boundsSidePoint(from, "right"), lane("x", rightBusX), boundsSidePoint(to, "right")), - }, - { - ...route, - labelAxis: route.labelAxis === undefined ? undefined : "y", - points: pathViaLane(boundsSidePoint(from, "left"), lane("x", leftBusX), boundsSidePoint(to, "left")), - }, - { - ...route, - labelAxis: route.labelAxis === undefined ? undefined : "x", - points: pathViaLane(boundsSidePoint(from, "top"), lane("y", topBusY), boundsSidePoint(to, "top")), - }, - { - ...route, - labelAxis: route.labelAxis === undefined ? undefined : "x", - points: pathViaLane(boundsSidePoint(from, "bottom"), lane("y", bottomBusY), boundsSidePoint(to, "bottom")), - }, - ] - const shortestValid = (candidateRoutes: FlowchartEdgeRoute[]): FlowchartEdgeRoute | undefined => - candidateRoutes - .filter((candidate) => !intersectsObstacle(candidate)) - .sort((left, right) => routeLength(left) - routeLength(right))[0] if (subgraphBounds) { - return shortestValid(preservedTargetCandidates) ?? shortestValid(candidates) ?? route + return ( + shortest(preservedTargets, (candidate) => !intersectsObstacle(candidate)) ?? + shortest(sameSides, (candidate) => !intersectsObstacle(candidate)) ?? + shortest(preservedSources, (candidate) => !intersectsObstacle(candidate)) ?? + shortest(attachments, (candidate) => !intersectsObstacle(candidate)) ?? + shortest(preservedSources, (candidate) => !intersectsNode(candidate)) ?? + shortest(attachments, (candidate) => !intersectsNode(candidate)) ?? + route + ) } return ( - candidates.find((candidate) => !intersectsObstacle(candidate)) ?? shortestValid(preservedTargetCandidates) ?? route + sameSides.find((candidate) => !intersectsObstacle(candidate)) ?? + shortest(preservedTargets, (candidate) => !intersectsObstacle(candidate)) ?? + attachments.find((candidate) => !intersectsObstacle(candidate)) ?? + shortest(preservedSources, (candidate) => !intersectsObstacle(candidate)) ?? + shortest(attachments, (candidate) => !intersectsNode(candidate)) ?? + shortest(preservedSources, (candidate) => !intersectsNode(candidate)) ?? + route ) } +function avoidLabelOverlap( + route: FlowchartEdgeRoute, + otherRoutes: readonly FlowchartEdgeRoute[], + bounds: ReadonlyMap, + subgraphBounds: ReadonlyMap | undefined, +): FlowchartEdgeRoute { + if (!route.edge.label) return route + const nodeBounds = [...bounds.values()] + const frameBounds = [...(subgraphBounds?.values() ?? [])] + const otherLabels = otherRoutes.flatMap((other) => + other.edge.label ? [flowchartRouteLabelLayout(other, diagramTextWidth)] : [], + ) + const otherConnectorBounds = otherRoutes.flatMap((other) => { + const source = bounds.get(other.edge.from) + const sourcePoint = other.points[0] + if (!source || !sourcePoint) return [] + const connector = flowchartSourceConnector(source, sourcePoint) + return [ + { left: connector.x, top: connector.y, width: 1, height: 1 }, + { left: sourcePoint.x, top: sourcePoint.y, width: 1, height: 1 }, + ] + }) + const intersectsObstacle = (label: FlowchartEdgeLabelLayout): boolean => + nodeBounds.some((bound) => labelIntersectsBounds(label, bound)) || + frameBounds.some((bound) => labelIntersectsSubgraphFrame(label, bound)) || + labelIntersectsLabels(label, otherLabels) || + labelIntersectsRoutePaths(label, otherRoutes) || + otherConnectorBounds.some((bound) => labelIntersectsBounds(label, bound)) + const current = flowchartRouteLabelLayout(route, diagramTextWidth) + if (!intersectsObstacle(current)) return route + + const seen = new Set() + let remaining = ROUTING_CANDIDATE_BUDGET + const available = (candidate: FlowchartPoint) => { + remaining-- + const key = `${candidate.x}:${candidate.y}` + if (seen.has(key)) return false + seen.add(key) + return !intersectsObstacle({ ...current, point: candidate }) + } + for (let index = 1; index < route.points.length && remaining > 0; index++) { + const segment = segmentBetween(route.points[index - 1]!, route.points[index]!) + if (!segment) continue + const label = flowchartEdgeLabelLayout(route.points, route.edge.label, diagramTextWidth, route.labelAxis, index - 1) + const fixed = + segment.axis === "y" + ? [label.point, { x: segment.from.x - label.width, y: label.point.y }] + : [ + label.point, + { x: label.point.x, y: segment.from.y - label.height }, + { x: label.point.x, y: segment.from.y + 1 }, + ] + for (const candidate of fixed) { + if (remaining <= 0) return route + if (available(candidate)) return { ...route, labelPoint: candidate } + } + if (segment.axis === "y") { + const bottom = Math.max(segment.from.y, segment.to.y) - label.height + 1 + for (let y = Math.min(segment.from.y, segment.to.y); y <= bottom && remaining > 0; y++) { + for (const x of [segment.from.x + 1, segment.from.x - label.width]) { + const candidate = { x, y } + if (available(candidate)) return { ...route, labelPoint: candidate } + if (remaining <= 0) return route + } + } + continue + } + const right = Math.max(segment.from.x, segment.to.x) - label.width + 1 + for (let x = Math.min(segment.from.x, segment.to.x); x <= right && remaining > 0; x++) { + for (const y of [segment.from.y, segment.from.y - label.height, segment.from.y + 1]) { + const candidate = { x, y } + if (available(candidate)) return { ...route, labelPoint: candidate } + if (remaining <= 0) return route + } + } + } + return route +} + export function routeFlowchartEdges( diagram: FlowchartDiagram, bounds: Map, @@ -833,7 +1110,7 @@ export function routeFlowchartEdges( ? Math.min(...[...bounds.values(), ...subgraphBounds.values()].map((bound) => bound.left)) : undefined - routeParallelEdges(routedDiagram, bounds, directionForEdge, leftBoundary, handled, routes) + routeParallelEdges(routedDiagram, bounds, handled, routes) for (const direction of ["LR", "RL"] satisfies FlowchartDirection[]) { const horizontalEdges = routedDiagram.edges.filter( @@ -841,7 +1118,7 @@ export function routeFlowchartEdges( ) if (horizontalEdges.length === 0) continue const records = horizontalForwardRecords(horizontalEdges, bounds, direction) - routeHorizontalFanOut(records, direction, handled, routes) + routeHorizontalFanOut(records, bounds, direction, handled, routes) routeHorizontalFanIn(records, direction, handled, routes) } @@ -868,7 +1145,9 @@ export function routeFlowchartEdges( for (let index = routes.length - 1; index >= 0; index--) { routes[index] = avoidNodeObstacles(routes[index]!, routes, bounds, subgraphBounds, index) } - return routes + return routes.reduce((resolved, route, index) => { + return [...resolved, avoidLabelOverlap(route, [...resolved, ...routes.slice(index + 1)], bounds, subgraphBounds)] + }, []) } function sideForOutsidePoint(bounds: FlowchartNodeBounds, sourcePoint: FlowchartPoint): DiagramSide { diff --git a/packages/merman/src/flowchart/types.ts b/packages/merman/src/flowchart/types.ts index d3ed87cff4b..2517e0cdc1e 100644 --- a/packages/merman/src/flowchart/types.ts +++ b/packages/merman/src/flowchart/types.ts @@ -57,6 +57,7 @@ export interface FlowchartEdgeRoute { edge: FlowchartEdge points: FlowchartPoint[] labelAxis?: DiagramAxis + labelPoint?: FlowchartPoint } export type FlowchartEdgeDirection = DiagramDirection diff --git a/packages/merman/src/state/diagram.test.ts b/packages/merman/src/state/diagram.test.ts index ac695704b01..abff6bc541a 100644 --- a/packages/merman/src/state/diagram.test.ts +++ b/packages/merman/src/state/diagram.test.ts @@ -1,9 +1,64 @@ import { describe, expect, test } from "bun:test" import stringWidth from "string-width" +import { spatialPathClaim } from "../core/spatial.js" import { expectDiagram } from "../test/diagram.js" import { renderStateDiagram } from "./diagram.js" import { drawStateDiagramGrid } from "./drawing.js" +import { createStateDiagramLayout } from "./layout.js" import { parseMermaidStateDiagram } from "./parser.js" +import { prepareVisibleStateDiagram } from "./visible-model.js" + +function expectCompleteStateDiagram(source: string, output = renderStateDiagram(source)): void { + const diagram = prepareVisibleStateDiagram(parseMermaidStateDiagram(source)) + const layout = createStateDiagramLayout(diagram, { minStateGap: 5 }) + const states = diagram.states.filter((state) => state.kind === "state") + const stateBounds = states.map((state) => layout.bounds.get(state.id)!) + + for (const text of [ + ...states.map((state) => state.label), + ...diagram.transitions.map((transition) => transition.label), + ...diagram.notes.flatMap((note) => note.lines), + ].filter(Boolean)) { + expect(output).toContain(text) + } + for (const [index, bound] of stateBounds.entries()) { + for (const other of stateBounds.slice(index + 1)) { + expect( + bound.left < other.left + other.width && + bound.left + bound.width > other.left && + bound.top < other.top + other.height && + bound.top + bound.height > other.top, + `${bound.id} overlaps ${other.id}`, + ).toBe(false) + } + } + const occupiedByNote = layout.noteBounds.map((note) => { + const connector = spatialPathClaim(`connector:${note.id}`, note.id, "boundary", note.connector!.points) + return new Set([ + ...Array.from({ length: note.height }, (_, dy) => + Array.from({ length: note.width }, (_, dx) => `${note.left + dx}:${note.top + dy}`), + ).flat(), + ...connector.spans.flatMap((span) => + Array.from({ length: span.toX - span.fromX + 1 }, (_, dx) => `${span.fromX + dx}:${span.y}`), + ), + ]) + }) + for (const [index, occupied] of occupiedByNote.entries()) { + for (const bound of stateBounds) { + expect( + Array.from({ length: bound.height }, (_, dy) => + Array.from({ length: bound.width }, (_, dx) => occupied.has(`${bound.left + dx}:${bound.top + dy}`)), + ) + .flat() + .some(Boolean), + `${layout.noteBounds[index]!.id} overlaps ${bound.id}`, + ).toBe(false) + } + for (const other of occupiedByNote.slice(index + 1)) { + expect([...occupied].some((cell) => other.has(cell))).toBe(false) + } + } +} describe("StateDiagram", () => { test("detects and parses Mermaid state diagrams", () => { @@ -439,6 +494,17 @@ stateDiagram-v2 `) }) + test("renders self transitions from choice pseudo-states", () => { + const output = renderStateDiagram(`stateDiagram-v2 + direction LR + state Decision <> + Decision --> Decision: reconsider`) + + expect(output).toContain("◆") + expect(output).toContain("reconsider") + expect(output.split("\n").filter((line) => line.trim())).toHaveLength(3) + }) + test("renders parallel transitions without losing labels", () => { const horizontal = renderStateDiagram(`stateDiagram-v2 direction LR @@ -455,6 +521,154 @@ stateDiagram-v2 expect(vertical).toContain("second") }) + test("renders cyclic same-rank vertical parallels with a note", () => { + const output = renderStateDiagram(`stateDiagram-v2 + direction TD + state "Node S13_0" as S13_0 + state "Node S13_1" as S13_1 + state "Node S13_2" as S13_2 + state "Node S13_3" as S13_3 + state "Node S13_4" as S13_4 + S13_0 --> S13_1: state_edge_13_0 + S13_1 --> S13_2: state_edge_13_1 + S13_2 --> S13_3: state_edge_13_2 + S13_3 --> S13_4: state_edge_13_3 + S13_0 --> S13_2: state_edge_13_4 + S13_4 --> S13_1: state_edge_13_5 + S13_2 --> S13_0: state_edge_13_6 + S13_1 --> S13_2: state_edge_13_7 + S13_3 --> S13_2: state_edge_13_8 + S13_3 --> S13_0: state_edge_13_9 + S13_0 --> S13_0: state_edge_13_10 + note left of S13_0: state_note_13_0`) + + for (const index of [0, 1, 2, 3, 4]) expect(output).toContain(`Node S13_${index}`) + for (const index of [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) expect(output).toContain(`state_edge_13_${index}`) + expect(output).toContain("state_note_13_0") + }) + + test("preserves every state in a routed multi-note vertical graph", () => { + const source = `stateDiagram-v2 + direction TD + state "Node S21_0" as S21_0 + state "Node S21_1" as S21_1 + state "Node S21_2" as S21_2 + state "Node S21_3" as S21_3 + state "Node S21_4" as S21_4 + state "Node S21_5" as S21_5 + S21_0 --> S21_1: state_edge_21_0 + S21_1 --> S21_2: state_edge_21_1 + S21_2 --> S21_3: state_edge_21_2 + S21_3 --> S21_4: state_edge_21_3 + S21_4 --> S21_5: state_edge_21_4 + S21_4 --> S21_5: state_edge_21_5 + S21_2 --> S21_4: state_edge_21_6 + S21_3 --> S21_1: state_edge_21_7 + note right of S21_1: state_note_21_0 + note left of S21_0: state_note_21_1 + note left of S21_4: state_note_21_2` + const output = renderStateDiagram(source) + + for (const index of [0, 1, 2, 3, 4, 5]) expect(output).toContain(`Node S21_${index}`) + for (const index of [0, 1, 2, 3, 4, 5, 6, 7]) expect(output).toContain(`state_edge_21_${index}`) + for (const index of [0, 1, 2]) expect(output).toContain(`state_note_21_${index}`) + expectCompleteStateDiagram(source, output) + }) + + test("renders every note in a cyclic vertical graph", () => { + const source = `stateDiagram-v2 + direction TB + state "Node S27_0" as S27_0 + state "Node S27_1" as S27_1 + state "Node S27_2" as S27_2 + state "Node S27_3" as S27_3 + state "Node S27_4" as S27_4 + state "Node S27_5" as S27_5 + S27_0 --> S27_1: state_edge_27_0 + S27_1 --> S27_2: state_edge_27_1 + S27_2 --> S27_3: state_edge_27_2 + S27_3 --> S27_4: state_edge_27_3 + S27_4 --> S27_5: state_edge_27_4 + S27_1 --> S27_0: state_edge_27_5 + S27_5 --> S27_1: state_edge_27_6 + S27_2 --> S27_1: state_edge_27_7 + S27_1 --> S27_5: state_edge_27_8 + note left of S27_5: state_note_27_0 + note right of S27_0: state_note_27_1 + note left of S27_3: state_note_27_2` + const output = renderStateDiagram(source) + + for (const index of [0, 1, 2, 3, 4, 5]) expect(output).toContain(`Node S27_${index}`) + for (const index of [0, 1, 2, 3, 4, 5, 6, 7, 8]) expect(output).toContain(`state_edge_27_${index}`) + for (const index of [0, 1, 2]) expect(output).toContain(`state_note_27_${index}`) + expectCompleteStateDiagram(source, output) + }) + + test("places exhausted notes on deterministic exterior lanes", () => { + const source = `stateDiagram-v2 + direction TD + state "Node S33_0" as S33_0 + state "Node S33_1" as S33_1 + state "Node S33_2" as S33_2 + state "Node S33_3" as S33_3 + state "Node S33_4" as S33_4 + state "Node S33_5" as S33_5 + state "Node S33_6" as S33_6 + S33_0 --> S33_1: state_edge_33_0 + S33_1 --> S33_2: state_edge_33_1 + S33_2 --> S33_3: state_edge_33_2 + S33_3 --> S33_4: state_edge_33_3 + S33_4 --> S33_5: state_edge_33_4 + S33_5 --> S33_6: state_edge_33_5 + S33_6 --> S33_4: state_edge_33_6 + S33_2 --> S33_4: state_edge_33_7 + S33_3 --> S33_3: state_edge_33_8 + note left of S33_2: state_note_33_0 + note left of S33_6: state_note_33_1` + const output = renderStateDiagram(source) + const exhaustedBudget = { remaining: 0 } + const layout = createStateDiagramLayout(prepareVisibleStateDiagram(parseMermaidStateDiagram(source)), { + minStateGap: 5, + searchBudget: exhaustedBudget, + }) + + for (const index of [0, 1, 2, 3, 4, 5, 6]) expect(output).toContain(`Node S33_${index}`) + for (const index of [0, 1, 2, 3, 4, 5, 6, 7, 8]) expect(output).toContain(`state_edge_33_${index}`) + for (const index of [0, 1]) expect(output).toContain(`state_note_33_${index}`) + expect(layout.noteBounds).toHaveLength(2) + expect(exhaustedBudget.remaining).toBe(0) + expectCompleteStateDiagram(source, output) + }) + + test("separates reciprocal RL branches sharing a parallel lane", () => { + const source = `stateDiagram-v2 + direction RL + state "Node S237_0" as S237_0 + state "Node S237_1" as S237_1 + state "Node S237_2" as S237_2 + state "Node S237_3" as S237_3 + state "Node S237_4" as S237_4 + state "Node S237_5" as S237_5 + S237_0 --> S237_1: state_edge_237_0 + S237_1 --> S237_2: state_edge_237_1 + S237_2 --> S237_3: state_edge_237_2 + S237_3 --> S237_4: state_edge_237_3 + S237_4 --> S237_5: state_edge_237_4 + S237_1 --> S237_5: state_edge_237_5 + S237_4 --> S237_5: state_edge_237_6 + S237_4 --> S237_0: state_edge_237_7 + S237_0 --> S237_4: state_edge_237_8 + note right of S237_1: state_note_237_0 + note left of S237_5: state_note_237_1 + note right of S237_2: state_note_237_2` + const output = renderStateDiagram(source) + + for (const index of [0, 1, 2, 3, 4, 5]) expect(output).toContain(`Node S237_${index}`) + for (const index of [0, 1, 2, 3, 4, 5, 6, 7, 8]) expect(output).toContain(`state_edge_237_${index}`) + for (const index of [0, 1, 2]) expect(output).toContain(`state_note_237_${index}`) + expectCompleteStateDiagram(source, output) + }) + test("separates labels on four parallel vertical transitions", () => { const output = renderStateDiagram(`stateDiagram-v2 direction TB @@ -501,6 +715,24 @@ stateDiagram-v2 for (const state of ["A", "B", "C", "D"]) expect(output.match(new RegExp(state, "g"))).toHaveLength(1) }) + test("keeps dense vertical fan routes out of sibling states", () => { + const output = renderStateDiagram(`stateDiagram-v2 + direction TB + state "Alpha" as A + state "Beta" as B + state "Gamma store" as C + state "Delta notifier" as D + A --> B + A --> C + A --> D + B --> A: back + B --> D: across`) + + for (const text of ["Alpha", "Beta", "Gamma store", "Delta notifier", "back", "across"]) { + expect(output).toContain(text) + } + }) + test("routes parallel transitions around vertically offset states", () => { const output = renderStateDiagram(`stateDiagram-v2 A --> B: first
line two @@ -566,6 +798,68 @@ stateDiagram-v2 expect(output).not.toContain("║──") }) + test("places notes away from transition routes and labels", () => { + const output = renderStateDiagram(`stateDiagram-v2 + direction LR + A --> B: proceed only after validation + note left of B: blocking note`) + + expect(output).toContain("proceed only after validation") + expect(output).toContain("blocking note") + expect(output.match(/ A | B /g)).toHaveLength(2) + expect(output).toMatch(/[╠╣]═/) + }) + + test("keeps adjacent note connectors out of other notes", () => { + const output = renderStateDiagram(`stateDiagram-v2 + direction LR + A --> B: advance + B --> C: continue + note right of B: first note + note right of B: second note + note left of C: left note`) + + for (const text of ["advance", "continue", "first note", "second note", "left note"]) { + expect(output).toContain(text) + } + expect(output).not.toContain("╝═════╗") + }) + + test("keeps note connectors out of vertical loop and transition labels", () => { + const output = renderStateDiagram(`stateDiagram-v2 + direction TB + state "Processing Ω
phase two" as Processing + state "Result ≥ threshold" as Result + Ready --> Processing: first + Ready --> Processing: duplicate + Processing --> Ready: restore + Processing --> Processing: heartbeat + Processing --> Result: result ≥ 1 + Result --> Ready: reopen + note right of Processing: Unicode note 界`) + + for (const text of ["heartbeat", "result ≥ 1", "Unicode note 界"]) { + expect(output).toContain(text) + } + expect(output).not.toContain("hea║tbeat") + expect(output).not.toContain("result║≥ 1") + }) + + test("keeps nested state labels clear of note connectors", () => { + const output = renderStateDiagram(`stateDiagram-v2 + direction LR + state "Open document" as Open { + [*] --> Clean: load + Clean --> Dirty: edit + Dirty --> Clean: save + } + note right of Dirty: unsaved changes`) + + for (const text of ["Open document", "Clean", "Dirty", "load", "edit", "save", "unsaved changes"]) { + expect(output).toContain(text) + } + }) + test("keeps duplicate feedback labels away from an independent return path", () => { const output = renderStateDiagram(`stateDiagram-v2 direction LR @@ -733,6 +1027,23 @@ stateDiagram-v2 } }) + test("routes vertical branch feedback around sibling state bodies", () => { + const output = renderStateDiagram(`stateDiagram-v2 + direction TB + [*] --> Root + Root --> A + Root --> B + A --> Merge + B --> Merge + Merge --> A: retry`) + + for (const state of ["Root", "A", "B", "Merge"]) { + expect(output.match(new RegExp(`\\b${state}\\b`, "g"))).toHaveLength(1) + } + expect(output).toContain("│ B │") + expect(output).toContain("retry") + }) + test("keeps lifecycle states intact around branches and feedback", () => { const output = renderStateDiagram(`stateDiagram-v2 [*] --> Idle diff --git a/packages/merman/src/state/drawing.ts b/packages/merman/src/state/drawing.ts index 1c6c85387dd..cafc57fc992 100644 --- a/packages/merman/src/state/drawing.ts +++ b/packages/merman/src/state/drawing.ts @@ -1,5 +1,6 @@ import { BorderChars, type BorderCharacters, type BorderStyle } from "@opentui/core" import { DiagramCanvas, type DiagramCanvasCell } from "../core/canvas.js" +import { directionBetween, orthogonalPathPoints, type DiagramDirection } from "../core/geometry.js" import { diagramArrowHead, diagramLineGlyph, @@ -11,9 +12,11 @@ import { createStateDiagramLayout, expandCompositeBoundsForFeedback, expandCompositeBoundsForInternalTransitions, + translateStateDiagramLayout, type StateDiagramBoxBounds as BoxBounds, type StateDiagramNoteBounds as StateNoteBounds, } from "./layout.js" +import { stateDiagramNoteConnector } from "./note.js" import { DEFAULT_STATE_ARROW_HEAD_STYLE, DEFAULT_STATE_BORDER_STYLE, normalizeStateMinStateGap } from "./options.js" import type { StateGrid } from "./render-grid.js" import { @@ -22,6 +25,7 @@ import { measureStateTransitionLabel, type StateTransitionRenderPlan, } from "./routing.js" +import { createStateSearchBudget } from "./search.js" import type { NoteConnectorRampStyle, StateCellStyle, @@ -36,13 +40,14 @@ type StateCell = DiagramCanvasCell function translateTransitionPlans( plans: readonly StateTransitionRenderPlan[], + dx: number, dy: number, ): StateTransitionRenderPlan[] { return plans.map((plan) => ({ ...plan, - cells: plan.cells.map((cell) => ({ ...cell, y: cell.y + dy })), - path: plan.path.map(([x, y]) => [x, y + dy]), - label: plan.label ? { ...plan.label, y: plan.label.y + dy } : undefined, + cells: plan.cells.map((cell) => ({ ...cell, x: cell.x + dx, y: cell.y + dy })), + path: plan.path.map(([x, y]) => [x + dx, y + dy]), + label: plan.label ? { ...plan.label, x: plan.label.x + dx, y: plan.label.y + dy } : undefined, })) } @@ -62,22 +67,24 @@ function makeGrid(width: number, height: number): StateGrid { }) } -function setCell(grid: StateGrid, x: number, y: number, char: string, style?: StateCellStyle): void { - grid.setCell(x, y, char, style) -} - -function setText(grid: StateGrid, x: number, y: number, text: string, style?: StateCellStyle): void { - grid.setText(x, y, text, style) -} - -function setTransitionLabel( +function setCell( grid: StateGrid, x: number, y: number, - lines: readonly string[], - style: StateCellStyle, + char: string, + style?: StateCellStyle, ): void { - lines.forEach((line, index) => setText(grid, x, y + index, line, style)) + grid.setCell(x, y, char, style) +} + +function setText( + grid: StateGrid, + x: number, + y: number, + text: string, + style?: StateCellStyle, +): void { + grid.setText(x, y, text, style) } function drawBox( @@ -101,7 +108,12 @@ function drawBox( }) } -function drawStateFrame(grid: StateGrid, bounds: BoxBounds, chars: BorderCharacters, style: StateCellStyle): void { +function drawStateFrame( + grid: StateGrid, + bounds: BoxBounds, + chars: BorderCharacters, + style: StateCellStyle, +): void { drawDiagramFrame(bounds, chars, (x, y, char) => setCell(grid, x, y, char, style)) } @@ -116,56 +128,46 @@ function drawContainerFrame( if (label) setText(grid, bounds.left + 2, bounds.top, ` ${label} `, style) } -function drawHorizontalNoteConnector(grid: StateGrid, fromX: number, toX: number, y: number, char: string): void { - const step = fromX <= toX ? 1 : -1 - for (let x = fromX; step === 1 ? x <= toX : x >= toX; x += step) { - const distanceFromNote = Math.abs(toX - x) - const style: StateCellStyle = - distanceFromNote < 3 ? (`noteConnectorRamp${3 - distanceFromNote}` as NoteConnectorRampStyle) : "noteConnector" - setCell(grid, x, y, char, style) - } +function noteConnectorGlyph(directions: ReadonlySet): string { + const chars = BorderChars.double + const up = directions.has("up") + const down = directions.has("down") + const left = directions.has("left") + const right = directions.has("right") + if (up && down && left && right) return chars.cross + if (up && down && right) return chars.leftT + if (up && down && left) return chars.rightT + if (left && right && down) return chars.topT + if (left && right && up) return chars.bottomT + if (up && right) return chars.bottomLeft + if (up && left) return chars.bottomRight + if (down && right) return chars.topLeft + if (down && left) return chars.topRight + if (up || down) return chars.vertical + return chars.horizontal } function drawNote(grid: StateGrid, bounds: StateNoteBounds, target: BoxBounds): void { const chars = BorderChars.double - const connectorChars = BorderChars.double - const noteX = bounds.note.position === "right" ? bounds.left - 1 : bounds.left + bounds.width - const targetX = bounds.note.position === "right" ? target.left + target.width : target.left - 1 - const targetBottom = target.top + target.height - 1 - const noteBottom = bounds.top + bounds.height - 1 - const noteAbove = noteBottom < target.top - const noteBelow = bounds.top > targetBottom - let connectorY: number - - if (noteAbove || noteBelow) { - const targetY = noteAbove ? target.top - 1 : targetBottom + 1 - connectorY = bounds.centerY - const verticalStep = targetY <= connectorY ? 1 : -1 - - for (let y = targetY; verticalStep === 1 ? y <= connectorY : y >= connectorY; y += verticalStep) { - setCell(grid, targetX, y, connectorChars.vertical, "noteConnector") - } - - drawHorizontalNoteConnector(grid, targetX, noteX, connectorY, connectorChars.horizontal) - const connectorTurnsRight = targetX <= noteX - const corner = noteAbove - ? connectorTurnsRight - ? connectorChars.topLeft - : connectorChars.topRight - : connectorTurnsRight - ? connectorChars.bottomLeft - : connectorChars.bottomRight - setCell(grid, targetX, connectorY, corner, "noteConnector") - } else { - connectorY = Math.max(bounds.top + 1, Math.min(target.centerY, bounds.top + bounds.height - 2)) - drawHorizontalNoteConnector(grid, targetX, noteX, connectorY, connectorChars.horizontal) + const connector = stateDiagramNoteConnector(bounds, target) + const points = orthogonalPathPoints(connector.points) + for (const [index, point] of points.entries()) { + const directions = new Set() + const previous = points[index - 1] + const next = points[index + 1] + if (previous) directions.add(directionBetween(point, previous)!) + if (next) directions.add(directionBetween(point, next)!) + const distanceFromNote = points.length - index - 1 + const style: StateCellStyle = + distanceFromNote < 3 ? (`noteConnectorRamp${3 - distanceFromNote}` as NoteConnectorRampStyle) : "noteConnector" + setCell(grid, point.x, point.y, noteConnectorGlyph(directions), style) } drawContainerFrame(grid, bounds, "", chars, "noteBorder") setCell( grid, bounds.note.position === "right" ? bounds.left : bounds.left + bounds.width - 1, - connectorY, + connector.connectorY, bounds.note.position === "right" ? chars.rightT : chars.leftT, "noteBorder", ) @@ -188,7 +190,9 @@ function drawTransitionRenderPlan( setCell(grid, cell.x, cell.y, char, departure.get(`${cell.x}:${cell.y}`) ?? "transition") } if (plan.label) { - setTransitionLabel(grid, plan.label.x, plan.label.y, plan.label.lines, "label") + plan.label.lines.forEach((line, index) => + setText(grid, plan.label!.x, plan.label!.y + index, line, "label"), + ) } } @@ -211,46 +215,55 @@ export function drawStateDiagramGrid(sourceDiagram: StateDiagram, options: State const borderStyle = options.borderStyle ?? DEFAULT_STATE_BORDER_STYLE const arrowHeadStyle = options.arrowHeadStyle ?? DEFAULT_STATE_ARROW_HEAD_STYLE const minStateGap = normalizeStateMinStateGap(options.minStateGap) - const { bounds, sizes, compositeBounds, noteBounds } = createStateDiagramLayout(diagram, { + const searchBudget = createStateSearchBudget() + const layout = createStateDiagramLayout(diagram, { minStateGap, + searchBudget, }) + const { bounds, sizes, compositeBounds, noteBounds } = layout let allBounds = [...bounds.values(), ...noteBounds] let maxY = Math.max(0, ...allBounds.map((bound) => bound.top + bound.height)) - let feedbackLaneY = maxY + 3 - let feedbackTopY = Math.min(0, ...allBounds.map((bound) => bound.top)) - 3 + const feedbackLaneY = maxY + 3 + const feedbackTopY = Math.min(0, ...allBounds.map((bound) => bound.top)) - 3 expandCompositeBoundsForFeedback(diagram, bounds, compositeBounds, feedbackLaneY) - let transitionPlans = createStateTransitionRenderPlans(diagram, bounds, feedbackLaneY, feedbackTopY) - const transitionTop = Math.min( - 0, - ...transitionPlans.flatMap((plan) => [...plan.cells.map((cell) => cell.y), ...(plan.label ? [plan.label.y] : [])]), - ) - if (transitionTop < 0) { - const dy = -transitionTop - for (const bound of new Set([...bounds.values(), ...noteBounds])) { - bound.top += dy - bound.centerY += dy - } - feedbackLaneY += dy - feedbackTopY += dy - transitionPlans = createStateTransitionRenderPlans(diagram, bounds, feedbackLaneY, feedbackTopY) - } + let transitionPlans = createStateTransitionRenderPlans(diagram, bounds, feedbackLaneY, { + feedbackTopY, + noteBounds, + searchBudget, + }) expandCompositeBoundsForInternalTransitions(diagram, compositeBounds, transitionPlans) + const connectorPoints = noteBounds.flatMap((bound) => bound.connector?.points ?? []) + const contentLeft = Math.min( + 0, + ...[...bounds.values(), ...noteBounds].map((bound) => bound.left), + ...connectorPoints.map((point) => point.x), + ...transitionPlans.flatMap((plan) => [ + ...plan.cells.map((cell) => cell.x), + ...(plan.label ? [plan.label.x] : []), + ]), + ) const contentTop = Math.min( 0, ...[...bounds.values(), ...noteBounds].map((bound) => bound.top), + ...connectorPoints.map((point) => point.y), ...transitionPlans.flatMap((plan) => [...plan.cells.map((cell) => cell.y), ...(plan.label ? [plan.label.y] : [])]), ) - if (contentTop < 0) { - const dy = -contentTop - for (const bound of new Set([...bounds.values(), ...noteBounds])) { - bound.top += dy - bound.centerY += dy - } - transitionPlans = translateTransitionPlans(transitionPlans, dy) + if (contentLeft < 0 || contentTop < 0) { + translateStateDiagramLayout(layout, -contentLeft, -contentTop) + transitionPlans = translateTransitionPlans(transitionPlans, -contentLeft, -contentTop) } allBounds = [...bounds.values(), ...noteBounds] - const maxX = Math.max(0, ...allBounds.map((bound) => bound.left + bound.width)) - maxY = Math.max(0, ...allBounds.map((bound) => bound.top + bound.height)) + const translatedConnectorPoints = noteBounds.flatMap((bound) => bound.connector?.points ?? []) + const maxX = Math.max( + 0, + ...allBounds.map((bound) => bound.left + bound.width), + ...translatedConnectorPoints.map((point) => point.x + 1), + ) + maxY = Math.max( + 0, + ...allBounds.map((bound) => bound.top + bound.height), + ...translatedConnectorPoints.map((point) => point.y + 1), + ) const transitionLabelSizes = diagram.transitions.map((transition) => measureStateTransitionLabel(transition.label)) const maxTransitionLabelWidth = Math.max(0, ...transitionLabelSizes.map((size) => size.width)) const maxTransitionLabelLines = Math.max(0, ...transitionLabelSizes.map((size) => size.height)) diff --git a/packages/merman/src/state/layout.test.ts b/packages/merman/src/state/layout.test.ts index 15f03126f0c..fc226c73be7 100644 --- a/packages/merman/src/state/layout.test.ts +++ b/packages/merman/src/state/layout.test.ts @@ -1,6 +1,12 @@ import { describe, expect, test } from "bun:test" +import { spatialPathClaim } from "../core/spatial.js" +import { diagramTextWidth } from "../core/text.js" import type { StateDiagram } from "./types.js" import { createStateDiagramLayout } from "./layout.js" +import { stateDiagramNoteConnector } from "./note.js" +import { parseMermaidStateDiagram } from "./parser.js" +import { createStateTransitionRenderPlans } from "./routing.js" +import { prepareVisibleStateDiagram } from "./visible-model.js" describe("StateDiagramLayout", () => { test("lays out horizontal main-path states before branch states", () => { @@ -95,4 +101,95 @@ describe("StateDiagramLayout", () => { expect(longGap).toBeGreaterThan(shortGap) }) + + test("reserves notes and connectors from final transition geometry", () => { + const diagram = prepareVisibleStateDiagram( + parseMermaidStateDiagram(`stateDiagram-v2 + direction LR + A --> B: advance + B --> C: continue + note right of B: first note + note right of B: second note + note left of C: left note`), + ) + const layout = createStateDiagramLayout(diagram, { minStateGap: 5 }) + const plans = createStateTransitionRenderPlans(diagram, layout.bounds, 30, { noteBounds: layout.noteBounds }) + const occupiedByNote = layout.noteBounds.map((noteBound) => { + const target = layout.bounds.get(noteBound.note.target)! + const connector = spatialPathClaim( + `connector:${noteBound.id}`, + `connector:${noteBound.id}`, + "boundary", + stateDiagramNoteConnector(noteBound, target).points, + ) + return new Set([ + ...Array.from({ length: noteBound.height }, (_, dy) => + Array.from({ length: noteBound.width }, (_, dx) => `${noteBound.left + dx}:${noteBound.top + dy}`), + ).flat(), + ...connector.spans.flatMap((span) => + Array.from({ length: span.toX - span.fromX + 1 }, (_, dx) => `${span.fromX + dx}:${span.y}`), + ), + ]) + }) + + for (const [index, occupied] of occupiedByNote.entries()) { + for (const other of occupiedByNote.slice(index + 1)) { + expect([...occupied].some((cell) => other.has(cell))).toBe(false) + } + } + const noteCells = new Set(occupiedByNote.flatMap((occupied) => [...occupied])) + for (const plan of plans) { + expect(plan.path.some(([x, y]) => noteCells.has(`${x}:${y}`))).toBe(false) + if (!plan.label) continue + const width = Math.max(...plan.label.lines.map(diagramTextWidth)) + expect( + plan.label.lines.some((_, dy) => + Array.from({ length: width }, (_, dx) => `${plan.label!.x + dx}:${plan.label!.y + dy}`).some((cell) => + noteCells.has(cell), + ), + ), + ).toBe(false) + } + }) + + test("finalizes nested composite bounds after transition-aware note placement", () => { + const diagram = prepareVisibleStateDiagram( + parseMermaidStateDiagram(`stateDiagram-v2 + direction LR + state Outer { + state Inner { + A --> B: internal route + note right of B: nested note + } + } + Outer --> Done: leave composite`), + ) + const layout = createStateDiagramLayout(diagram, { minStateGap: 5 }) + const note = layout.noteBounds[0]! + const inner = layout.compositeBounds.get("Inner")! + const outer = layout.compositeBounds.get("Outer")! + const done = layout.bounds.get("Done")! + + for (const composite of [inner, outer]) { + expect(note.left).toBeGreaterThan(composite.left) + expect(note.top).toBeGreaterThan(composite.top) + expect(note.left + note.width).toBeLessThan(composite.left + composite.width) + expect(note.top + note.height).toBeLessThan(composite.top + composite.height) + } + expect( + done.left < outer.left + outer.width && + done.left + done.width > outer.left && + done.top < outer.top + outer.height && + done.top + done.height > outer.top, + ).toBe(false) + + const maxY = Math.max(...[...layout.bounds.values(), note].map((bound) => bound.top + bound.height)) + const plans = createStateTransitionRenderPlans(diagram, layout.bounds, maxY + 3, { noteBounds: layout.noteBounds }) + const noteCells = new Set( + Array.from({ length: note.height }, (_, dy) => + Array.from({ length: note.width }, (_, dx) => `${note.left + dx}:${note.top + dy}`), + ).flat(), + ) + expect(plans.every((plan) => plan.path.every(([x, y]) => !noteCells.has(`${x}:${y}`)))).toBe(true) + }) }) diff --git a/packages/merman/src/state/layout.ts b/packages/merman/src/state/layout.ts index 1f8c8aaf1ca..7c6bf497d7c 100644 --- a/packages/merman/src/state/layout.ts +++ b/packages/merman/src/state/layout.ts @@ -1,11 +1,21 @@ -import { translateDiagramBounds } from "../core/geometry.js" +import { orthogonalPathPoints, translateDiagramBounds, type DiagramPoint } from "../core/geometry.js" +import { SpatialIndex, spatialPathClaim, spatialRectClaim } from "../core/spatial.js" import { diagramTextWidth, measureDiagramTextBox, splitDiagramLines } from "../core/text.js" +import { stateDiagramNoteConnector, type StateDiagramNoteConnector } from "./note.js" import { + createStateTransitionRenderPlans, hasReverseTransition, isStateHorizontalFeedback, measureStateTransitionLabel, type StateTransitionRenderPlan, } from "./routing.js" +import { + createStateSearchBudget, + createStateSearchSpace, + findStateManhattanPath, + type StateSearchBudget, + type StateSearchSpace, +} from "./search.js" import type { StateDiagram, StateDiagramCompositeState, @@ -14,6 +24,8 @@ import type { StateDiagramTransition, } from "./types.js" +const MAX_STRICT_NOTE_PLACEMENTS = 24 + export interface StateDiagramBoxBounds { id: string left: number @@ -34,10 +46,12 @@ export interface StateDiagramLayout { export interface StateDiagramNoteBounds extends StateDiagramBoxBounds { note: StateDiagramNote lines: string[] + connector?: StateDiagramNoteConnector } export interface StateDiagramLayoutOptions { minStateGap: number + searchBudget?: StateSearchBudget } function computeRanks(diagram: StateDiagram): Map { @@ -150,12 +164,25 @@ function emptyLayout( return { bounds, sizes, compositeBounds: new Map(), noteBounds: [] } } +function isNoteBound(bound: StateDiagramBoxBounds): bound is StateDiagramNoteBounds { + return "note" in bound +} + function shiftBounds(bounds: Iterable, dx: number, dy: number): void { for (const bound of bounds) { translateDiagramBounds(bound, dx, dy) + if (!isNoteBound(bound) || !bound.connector) continue + bound.connector = { + connectorY: bound.connector.connectorY + dy, + points: bound.connector.points.map((point) => ({ x: point.x + dx, y: point.y + dy })), + } } } +export function translateStateDiagramLayout(layout: StateDiagramLayout, dx: number, dy: number): void { + shiftBounds(uniqueBounds(layout.bounds.values(), layout.compositeBounds.values(), layout.noteBounds), dx, dy) +} + function uniqueBounds(...bounds: Iterable[]): StateDiagramBoxBounds[] { return [...new Set(bounds.flatMap((group) => [...group]))] } @@ -163,8 +190,9 @@ function uniqueBounds(...bounds: Iterable[]): StateDiagra function normalizeLayout(layout: StateDiagramLayout): void { const allBounds = uniqueBounds(layout.bounds.values(), layout.compositeBounds.values(), layout.noteBounds) if (allBounds.length === 0) return - const minX = Math.min(0, ...allBounds.map((bound) => bound.left)) - const minY = Math.min(0, ...allBounds.map((bound) => bound.top)) + const connectorPoints = layout.noteBounds.flatMap((bound) => bound.connector?.points ?? []) + const minX = Math.min(0, ...allBounds.map((bound) => bound.left), ...connectorPoints.map((point) => point.x)) + const minY = Math.min(0, ...allBounds.map((bound) => bound.top), ...connectorPoints.map((point) => point.y)) if (minX === 0 && minY === 0) return shiftBounds(allBounds, -minX, -minY) } @@ -221,21 +249,6 @@ function addCompositeBounds(diagram: StateDiagram, layout: StateDiagramLayout): for (const composite of diagram.composites) addComposite(composite) } -function addNoteBounds(diagram: StateDiagram, layout: StateDiagramLayout): void { - const compositeIds = new Set(diagram.composites.map((composite) => composite.id)) - const avoidBounds = [...layout.bounds.values()].filter((bound) => !compositeIds.has(bound.id)) - const noteBounds: StateDiagramNoteBounds[] = [] - - for (const [index, note] of diagram.notes.entries()) { - const target = layout.bounds.get(note.target) - if (!target) continue - const size = noteSize(note) - noteBounds.push(placeNote(note, index, target, size, avoidBounds, noteBounds)) - } - - layout.noteBounds = noteBounds -} - function intersects( left: number, top: number, @@ -272,43 +285,65 @@ function createNoteBound( } } -function placeNote( - note: StateDiagramNote, - index: number, +function findNoteConnector( + search: StateSearchSpace, + bounds: StateDiagramNoteBounds, target: StateDiagramBoxBounds, - size: { width: number; height: number; lines: string[] }, - avoidBounds: readonly StateDiagramBoxBounds[], - existingNotes: readonly StateDiagramNoteBounds[], -): StateDiagramNoteBounds { - const gap = 4 - const baseLeft = note.position === "right" ? target.left + target.width + gap : target.left - size.width - gap - const baseTop = target.centerY - Math.floor(size.height / 2) - const candidateTops = [ - baseTop, - target.top - size.height - 2, - target.top + target.height + 2, - baseTop - size.height - 2, - baseTop + target.height + 2, + budget: StateSearchBudget, +): StateDiagramNoteConnector | undefined { + const connectorY = Math.max(bounds.top + 1, Math.min(target.centerY, bounds.top + bounds.height - 2)) + const end = { + x: bounds.note.position === "right" ? bounds.left - 1 : bounds.left + bounds.width, + y: connectorY, + } + const goal = { x: end.x + (bounds.note.position === "right" ? -1 : 1), y: end.y } + const targetX = bounds.note.position === "right" ? target.left + target.width : target.left - 1 + const preferredStarts = [ + { x: targetX, y: target.centerY }, + { x: targetX, y: target.top - 1 }, + { x: targetX, y: target.top + target.height }, ] - const collides = (left: number, top: number) => { - for (const bound of avoidBounds) { - if (bound.id !== target.id && intersects(left, top, size.width, size.height, bound)) return true - } - for (const bound of existingNotes) { - if (intersects(left, top, size.width, size.height, bound)) return true - } - return false + const perimeterStarts = [ + ...Array.from({ length: target.height }, (_, offset) => ({ x: target.left - 1, y: target.top + offset })), + ...Array.from({ length: target.height }, (_, offset) => ({ + x: target.left + target.width, + y: target.top + offset, + })), + ...Array.from({ length: target.width }, (_, offset) => ({ x: target.left + offset, y: target.top - 1 })), + ...Array.from({ length: target.width }, (_, offset) => ({ + x: target.left + offset, + y: target.top + target.height, + })), + ] + const starts = [...preferredStarts, ...perimeterStarts] + const isFree = (point: DiagramPoint): boolean => + point.x >= 0 && + !search.blocked.has(`${point.x}:${point.y}`) && + !(point.x >= bounds.left && point.x < bounds.left + bounds.width && point.y >= bounds.top && point.y < bounds.top + bounds.height) + if (!isFree(end) || !isFree(goal)) return undefined + + for (const start of starts.filter(isFree)) { + const directPaths = [ + [start, { x: start.x, y: goal.y }, goal, end], + [start, { x: goal.x, y: start.y }, goal, end], + ] + const direct = directPaths.find((points) => orthogonalPathPoints(points).every(isFree)) + if (direct) return { connectorY, points: direct } } - for (const top of candidateTops) { - if (!collides(baseLeft, top)) return createNoteBound(note, index, baseLeft, top, size) - } - - const shiftedLeft = - note.position === "right" - ? Math.max(...avoidBounds.map((bound) => bound.left + bound.width), target.left + target.width) + gap - : Math.min(...avoidBounds.map((bound) => bound.left), target.left) - size.width - gap - return createNoteBound(note, index, shiftedLeft, baseTop + target.height + 1, size) + const margin = 8 + const minY = Math.min(target.top, bounds.top, search.minY) - margin + const maxX = Math.max(target.left + target.width, bounds.left + bounds.width, search.maxX) + margin + const maxY = Math.max(target.top + target.height, bounds.top + bounds.height, search.maxY) + margin + const path = findStateManhattanPath( + starts, + goal, + search, + { minX: 0, minY, maxX, maxY }, + budget, + isFree, + ) + return path ? { connectorY, points: [...path, end] } : undefined } function belongsToComposite( @@ -392,16 +427,42 @@ function separateExternalBoundsFromComposites(diagram: StateDiagram, layout: Sta if (candidateBound && candidateBound.left >= leftThreshold) boundsToShift.push(candidateBound) } + for (const noteBound of layout.noteBounds) { + const target = layout.bounds.get(noteBound.note.target) + if (target && boundsToShift.includes(target)) boundsToShift.push(noteBound) + } + shiftBounds(uniqueBounds(boundsToShift), dx, 0) } } } -function finalizeLayout(diagram: StateDiagram, layout: StateDiagramLayout): StateDiagramLayout { +function finalizeLayout( + diagram: StateDiagram, + layout: StateDiagramLayout, + budget: StateSearchBudget, +): StateDiagramLayout { if (diagram.composites.length === 0 && diagram.notes.length === 0) return layout addCompositeBounds(diagram, layout) normalizeLayout(layout) - addNoteBounds(diagram, layout) + if (diagram.notes.length > 0) { + const allBounds = [...layout.bounds.values()] + placeStateDiagramNotesAroundTransitions( + diagram, + layout, + createStateTransitionRenderPlans( + diagram, + layout.bounds, + Math.max(0, ...allBounds.map((bound) => bound.top + bound.height)) + 3, + { + feedbackTopY: Math.min(0, ...allBounds.map((bound) => bound.top)) - 3, + repairRoutes: false, + searchBudget: budget, + }, + ), + budget, + ) + } expandCompositeBoundsForNotes(diagram, layout) separateExternalBoundsFromComposites(diagram, layout) normalizeLayout(layout) @@ -412,8 +473,9 @@ export function createStateDiagramLayout( diagram: StateDiagram, options: StateDiagramLayoutOptions, ): StateDiagramLayout { + const budget = options.searchBudget ?? createStateSearchBudget() if (diagram.direction === "LR" || diagram.direction === "RL") { - return finalizeLayout(diagram, createHorizontalLayout(diagram, options)) + return finalizeLayout(diagram, createHorizontalLayout(diagram, options), budget) } const ranks = computeRanks(diagram) @@ -466,7 +528,7 @@ export function createStateDiagramLayout( y += rowHeight + Math.max(4, labelRows + 3) + pseudoStateApproachClearance } - return finalizeLayout(diagram, emptyLayout(bounds, sizes)) + return finalizeLayout(diagram, emptyLayout(bounds, sizes), budget) } function createHorizontalLayout(diagram: StateDiagram, options: StateDiagramLayoutOptions): StateDiagramLayout { @@ -540,7 +602,13 @@ function createHorizontalLayout(diagram: StateDiagram, options: StateDiagramLayo if (bounds.has(branchId)) continue const size = sizes.get(branchId) if (!size) continue - const top = baselineY + (parallelLane ? 6 : 5) + const top = availableStateTop( + [...bounds.values()], + left, + baselineY + (parallelLane ? 6 : 5), + size.width, + size.height, + ) bounds.set(branchId, { id: branchId, left, @@ -618,6 +686,21 @@ function createHorizontalLayout(diagram: StateDiagram, options: StateDiagramLayo return emptyLayout(bounds, sizes) } +function availableStateTop( + bounds: readonly StateDiagramBoxBounds[], + left: number, + top: number, + width: number, + height: number, +): number { + let available = top + while (true) { + const collision = bounds.find((bound) => intersects(left, available, width, height, bound, 0)) + if (!collision) return available + available = collision.top + collision.height + 3 + } +} + export function expandCompositeBoundsForFeedback( diagram: StateDiagram, bounds: Map, @@ -648,6 +731,234 @@ export function expandCompositeBoundsForFeedback( } } +function placeStateDiagramNotesAroundTransitions( + diagram: StateDiagram, + layout: StateDiagramLayout, + transitionPlans: readonly StateTransitionRenderPlan[], + budget: StateSearchBudget, +): void { + if (diagram.notes.length === 0) return + const compositeIds = new Set(diagram.composites.map((composite) => composite.id)) + let noteSpace = SpatialIndex.empty().add( + ...[...layout.bounds.values()].flatMap((bound) => + compositeIds.has(bound.id) ? [] : [spatialRectClaim(`state:${bound.id}`, `state:${bound.id}`, "body", bound)], + ), + ) + let reserved = noteSpace.add( + ...transitionPlans.flatMap((plan, planIndex) => [ + spatialPathClaim( + `transition-terminal:${planIndex}:source`, + `transition-terminal:${planIndex}:source`, + "route", + plan.path.slice(0, 2).map(([x, y]) => ({ x, y })), + ), + spatialPathClaim( + `transition-terminal:${planIndex}:target`, + `transition-terminal:${planIndex}:target`, + "route", + plan.path.slice(-2).map(([x, y]) => ({ x, y })), + ), + ]), + ) + let space = reserved.add( + ...transitionPlans.flatMap((plan, planIndex) => [ + spatialPathClaim( + `transition:${planIndex}`, + `transition:${planIndex}`, + "route", + plan.path.map(([x, y]) => ({ x, y })), + ), + ...(plan.label + ? [ + spatialRectClaim(`transition-label:${planIndex}`, `transition-label:${planIndex}`, "label", { + left: plan.label.x, + top: plan.label.y, + width: Math.max(...plan.label.lines.map(diagramTextWidth)), + height: plan.label.lines.length, + }), + ] + : []), + ]), + ) + const noteBounds: StateDiagramNoteBounds[] = [] + + for (const [index, note] of diagram.notes.entries()) { + const target = layout.bounds.get(note.target) + if (!target) continue + const size = noteSize(note) + const gap = 4 + const baseLeft = note.position === "right" ? target.left + target.width + gap : target.left - size.width - gap + const baseTop = target.centerY - Math.floor(size.height / 2) + const candidates = [ + baseTop, + target.top - size.height - 2, + target.top + target.height + 2, + ...Array.from({ length: 12 }, (_, distance) => [baseTop - distance - 1, baseTop + distance + 1]).flat(), + ] + const candidateBounds = Array.from({ length: 5 }, (_, outward) => + candidates.map((top) => + createNoteBound( + note, + index, + baseLeft + (note.position === "right" ? 1 : -1) * outward * (size.width + 2), + top, + size, + ), + ), + ) + .flat() + const findPlacement = (candidateSpace: SpatialIndex, limit: number) => { + const connectorSearch = createStateSearchSpace(candidateSpace, (role) => (role === "label" ? 1 : 0)) + for (const bound of candidateBounds.slice(0, limit)) { + if (bound.left < 0) continue + const owner = `note:${index}` + if (!candidateSpace.isFree(spatialRectClaim(`${owner}:body`, owner, "body", bound), { clearance: 1 })) + continue + const connector = findNoteConnector(connectorSearch, bound, target, budget) + if (connector) return { bound: { ...bound, connector }, connector } + } + return undefined + } + const placement = + findPlacement(space, MAX_STRICT_NOTE_PLACEMENTS) ?? + findPlacement(reserved, candidateBounds.length) ?? + outsideNotePlacement(noteSpace, note, index, target, size) + const owner = `note:${index}` + const claims = [ + spatialRectClaim(`${owner}:body`, owner, "body", placement.bound), + spatialPathClaim(`${owner}:connector`, owner, "boundary", placement.connector.points), + ] as const + noteBounds.push(placement.bound) + noteSpace = noteSpace.add(...claims) + reserved = reserved.add(...claims) + space = space.add(...claims) + } + + layout.noteBounds.splice(0, layout.noteBounds.length, ...noteBounds) +} + +function outsideNotePlacement( + space: SpatialIndex, + note: StateDiagramNote, + index: number, + target: StateDiagramBoxBounds, + size: { width: number; height: number; lines: string[] }, +): { bound: StateDiagramNoteBounds; connector: StateDiagramNoteConnector } { + const search = createStateSearchSpace(space) + const top = search.maxY + 4 + const owner = `note:${index}:outside` + + for (const position of [note.position, note.position === "left" ? "right" : "left"] as const) { + const aligned = createNoteBound( + { ...note, position }, + index, + position === "left" ? search.minX - size.width - 4 : search.maxX + 4, + target.centerY - Math.floor(size.height / 2), + size, + ) + const alignedNoteX = position === "left" ? aligned.left + aligned.width : aligned.left - 1 + const alignedConnectorY = Math.max( + aligned.top + 1, + Math.min(target.centerY, aligned.top + aligned.height - 2), + ) + const alignedTargetX = position === "left" ? target.left - 1 : target.left + target.width + const alignedConnector = { + connectorY: alignedConnectorY, + points: [ + { x: alignedTargetX, y: target.centerY }, + { x: alignedNoteX, y: alignedConnectorY }, + ], + } + if ( + space.isFree(spatialRectClaim(`${owner}:body`, owner, "body", aligned), { clearance: 1 }) && + space.isFree(spatialPathClaim(`${owner}:connector`, owner, "boundary", alignedConnector.points)) + ) + return { bound: { ...aligned, connector: alignedConnector }, connector: alignedConnector } + + const railX = position === "left" ? search.minX - 2 : search.maxX + 2 + const bound = createNoteBound( + { ...note, position }, + index, + position === "left" ? railX - size.width - 2 : railX + 3, + top, + size, + ) + const noteX = position === "left" ? bound.left + bound.width : bound.left - 1 + const connectorY = bound.top + 1 + const sideX = position === "left" ? target.left - 1 : target.left + target.width + const escapes = [ + ...[target.top, target.centerY, target.top + target.height - 1].map((y) => [ + { x: sideX, y }, + { x: railX, y }, + ]), + [ + { x: target.centerX, y: target.top - 1 }, + { x: railX, y: target.top - 1 }, + ], + [ + { x: target.centerX, y: target.top + target.height }, + { x: railX, y: target.top + target.height }, + ], + ] + + for (const escape of escapes) { + const connector = { + connectorY, + points: [...escape, { x: railX, y: connectorY }, { x: noteX, y: connectorY }], + } + if ( + space.isFree(spatialRectClaim(`${owner}:body`, owner, "body", bound), { clearance: 1 }) && + space.isFree(spatialPathClaim(`${owner}:connector`, owner, "boundary", connector.points)) + ) + return { bound: { ...bound, connector }, connector } + } + } + + for (const vertical of ["below", "above"] as const) { + const railY = vertical === "below" ? search.maxY + 2 : search.minY - 2 + const bound = createNoteBound( + note, + index, + note.position === "left" ? search.minX - size.width - 4 : search.maxX + 4, + vertical === "below" ? railY + 2 : railY - size.height - 2, + size, + ) + const noteX = note.position === "left" ? bound.left + bound.width : bound.left - 1 + const connectorY = bound.top + 1 + const targetY = vertical === "below" ? target.top + target.height : target.top - 1 + const escapes = [ + ...[target.left, target.centerX, target.left + target.width - 1].map((x) => [ + { x, y: targetY }, + { x, y: railY }, + ]), + [ + { x: target.left, y: targetY }, + { x: target.left - 1, y: targetY }, + { x: target.left - 1, y: railY }, + ], + [ + { x: target.left + target.width - 1, y: targetY }, + { x: target.left + target.width, y: targetY }, + { x: target.left + target.width, y: railY }, + ], + ] + + for (const escape of escapes) { + const connector = { + connectorY, + points: [...escape, { x: noteX, y: railY }, { x: noteX, y: connectorY }], + } + if ( + space.isFree(spatialRectClaim(`${owner}:body`, owner, "body", bound), { clearance: 1 }) && + space.isFree(spatialPathClaim(`${owner}:connector`, owner, "boundary", connector.points)) + ) + return { bound: { ...bound, connector }, connector } + } + } + + throw new Error(`State ${note.target} has no exterior note corridor`) +} + export function expandCompositeBoundsForInternalTransitions( diagram: StateDiagram, compositeBounds: Map, diff --git a/packages/merman/src/state/note.ts b/packages/merman/src/state/note.ts new file mode 100644 index 00000000000..78ae3866cbf --- /dev/null +++ b/packages/merman/src/state/note.ts @@ -0,0 +1,59 @@ +import type { DiagramPoint } from "../core/geometry.js" +import type { StateDiagramNote } from "./types.js" + +interface NoteBounds { + left: number + top: number + width: number + height: number + centerY: number + note: StateDiagramNote + connector?: StateDiagramNoteConnector +} + +interface TargetBounds { + left: number + top: number + width: number + height: number + centerY: number +} + +export interface StateDiagramNoteConnector { + points: readonly DiagramPoint[] + connectorY: number +} + +export function stateDiagramNoteConnector( + bounds: NoteBounds, + target: TargetBounds, +): StateDiagramNoteConnector { + if (bounds.connector) return bounds.connector + const noteX = bounds.note.position === "right" ? bounds.left - 1 : bounds.left + bounds.width + const targetX = bounds.note.position === "right" ? target.left + target.width : target.left - 1 + const targetBottom = target.top + target.height - 1 + const noteBottom = bounds.top + bounds.height - 1 + const noteAbove = noteBottom < target.top + const noteBelow = bounds.top > targetBottom + + if (noteAbove || noteBelow) { + const connectorY = bounds.centerY + return { + connectorY, + points: [ + { x: targetX, y: noteAbove ? target.top - 1 : targetBottom + 1 }, + { x: targetX, y: connectorY }, + { x: noteX, y: connectorY }, + ], + } + } + + const connectorY = Math.max(bounds.top + 1, Math.min(target.centerY, bounds.top + bounds.height - 2)) + return { + connectorY, + points: [ + { x: targetX, y: connectorY }, + { x: noteX, y: connectorY }, + ], + } +} diff --git a/packages/merman/src/state/routing.test.ts b/packages/merman/src/state/routing.test.ts index c832c05ca78..ce6088b20b3 100644 --- a/packages/merman/src/state/routing.test.ts +++ b/packages/merman/src/state/routing.test.ts @@ -92,6 +92,28 @@ describe("createStateTransitionRoutePlans", () => { ]) }) + test("uses a bottom lane for same-rank parallel transitions in vertical diagrams", () => { + const diagram: StateVisibleDiagram = { + direction: "TB", + states: ["A", "B"].map((id) => ({ id, label: id, kind: "state" })), + transitions: [ + { from: "A", to: "B", label: "first" }, + { from: "A", to: "B", label: "second" }, + ], + composites: [], + notes: [], + } + const placements = new Map([ + ["A", bounds("A", 4, 4)], + ["B", bounds("B", 18, 4)], + ]) + + expect(createStateTransitionRoutePlans(diagram, placements, 12).map((plan) => plan.kind)).toEqual([ + "horizontal-forward", + "bottom-parallel", + ]) + }) + test("routes interleaving independent feedback transitions on opposite sides", () => { const diagram: StateVisibleDiagram = { direction: "LR", @@ -244,6 +266,67 @@ describe("createStateTransitionRenderPlans", () => { } }) + test("keeps vertical feedback routes out of compact sibling state bounds", () => { + const diagram = prepareVisibleStateDiagram( + parseMermaidStateDiagram(`stateDiagram-v2 + direction TB + [*] --> Root + Root --> A + Root --> B + A --> Merge + B --> Merge + Merge --> A: retry`), + ) + const layout = createStateDiagramLayout(diagram, { minStateGap: 12 }) + const plan = createStateTransitionRenderPlans(diagram, layout.bounds, 30).find( + (plan) => plan.route.transition.label === "retry", + )! + const sibling = layout.bounds.get("B")! + + expect( + plan.path.some( + ([x, y]) => + x >= sibling.left && + x < sibling.left + sibling.width && + y >= sibling.top && + y < sibling.top + sibling.height, + ), + ).toBe(false) + }) + + test("keeps every dense vertical fan route out of unrelated state bounds", () => { + const diagram = prepareVisibleStateDiagram( + parseMermaidStateDiagram(`stateDiagram-v2 + direction TB + state "Alpha" as A + state "Beta" as B + state "Gamma store" as C + state "Delta notifier" as D + A --> B + A --> C + A --> D + B --> A: back + B --> D: across`), + ) + const layout = createStateDiagramLayout(diagram, { minStateGap: 5 }) + const plans = createStateTransitionRenderPlans(diagram, layout.bounds, 30) + + for (const plan of plans) { + const unrelated = diagram.states + .filter((state) => state.id !== plan.route.transition.from && state.id !== plan.route.transition.to) + .map((state) => layout.bounds.get(state.id)!) + expect( + plan.path.some(([x, y]) => + unrelated.some( + (bound) => + x >= bound.left && x < bound.left + bound.width && y >= bound.top && y < bound.top + bound.height, + ), + ), + `${plan.route.transition.from} -> ${plan.route.transition.to}`, + ).toBe(false) + } + }) + test("keeps routes to offset end markers continuous", () => { const diagram = prepareVisibleStateDiagram( parseMermaidStateDiagram(`stateDiagram-v2 diff --git a/packages/merman/src/state/routing.ts b/packages/merman/src/state/routing.ts index 23f0644bbe6..4e4b7df48b1 100644 --- a/packages/merman/src/state/routing.ts +++ b/packages/merman/src/state/routing.ts @@ -1,8 +1,17 @@ import { BorderChars } from "@opentui/core" +import { diagramLineGlyph } from "../core/drawing.js" import type { DiagramDirection } from "../core/geometry.js" import { SpatialIndex, spatialPathClaim, spatialRectClaim } from "../core/spatial.js" import { diagramTextWidth, splitDiagramLines } from "../core/text.js" -import type { StateDiagramBoxBounds as BoxBounds } from "./layout.js" +import type { StateDiagramBoxBounds as BoxBounds, StateDiagramNoteBounds } from "./layout.js" +import { stateDiagramNoteConnector } from "./note.js" +import { + createStateSearchBudget, + createStateSearchSpace, + findStateManhattanPath, + type StateSearchBudget, + type StateSearchSpace, +} from "./search.js" import type { StateDiagram, StateDiagramState, StateDiagramTransition } from "./types.js" import { isHiddenCompositeMarker, type StateVisibleDiagram, type StateVisibleTransition } from "./visible-model.js" @@ -21,7 +30,11 @@ export type StateTransitionRoutePlan = | (StateTransitionRoutePlanBase & { kind: "top-feedback"; railY: number }) | (StateTransitionRoutePlanBase & { kind: "bottom-parallel"; railY: number; approachX: number }) | (StateTransitionRoutePlanBase & { kind: "vertical-elbow"; hasReverse: boolean; offsetConnector: boolean }) - | (StateTransitionRoutePlanBase & { kind: "side-parallel"; railX: number }) + | (StateTransitionRoutePlanBase & { + kind: "side-parallel" + railX: number + targetApproach?: "top" | "bottom" + }) | (StateTransitionRoutePlanBase & { kind: "vertical" }) export type StateTransitionPathPoint = readonly [number, number] @@ -47,6 +60,13 @@ export interface StateTransitionRenderPlan { label?: StateTransitionRenderLabel } +export interface StateTransitionRenderOptions { + feedbackTopY?: number + noteBounds?: readonly StateDiagramNoteBounds[] + repairRoutes?: boolean + searchBudget?: StateSearchBudget +} + export interface StateTransitionJunctionPlan { state: StateDiagramState bounds: BoxBounds @@ -286,6 +306,50 @@ function bottomApproachX( return targetX } +function sideParallelTargetApproach( + diagram: StateVisibleDiagram, + transition: StateVisibleTransition, + from: BoxBounds, + to: BoxBounds, + bounds: ReadonlyMap, + railX: number, +): "top" | "bottom" | undefined { + const space = SpatialIndex.empty().add( + ...diagram.states.flatMap((state) => { + if (state.id === transition.from || state.id === transition.to || isHiddenCompositeMarker(state)) return [] + const bound = bounds.get(state.id) + return bound ? [spatialRectClaim(`state:${state.id}`, `state:${state.id}`, "body", bound)] : [] + }), + ) + const targetSideX = to.left + to.width + const claim = (points: readonly { x: number; y: number }[]) => + spatialPathClaim(`side-target:${transition.from}:${transition.to}`, "side-target", "route", points) + + if ( + space.isFree( + claim([ + { x: railX, y: to.centerY }, + { x: targetSideX, y: to.centerY }, + ]), + ) + ) + return undefined + + const targetX = innerConnectorX(to, from.centerX) + const preferred = from.centerY > to.centerY ? "top" : "bottom" + return ([preferred, preferred === "top" ? "bottom" : "top"] as const).find((side) => { + const railY = side === "top" ? to.top - 2 : to.top + to.height + 1 + const targetY = side === "top" ? to.top - 1 : to.top + to.height + return space.isFree( + claim([ + { x: railX, y: railY }, + { x: targetX, y: railY }, + { x: targetX, y: targetY }, + ]), + ) + }) +} + export function createStateTransitionRoutePlans( diagram: StateVisibleDiagram, bounds: ReadonlyMap, @@ -327,6 +391,15 @@ export function createStateTransitionRoutePlans( const targetIsChoice = targetState?.kind === "choice" const targetIsHiddenMarker = isHiddenCompositeMarker(targetState) const base = { transition, from, to, targetIsChoice, targetIsHiddenMarker } + const sideParallel = (): StateTransitionRoutePlan => { + const railX = allocateSideRail(transition.label) + return { + ...base, + kind: "side-parallel", + railX, + targetApproach: sideParallelTargetApproach(diagram, transition, from, to, bounds, railX), + } + } if (transition.from === transition.to) return [{ ...base, kind: "self" }] const endpointKey = `${transition.from}\u0000${transition.to}` const parallelIndex = endpointOccurrences.get(endpointKey) ?? 0 @@ -354,7 +427,7 @@ export function createStateTransitionRoutePlans( ] } if (parallelIndex > 0) { - if ((diagram.direction === "LR" || diagram.direction === "RL") && from.centerY === to.centerY) { + if (from.centerY === to.centerY) { const railY = allocateBottomRail() return [ { @@ -365,19 +438,19 @@ export function createStateTransitionRoutePlans( }, ] } - return [{ ...base, kind: "side-parallel", railX: allocateSideRail(transition.label) }] + return [sideParallel()] } if (diagram.direction !== "LR" && diagram.direction !== "RL") { const fromParent = statesById.get(transition.from)?.parentId const toParent = statesById.get(transition.to)?.parentId if (fromParent && toParent && fromParent !== toParent) { - return [{ ...base, kind: "side-parallel", railX: allocateSideRail(transition.label) }] + return [sideParallel()] } if (verticalCorridorCrossesUnrelatedState(diagram, transition, from, to, bounds)) { - return [{ ...base, kind: "side-parallel", railX: allocateSideRail(transition.label) }] + return [sideParallel()] } if (from.centerY > to.centerY) { - return [{ ...base, kind: "side-parallel", railX: allocateSideRail(transition.label) }] + return [sideParallel()] } if (from.centerY === to.centerY) { if (hasReverseTransition(diagram, transition) && from.centerX > to.centerX) { @@ -394,7 +467,7 @@ export function createStateTransitionRoutePlans( return [{ ...base, kind: "horizontal-forward", leftToRight: from.centerX <= to.centerX }] } if (!hasVerticalCorridor(from, to)) { - return [{ ...base, kind: "side-parallel", railX: allocateSideRail(transition.label) }] + return [sideParallel()] } if (from.centerX !== to.centerX) { return [{ ...base, kind: "vertical-elbow", hasReverse: false, offsetConnector: false }] @@ -404,7 +477,7 @@ export function createStateTransitionRoutePlans( if (from.centerY !== to.centerY) { if (!hasVerticalCorridor(from, to)) { - return [{ ...base, kind: "side-parallel", railX: allocateSideRail(transition.label) }] + return [sideParallel()] } if (from.centerY > to.centerY && feedback) return [ @@ -540,7 +613,20 @@ function addHorizontalForward(builder: StateTransitionRenderBuilder): void { function addSelfTransition(builder: StateTransitionRenderBuilder): void { const { from: bounds, transition } = builder.route - if (bounds.width <= 1 || bounds.height <= 1) return + if (bounds.width <= 1 || bounds.height <= 1) { + const railX = bounds.left + 4 + const railY = bounds.top + 2 + addHorizontalLine(builder, bounds.left + 1, railX - 1, bounds.top, 1) + addCell(builder, { x: railX, y: bounds.top, char: "╮" }) + addCell(builder, { x: railX, y: bounds.top + 1, char: "│" }) + addCell(builder, { x: railX, y: railY, char: "╯" }) + addHorizontalLine(builder, railX - 1, bounds.left + 1, railY, -1) + addCell(builder, { x: bounds.left, y: railY, char: "╰" }) + addCell(builder, { x: bounds.left, y: bounds.top + 1, arrowDirection: "up" }) + addPathPoint(builder, bounds.left, bounds.top) + if (transition.label) addLabel(builder, railX + 2, bounds.top + 1, transition.label) + return + } const sourceX = bounds.left + Math.max(2, Math.floor(bounds.width / 3)) const bottomY = bounds.top + bounds.height - 1 const railY = bottomY + 2 @@ -646,20 +732,36 @@ function addTopFeedbackTransition(builder: StateTransitionRenderBuilder): void { } function addSideParallelTransition(builder: StateTransitionRenderBuilder): void { - const { from, to, targetIsChoice, targetIsHiddenMarker, transition, railX } = builder.route as Extract< + const { from, to, targetIsChoice, targetIsHiddenMarker, transition, railX, targetApproach } = builder.route as Extract< StateTransitionRoutePlan, { kind: "side-parallel" } > const startX = from.left + from.width - const endX = to.left + to.width const startY = from.centerY - const endY = to.centerY + const endY = targetApproach === "top" ? to.top - 2 : targetApproach === "bottom" ? to.top + to.height + 1 : to.centerY const verticalStep: 1 | -1 = startY <= endY ? 1 : -1 addRightDeparture(builder, from) addHorizontalLine(builder, startX, railX - 1, startY, 1) addCell(builder, { x: railX, y: startY, char: verticalStep === 1 ? "╮" : "╯" }) for (let y = startY + verticalStep; y !== endY; y += verticalStep) addCell(builder, { x: railX, y, char: "│" }) addCell(builder, { x: railX, y: endY, char: verticalStep === 1 ? "╯" : "╮" }) + if (targetApproach) { + const targetX = innerConnectorX(to, from.centerX) + for (let x = railX - 1; x > targetX; x--) addCell(builder, { x, y: endY, char: "─" }) + addCell(builder, { x: targetX, y: endY, char: targetApproach === "top" ? "╭" : "╰" }) + addCell(builder, { + x: targetX, + y: targetApproach === "top" ? endY + 1 : endY - 1, + arrowDirection: targetApproach === "top" ? "down" : "up", + }) + if (targetIsChoice || targetIsHiddenMarker) addPathPoint(builder, to.left, to.top) + if (transition.label) { + const metrics = measureStateTransitionLabel(transition.label) + addLabel(builder, railX + 2, Math.max(0, Math.floor((startY + to.centerY - metrics.height + 1) / 2)), transition.label) + } + return + } + const endX = to.left + to.width for (let x = railX - 1; x > endX; x--) addCell(builder, { x, y: endY, char: "─" }) addCell( builder, @@ -797,10 +899,187 @@ function createStateTransitionRenderPlan(route: StateTransitionRoutePlan): State return builder } +function pointIsInsideBounds(point: StateTransitionPathPoint, bounds: BoxBounds): boolean { + return ( + point[0] >= bounds.left && + point[0] < bounds.left + bounds.width && + point[1] >= bounds.top && + point[1] < bounds.top + bounds.height + ) +} + +function routeIntersectsUnrelatedState( + plan: StateTransitionRenderPlan, + diagram: StateVisibleDiagram, + bounds: ReadonlyMap, + noteBounds: readonly StateDiagramNoteBounds[], +): boolean { + if ( + diagram.states.some((state) => { + if ( + state.id === plan.route.transition.from || + state.id === plan.route.transition.to || + isHiddenCompositeMarker(state) + ) + return false + const bound = bounds.get(state.id) + return Boolean(bound && plan.path.some((point) => pointIsInsideBounds(point, bound))) + }) + ) + return true + + return noteBounds.some((noteBound) => { + if (plan.path.some((point) => pointIsInsideBounds(point, noteBound))) return true + const target = bounds.get(noteBound.note.target) + if (!target) return false + const connector = spatialPathClaim( + `note-connector:${noteBound.id}`, + `note-connector:${noteBound.id}`, + "boundary", + stateDiagramNoteConnector(noteBound, target).points, + ) + return plan.path.some(([x, y]) => + connector.spans.some((span) => span.y === y && x >= span.fromX && x <= span.toX), + ) + }) +} + +function findBodySafePath( + start: StateTransitionPathPoint, + end: StateTransitionPathPoint, + bounds: ReadonlyMap, + plan: StateTransitionRenderPlan, + search: StateSearchSpace, + budget: StateSearchBudget, +): StateTransitionPathPoint[] | undefined { + const margin = Math.max(8, bounds.size * 2) + const path = findStateManhattanPath( + [{ x: start[0], y: start[1] }], + { x: end[0], y: end[1] }, + search, + { + minX: search.minX - margin, + minY: Math.min(search.minY, ...plan.path.map((point) => point[1])) - margin, + maxX: Math.max(search.maxX, ...plan.path.map((point) => point[0])) + margin, + maxY: Math.max(search.maxY, ...plan.path.map((point) => point[1])) + margin, + }, + budget, + ) + return path?.map((point) => [point.x, point.y] as const) +} + +function bodySafeTransitionPlan( + plan: StateTransitionRenderPlan, + diagram: StateVisibleDiagram, + bounds: ReadonlyMap, + noteBounds: readonly StateDiagramNoteBounds[], + search: StateSearchSpace, + budget: StateSearchBudget, +): StateTransitionRenderPlan { + if (!routeIntersectsUnrelatedState(plan, diagram, bounds, noteBounds)) return plan + const sourceOutsideIndex = plan.path.findIndex((point) => !pointIsInsideBounds(point, plan.route.from)) + const targetOutsideIndex = plan.path.findLastIndex((point) => !pointIsInsideBounds(point, plan.route.to)) + if (sourceOutsideIndex < 0 || targetOutsideIndex < sourceOutsideIndex) return plan + const safePath = findBodySafePath(plan.path[sourceOutsideIndex]!, plan.path[targetOutsideIndex]!, bounds, plan, search, budget) + if (!safePath) return alternateBodySafeTransitionPlan(plan, diagram, bounds, noteBounds, search, budget) + + const prefix = plan.path.slice(0, sourceOutsideIndex) + const suffix = plan.path.slice(targetOutsideIndex + 1) + return renderBodySafeTransitionPlan(plan, safePath, prefix, suffix) +} + +function alternateBodySafeTransitionPlan( + plan: StateTransitionRenderPlan, + diagram: StateVisibleDiagram, + bounds: ReadonlyMap, + noteBounds: readonly StateDiagramNoteBounds[], + search: StateSearchSpace, + budget: StateSearchBudget, +): StateTransitionRenderPlan { + for (const source of stateRoutePorts(plan.route.from)) { + for (const target of stateRoutePorts(plan.route.to)) { + const safePath = findBodySafePath(source.outside, target.outside, bounds, plan, search, budget) + if (!safePath) continue + const prefix = plan.route.from.width > 1 && plan.route.from.height > 1 ? [source.border] : [] + const suffix = + plan.route.targetIsChoice || plan.route.targetIsHiddenMarker + ? ([[plan.route.to.left, plan.route.to.top]] as const) + : [] + const repaired = renderBodySafeTransitionPlan(plan, safePath, prefix, suffix, source.char) + if (!routeIntersectsUnrelatedState(repaired, diagram, bounds, noteBounds)) return repaired + } + } + return plan +} + +function stateRoutePorts(bounds: BoxBounds): Array<{ + outside: StateTransitionPathPoint + border: StateTransitionPathPoint + char: string +}> { + return [ + { + outside: [bounds.centerX, bounds.top - 1] as const, + border: [bounds.centerX, bounds.top] as const, + char: BorderChars.rounded.bottomT, + }, + { + outside: [bounds.centerX, bounds.top + bounds.height] as const, + border: [bounds.centerX, bounds.top + bounds.height - 1] as const, + char: BorderChars.rounded.topT, + }, + { + outside: [bounds.left - 1, bounds.centerY] as const, + border: [bounds.left, bounds.centerY] as const, + char: BorderChars.rounded.rightT, + }, + { + outside: [bounds.left + bounds.width, bounds.centerY] as const, + border: [bounds.left + bounds.width - 1, bounds.centerY] as const, + char: BorderChars.rounded.leftT, + }, + ].filter((port) => port.outside[0] >= 0) +} + +function renderBodySafeTransitionPlan( + plan: StateTransitionRenderPlan, + safePath: readonly StateTransitionPathPoint[], + prefix: readonly StateTransitionPathPoint[], + suffix: readonly StateTransitionPathPoint[], + sourceChar?: string, +): StateTransitionRenderPlan { + const prefixKeys = new Set(prefix.map(([x, y]) => `${x}:${y}`)) + const cells: StateTransitionRenderCell[] = sourceChar + ? prefix.map(([x, y]) => ({ x, y, char: sourceChar })) + : plan.cells.filter((cell) => prefixKeys.has(`${cell.x}:${cell.y}`)) + const fullPath = [...prefix, ...safePath, ...suffix] + const previous = prefix.at(-1) + for (const [index, point] of safePath.entries()) { + if (index === safePath.length - 1) { + const targetDirection = connectionDirection(point, [plan.route.to.centerX, plan.route.to.centerY]) + cells.push( + plan.route.targetIsHiddenMarker + ? { x: point[0], y: point[1], char: targetDirection === "left" || targetDirection === "right" ? "─" : "│" } + : { x: point[0], y: point[1], arrowDirection: targetDirection }, + ) + continue + } + const before = index === 0 ? previous : safePath[index - 1] + const after = safePath[index + 1]! + const connections = new Set() + if (before) connections.add(connectionDirection(point, before)) + connections.add(connectionDirection(point, after)) + cells.push({ x: point[0], y: point[1], char: diagramLineGlyph(connections, "rounded") }) + } + + return { ...plan, cells, path: fullPath } +} + function placeStateTransitionLabels( plans: readonly StateTransitionRenderPlan[], diagram: StateVisibleDiagram, bounds: ReadonlyMap, + noteBounds: readonly StateDiagramNoteBounds[], ): StateTransitionRenderPlan[] { let space = SpatialIndex.empty().add( ...diagram.states.flatMap((state) => { @@ -817,6 +1096,22 @@ function placeStateTransitionLabels( plan.path.map(([x, y]) => ({ x, y })), ), ), + ...noteBounds.flatMap((noteBound) => { + const target = bounds.get(noteBound.note.target) + return [ + spatialRectClaim(`note:${noteBound.id}`, `note:${noteBound.id}`, "body", noteBound), + ...(target + ? [ + spatialPathClaim( + `note-connector:${noteBound.id}`, + `note-connector:${noteBound.id}`, + "boundary", + stateDiagramNoteConnector(noteBound, target).points, + ), + ] + : []), + ] + }), ) return plans.map((plan, planIndex) => { @@ -866,12 +1161,51 @@ export function createStateTransitionRenderPlans( diagram: StateVisibleDiagram, bounds: ReadonlyMap, feedbackLaneY: number, - feedbackTopY?: number, + options: StateTransitionRenderOptions = {}, ): StateTransitionRenderPlan[] { + const noteBounds = options.noteBounds ?? [] + const budget = options.searchBudget ?? createStateSearchBudget() + const plans = createStateTransitionRoutePlans(diagram, bounds, feedbackLaneY, options.feedbackTopY).map( + createStateTransitionRenderPlan, + ) + if (options.repairRoutes === false) return placeStateTransitionLabels(plans, diagram, bounds, noteBounds) + const routeSpace = createStateSearchSpace(transitionObstacles(diagram, bounds, noteBounds)) return placeStateTransitionLabels( - createStateTransitionRoutePlans(diagram, bounds, feedbackLaneY, feedbackTopY).map(createStateTransitionRenderPlan), + plans.map((plan) => bodySafeTransitionPlan(plan, diagram, bounds, noteBounds, routeSpace, budget)), diagram, bounds, + noteBounds, + ) +} + +function transitionObstacles( + diagram: StateVisibleDiagram, + bounds: ReadonlyMap, + noteBounds: readonly StateDiagramNoteBounds[], +): SpatialIndex { + return SpatialIndex.empty().add( + ...diagram.states.flatMap((state) => { + const bound = bounds.get(state.id) + return bound && !isHiddenCompositeMarker(state) + ? [spatialRectClaim(`state:${state.id}`, `state:${state.id}`, "body", bound)] + : [] + }), + ...noteBounds.flatMap((noteBound) => { + const target = bounds.get(noteBound.note.target) + return [ + spatialRectClaim(`note:${noteBound.id}`, `note:${noteBound.id}`, "body", noteBound), + ...(target + ? [ + spatialPathClaim( + `note-connector:${noteBound.id}`, + `note-connector:${noteBound.id}`, + "boundary", + stateDiagramNoteConnector(noteBound, target).points, + ), + ] + : []), + ] + }), ) } diff --git a/packages/merman/src/state/search.ts b/packages/merman/src/state/search.ts new file mode 100644 index 00000000000..e9553c8231e --- /dev/null +++ b/packages/merman/src/state/search.ts @@ -0,0 +1,103 @@ +import type { DiagramPoint } from "../core/geometry.js" +import type { SpatialIndex, SpatialRole } from "../core/spatial.js" + +const MAX_RENDER_SEARCH_VISITS = 250_000 + +export interface StateSearchBudget { + remaining: number +} + +export interface StateSearchSpace { + blocked: ReadonlySet + minX: number + minY: number + maxX: number + maxY: number +} + +export function createStateSearchBudget(): StateSearchBudget { + return { remaining: MAX_RENDER_SEARCH_VISITS } +} + +export function createStateSearchSpace( + space: SpatialIndex, + clearance: (role: SpatialRole) => number = () => 0, +): StateSearchSpace { + const blocked = new Set() + let minX = 0 + let minY = 0 + let maxX = 0 + let maxY = 0 + + for (const claim of space.claims) { + const padding = clearance(claim.role) + for (const span of claim.spans) { + for (let y = span.y - padding; y <= span.y + padding; y++) { + for (let x = span.fromX - padding; x <= span.toX + padding; x++) { + blocked.add(`${x}:${y}`) + minX = Math.min(minX, x) + minY = Math.min(minY, y) + maxX = Math.max(maxX, x) + maxY = Math.max(maxY, y) + } + } + } + } + + return { blocked, minX, minY, maxX, maxY } +} + +export function findStateManhattanPath( + starts: readonly DiagramPoint[], + goal: DiagramPoint, + space: Pick, + bounds: Pick, + budget: StateSearchBudget, + isFree: (point: DiagramPoint) => boolean = (point) => !space.blocked.has(pointKey(point)), +): DiagramPoint[] | undefined { + const queue = [...new Map(starts.filter(isFree).map((point) => [pointKey(point), point])).values()] + const parents = new Map(queue.map((point) => [pointKey(point), undefined as string | undefined])) + const points = new Map(queue.map((point) => [pointKey(point), point])) + + for (let cursor = 0; cursor < queue.length && budget.remaining > 0; cursor++, budget.remaining--) { + const current = queue[cursor]! + if (current.x === goal.x && current.y === goal.y) break + const dx = Math.sign(goal.x - current.x) + const dy = Math.sign(goal.y - current.y) + const candidates = [ + ...(dx === 0 ? [] : [{ x: current.x + dx, y: current.y }]), + ...(dy === 0 ? [] : [{ x: current.x, y: current.y + dy }]), + { x: current.x + 1, y: current.y }, + { x: current.x, y: current.y + 1 }, + { x: current.x - 1, y: current.y }, + { x: current.x, y: current.y - 1 }, + ] + for (const candidate of candidates) { + if ( + candidate.x < bounds.minX || + candidate.x > bounds.maxX || + candidate.y < bounds.minY || + candidate.y > bounds.maxY + ) + continue + const key = pointKey(candidate) + if (parents.has(key) || !isFree(candidate)) continue + parents.set(key, pointKey(current)) + points.set(key, candidate) + queue.push(candidate) + } + } + + if (!parents.has(pointKey(goal))) return undefined + const path: DiagramPoint[] = [] + let cursor: string | undefined = pointKey(goal) + while (cursor) { + path.push(points.get(cursor)!) + cursor = parents.get(cursor) + } + return path.reverse() +} + +function pointKey(point: DiagramPoint): string { + return `${point.x}:${point.y}` +}