diff --git a/packages/merman/package.json b/packages/merman/package.json
index d65fe8783b6..66877df294e 100644
--- a/packages/merman/package.json
+++ b/packages/merman/package.json
@@ -10,6 +10,7 @@
"./plugin": "./src/plugin.ts"
},
"scripts": {
+ "audit:layouts": "bun run script/layout-audit.ts",
"test": "bun test --timeout 30000 --only-failures",
"typecheck": "tsgo --noEmit"
},
diff --git a/packages/merman/script/layout-audit.ts b/packages/merman/script/layout-audit.ts
new file mode 100644
index 00000000000..e6085ce518e
--- /dev/null
+++ b/packages/merman/script/layout-audit.ts
@@ -0,0 +1,98 @@
+import { mkdir } from "node:fs/promises"
+import { dirname, resolve } from "node:path"
+import {
+ auditAllFixtures,
+ summarizeAudits,
+ worstAudits,
+ type LayoutAudit,
+ type LayoutMetrics,
+} from "../src/test/layout-audit/harness.js"
+
+const outputPath = resolve(import.meta.dir, "../../../tmp/merman-layout-audit.md")
+const startedAt = performance.now()
+const audits = auditAllFixtures()
+const elapsedMs = performance.now() - startedAt
+const summary = summarizeAudits(audits)
+
+function label(audit: LayoutAudit): string {
+ return `${audit.fixture.id} @${audit.viewport}`
+}
+
+function metricTable(items: readonly LayoutAudit[]): string {
+ return [
+ "| Fixture | Viewport | Size | Area | Route length | Bends | Crossings | Shared cells | Overflow |",
+ "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
+ ...items.map(
+ (audit) =>
+ `| \`${audit.fixture.id}\` | ${audit.viewport} | ${audit.metrics.width}x${audit.metrics.height} | ${audit.metrics.area} | ${audit.metrics.routeLength} | ${audit.metrics.bends} | ${audit.metrics.crossings} | ${audit.metrics.sharedRouteCells} | ${audit.metrics.overflow} |`,
+ ),
+ ].join("\n")
+}
+
+function worstSection(metric: keyof LayoutMetrics): string {
+ const worst = worstAudits(audits, metric)
+ return [`### ${metric}`, "", metricTable(worst)].join("\n")
+}
+
+function fixtureSection(audit: LayoutAudit): string {
+ return [
+ ` 0 ? " open" : ""}>`,
+ `${label(audit)} · ${audit.metrics.width}x${audit.metrics.height} · area ${audit.metrics.area} · bends ${audit.metrics.bends} · crossings ${audit.metrics.crossings} · overflow ${audit.metrics.overflow}
`,
+ "",
+ ...(audit.violations.length > 0 ? ["Violations:", "", ...audit.violations.map((item) => `- ${item}`), ""] : []),
+ "Source:",
+ "",
+ "```mermaid",
+ audit.fixture.source,
+ "```",
+ "",
+ "Rendered output:",
+ "",
+ "```text",
+ audit.output,
+ "```",
+ "",
+ " ",
+ ].join("\n")
+}
+
+const grouped = Map.groupBy(audits, (audit) => `${audit.fixture.kind}/${audit.fixture.family}`)
+const violations = audits.flatMap((audit) => audit.violations.map((violation) => `${label(audit)}: ${violation}`))
+const markdown = [
+ "# Merman Layout Audit",
+ "",
+ `Generated from ${new Set(audits.map((audit) => audit.fixture.id)).size} sources and ${audits.length} layout runs.`,
+ "",
+ `Structural violations: **${violations.length}**`,
+ "",
+ "## Aggregate Metrics",
+ "",
+ "```json",
+ JSON.stringify(summary, null, 2),
+ "```",
+ "",
+ "## Worst Offenders",
+ "",
+ ...(["area", "bends", "crossings", "sharedRouteCells", "overflow"] as const).flatMap((metric) => [
+ worstSection(metric),
+ "",
+ ]),
+ "## Fixtures",
+ "",
+ ...[...grouped.entries()].flatMap(([family, items]) => [
+ `### ${family}`,
+ "",
+ metricTable(items),
+ "",
+ ...items.flatMap((audit) => [fixtureSection(audit), ""]),
+ ]),
+].join("\n")
+
+await mkdir(dirname(outputPath), { recursive: true })
+await Bun.write(outputPath, markdown)
+
+console.log(`Wrote ${audits.length} layout runs to ${outputPath} in ${elapsedMs.toFixed(0)}ms`)
+if (violations.length > 0) {
+ console.error(violations.join("\n"))
+ process.exitCode = 1
+}
diff --git a/packages/merman/src/flowchart/drawing.ts b/packages/merman/src/flowchart/drawing.ts
index 1582ad5a463..96bc54d7333 100644
--- a/packages/merman/src/flowchart/drawing.ts
+++ b/packages/merman/src/flowchart/drawing.ts
@@ -43,8 +43,9 @@ function mergeFlowchartCell(
if (incoming.style !== "edge") return incoming
if (existing.style === "label") return existing
if (incoming.char === " ") return existing
- 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
+ if (existing.style !== "edge" || existing.char === " ") return incoming
+ if (DIAGRAM_ARROW_HEADS.has(existing.char)) return existing
+ if (DIAGRAM_ARROW_HEADS.has(incoming.char)) return incoming
return {
...incoming,
@@ -75,22 +76,23 @@ function drawNode(
): void {
const chars = BorderChars[borderStyle]
const style: FlowchartCellStyle = node.shape === "database" ? "database" : "node"
+ const border: FlowchartCellStyle = node.shape === "database" ? "databaseBorder" : "nodeBorder"
if (node.shape === "decision") {
drawDiagramDiamond(
bounds,
- (x, y, char) => grid.setCell(x, y, char, style),
+ (x, y, char) => grid.setCell(x, y, char, border),
diagramDiamondCharactersFromBorder(chars),
)
} else if (node.shape === "subroutine") {
fillDiagramFrameInterior(bounds, (x, y) => grid.setCell(x, y, " ", style))
- drawSubroutineNode(grid, bounds, chars, style)
+ drawSubroutineNode(grid, bounds, chars, border)
} else if (node.shape === "database") {
fillDiagramFrameInterior(bounds, (x, y) => grid.setCell(x, y, " ", style))
- drawDatabaseNode(grid, bounds, chars, style)
+ drawDatabaseNode(grid, bounds, chars, border)
} else {
fillDiagramFrameInterior(bounds, (x, y) => grid.setCell(x, y, " ", style))
- drawDiagramFrame(bounds, chars, (x, y, char) => grid.setCell(x, y, char, style))
+ drawDiagramFrame(bounds, chars, (x, y, char) => grid.setCell(x, y, char, border))
}
const textTop =
@@ -270,7 +272,7 @@ function drawSourceConnectors(
const connectorDirection = flowchartDirectionBetween(sourcePoint, connector)
if (routeDirection && connectorDirection) {
const cell = grid.getCell(sourcePoint.x, sourcePoint.y)
- if (cell && cell.style !== "label") {
+ if (cell && cell.style !== "label" && !DIAGRAM_ARROW_HEADS.has(cell.char)) {
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 99c8229f495..ac3eb86f1bc 100644
--- a/packages/merman/src/flowchart/flowchart.test.ts
+++ b/packages/merman/src/flowchart/flowchart.test.ts
@@ -1,7 +1,10 @@
import { describe, expect, test } from "bun:test"
import { parseColor, TextAttributes } from "@opentui/core"
import stringWidth from "string-width"
+import { diagramArrowHeadBetween } from "../core/drawing.js"
+import { orthogonalPathPoints } from "../core/geometry.js"
import { expectDiagram } from "../test/diagram.js"
+import { deploymentArchitectureSource } from "../test/layout-audit/fixtures.js"
import { drawFlowchartDiagramGrid as drawParsedFlowchartDiagramGrid } from "./drawing.js"
import {
DEFAULT_MIN_RANK_GAP,
@@ -35,7 +38,7 @@ function routeRunsAlongHorizontalBorder(
const from = route.points[index - 1]!
const to = route.points[index]!
if (from.y !== to.y || !borderYs.has(from.y)) continue
- if (Math.max(from.x, to.x) >= left && Math.min(from.x, to.x) <= right) return true
+ if (Math.min(Math.max(from.x, to.x), right) > Math.max(Math.min(from.x, to.x), left)) return true
}
return false
}
@@ -52,7 +55,7 @@ function routeRunsAlongVerticalBorder(
const from = route.points[index - 1]!
const to = route.points[index]!
if (from.x !== to.x || !borderXs.has(from.x)) continue
- if (Math.max(from.y, to.y) >= top && Math.min(from.y, to.y) <= bottom) return true
+ if (Math.min(Math.max(from.y, to.y), bottom) > Math.max(Math.min(from.y, to.y), top)) return true
}
return false
}
@@ -114,6 +117,111 @@ function boundsIntersect(
)
}
+function boundsContains(
+ outer: { left: number; top: number; width: number; height: number },
+ inner: { left: number; top: number; width: number; height: number },
+): boolean {
+ return (
+ inner.left >= outer.left &&
+ inner.top >= outer.top &&
+ inner.left + inner.width <= outer.left + outer.width &&
+ inner.top + inner.height <= outer.top + outer.height
+ )
+}
+
+function routesIntersect(
+ left: { points: readonly { x: number; y: number }[] },
+ right: { points: readonly { x: number; y: number }[] },
+): boolean {
+ const occupied = new Set(orthogonalPathPoints(left.points).map((point) => `${point.x}:${point.y}`))
+ return orthogonalPathPoints(right.points).some((point) => occupied.has(`${point.x}:${point.y}`))
+}
+
+function renderedDimensions(output: string): { width: number; height: number } {
+ const lines = output.split("\n")
+ return { width: Math.max(...lines.map((line) => stringWidth(line))), height: lines.length }
+}
+
+function expectResponsiveFlowchartValid(content: string, layoutMaxWidth: number) {
+ const diagram = parseMermaidFlowchartDiagram(content)
+ const options = { compact: true, layoutMaxWidth }
+ const layout = layoutParsedFlowchartDiagram(diagram, options)
+ const grid = drawParsedFlowchartDiagramGrid(diagram, options)
+ const output = renderFlowchartDiagram(content, options)
+ const nodes = [...layout.bounds.values()]
+
+ expect(layout.diagram.direction).toBe("TD")
+ for (let left = 0; left < nodes.length; left++) {
+ for (let right = left + 1; right < nodes.length; right++) {
+ expect(boundsIntersect(nodes[left]!, nodes[right]!)).toBe(false)
+ }
+ }
+ for (const route of layout.routes) {
+ expect(route.points.length).toBeGreaterThanOrEqual(2)
+ for (let index = 1; index < route.points.length; index++) {
+ const from = route.points[index - 1]!
+ const to = route.points[index]!
+ expect(from.x === to.x || from.y === to.y).toBe(true)
+ }
+ expect(terminalPointsTowardBounds(route, layout.bounds.get(route.edge.to)!)).toBe(true)
+ const end = route.points.at(-1)!
+ expect(grid.getCell(end.x, end.y)?.char).toBe(diagramArrowHeadBetween(route.points.at(-2)!, end))
+ if (route.edge.label) expect(output).toContain(route.edge.label)
+ }
+ expectFlowchartRoutesAvoidUnrelatedNodes(layout)
+
+ for (const subgraph of diagram.subgraphs ?? []) {
+ const frame = layout.subgraphBounds.get(subgraph.id)!
+ for (const nodeId of subgraph.nodeIds) expect(boundsContains(frame, layout.bounds.get(nodeId)!)).toBe(true)
+ expect(output).toContain(subgraph.label)
+ }
+ for (const node of layout.bounds.values()) {
+ for (const line of node.lines) expect(output).toContain(line)
+ }
+
+ const widestContent = Math.max(
+ ...nodes.map((node) => node.width),
+ ...layout.routes.flatMap((route) =>
+ route.edge.label ? [flowchartRouteLabelLayout(route, visualLength).width] : [],
+ ),
+ )
+ const dimensions = renderedDimensions(output)
+ expect(Math.max(...output.split("\n").map((line) => stringWidth(line)))).toBeLessThanOrEqual(
+ layoutMaxWidth + widestContent + 4,
+ )
+ return { dimensions, layout, output }
+}
+
+function generatedWideRankFlowchart(count: number): string {
+ const labels = [
+ "地域 gateway Ω",
+ "界面 worker λ",
+ "Long-running synchronization service",
+ "Cache café 🚀",
+ "Audit and observability pipeline",
+ "Provider μ endpoint",
+ "Fallback Ж service",
+ "Archive 数据 lake",
+ "Terminal résumé queue",
+ ]
+ const branches = labels
+ .slice(0, count)
+ .flatMap((label, index) => [
+ ` Hub ${index === 0 ? "-->|dispatch across regions and providers|" : "-->"} N${index}[${label}]`,
+ ` N${index} --> Join`,
+ ])
+ return [
+ "flowchart LR",
+ " Start[Client α] --> Hub",
+ " subgraph Services [地域 services Ω]",
+ " Hub[Dispatch hub]",
+ ...branches,
+ " Join[Join results]",
+ " end",
+ " Join --> Done[Complete ✓]",
+ ].join("\n")
+}
+
function expectFlowchartRoutesAvoidUnrelatedNodes(layout: ReturnType): void {
for (const route of layout.routes) {
for (const [id, bounds] of layout.bounds) {
@@ -327,6 +435,52 @@ describe("FlowchartDiagram", () => {
`)
})
+ test.each(
+ (["LR", "RL", "TD", "TB", "BT"] as const).flatMap((direction) =>
+ [false, true].map((compact) => ({ direction, compact })),
+ ),
+ )(
+ "preserves every target arrowhead after painting $direction routes with compact=$compact",
+ ({ direction, compact }) => {
+ const content = `flowchart ${direction}
+ A[A]
+ B[B]
+ C[C]
+ D[D]
+ A --> A
+ A --> C
+ C --> B`
+ const diagram = parseMermaidFlowchartDiagram(content)
+ const layout = layoutParsedFlowchartDiagram(diagram, { compact })
+ const grid = drawParsedFlowchartDiagramGrid(diagram, { compact })
+
+ for (const route of layout.routes) {
+ const end = route.points.at(-1)!
+ expect(grid.getCell(end.x, end.y)?.char).toBe(diagramArrowHeadBetween(route.points.at(-2)!, end))
+ }
+ },
+ )
+
+ test.each(
+ (["LR", "RL", "TD", "TB", "BT"] as const).flatMap((direction) =>
+ [false, true].map((compact) => ({ direction, compact })),
+ ),
+ )("keeps crossed endpoint-disjoint $direction routes separate with compact=$compact", ({ direction, compact }) => {
+ const layout = layoutFlowchartDiagram(
+ `flowchart ${direction}
+ A[A]
+ B[B]
+ C[C]
+ D[D]
+ A --> C
+ D --> B`,
+ { compact },
+ )
+
+ expect(layout.routes).toHaveLength(2)
+ expect(routesIntersect(layout.routes[0]!, layout.routes[1]!)).toBe(false)
+ })
+
test("keeps vertical feedback labels clear of unrelated nodes", () => {
const content = `flowchart TD
S[Source] --> A[Alpha]
@@ -613,6 +767,7 @@ describe("FlowchartDiagram", () => {
const loops = layout.routes.filter((route) => route.edge.from === "B" && route.edge.to === "B")
expect(loops).toHaveLength(3)
+ expect(new Set(loops.map((route) => JSON.stringify(route.points))).size).toBe(3)
},
)
@@ -811,6 +966,119 @@ describe("FlowchartDiagram", () => {
`)
})
+ test("wraps the real deployment chart responsively without losing content or geometry", () => {
+ const expected = new Map([
+ [60, { width: 82, height: 108 }],
+ [80, { width: 97, height: 85 }],
+ [120, { width: 143, height: 77 }],
+ [160, { width: 163, height: 69 }],
+ ])
+ const results = [...expected].map(([budget, dimensions]) => {
+ const result = expectResponsiveFlowchartValid(deploymentArchitectureSource, budget)
+ expect(result.dimensions).toEqual(dimensions)
+ for (const frame of result.layout.subgraphBounds.values()) {
+ for (const other of result.layout.subgraphBounds.values()) {
+ if (frame !== other) expect(boundsIntersect(frame, other)).toBe(false)
+ }
+ }
+ return result.dimensions
+ })
+
+ for (let index = 1; index < results.length; index++) {
+ expect(results[index - 1]!.width).toBeLessThan(results[index]!.width)
+ }
+ })
+
+ test.each([7, 9])("wraps generated %s-node Unicode subgraph ranks across width targets", (count) => {
+ const results = [60, 80, 120].map(
+ (budget) => expectResponsiveFlowchartValid(generatedWideRankFlowchart(count), budget).dimensions,
+ )
+
+ for (let index = 1; index < results.length; index++) {
+ expect(results[index - 1]!.width).toBeLessThan(results[index]!.width)
+ expect(results[index - 1]!.height).toBeGreaterThanOrEqual(results[index]!.height)
+ }
+ })
+
+ test("keeps responsive local-direction subgraphs clear of sibling nodes", () => {
+ const layout = layoutFlowchartDiagram(
+ `flowchart BT
+ N0[Outside zero]
+ subgraph Outer
+ N2[Two]
+ subgraph Inner
+ direction LR
+ N3[Three]
+ N4[Four]
+ end
+ N5[X]
+ end
+ N7[Outside seven]
+ N7 --> N2
+ N2 -->|label 6| N5
+ N5 --> N4
+ N0 --> N7`,
+ { compact: true, layoutMaxWidth: 35 },
+ )
+ const nodes = [...layout.bounds.values()]
+
+ for (let left = 0; left < nodes.length; left++) {
+ for (let right = left + 1; right < nodes.length; right++) {
+ expect(boundsIntersect(nodes[left]!, nodes[right]!)).toBe(false)
+ }
+ }
+ })
+
+ test("keeps responsive sibling subgraph frames and long titles disjoint", () => {
+ const layout = layoutFlowchartDiagram(
+ `flowchart TD
+ subgraph Parent
+ direction LR
+ subgraph Left [A deliberately long left group title]
+ direction LR
+ A1[One] --> A2[Two]
+ end
+ subgraph Right [A deliberately long right group title]
+ direction LR
+ B1[Three] --> B2[Four]
+ end
+ A2 --> B1
+ end`,
+ { compact: true, layoutMaxWidth: 35 },
+ )
+
+ expect(boundsIntersect(layout.subgraphBounds.get("Left")!, layout.subgraphBounds.get("Right")!)).toBe(false)
+ })
+
+ test("does not change parallel routes for a non-binding width target", () => {
+ const diagram = parseMermaidFlowchartDiagram(`flowchart TD
+ A[A] -->|one| B[B]
+ A -->|two| B`)
+ const unconstrained = layoutParsedFlowchartDiagram(diagram, { compact: true })
+ const nonBinding = layoutParsedFlowchartDiagram(diagram, { compact: true, layoutMaxWidth: 1_000 })
+
+ expect(nonBinding.routes.map((route) => route.points)).toEqual(unconstrained.routes.map((route) => route.points))
+ })
+
+ test("keeps responsive fan-out labels inside the width target", () => {
+ const content = `flowchart TD
+ subgraph Group
+ S[Source]
+ S -->|route 0 detail| N0[Node 0]
+ S -->|route 1 detail| N1[Node 1]
+ S -->|route 2 detail| N2[Node 2]
+ S -->|route 3 detail| N3[Node 3]
+ end`
+ const layout = layoutFlowchartDiagram(content, { compact: true, layoutMaxWidth: 30 })
+ const output = renderFlowchartDiagram(content, { compact: true, layoutMaxWidth: 30 })
+
+ for (const route of layout.routes) {
+ const label = flowchartRouteLabelLayout(route, visualLength)
+ expect(label.point.x + label.width).toBeLessThanOrEqual(30)
+ }
+ expect(Math.max(...output.split("\n").map((line) => stringWidth(line)))).toBeLessThanOrEqual(34)
+ })
+
test("parses Mermaid flowchart nodes and standard arrows", () => {
const diagram = parseMermaidFlowchartDiagram(`
flowchart TD
@@ -1349,6 +1617,22 @@ flowchart LR
expect(output).not.toContain("
+ [false, true].flatMap((compact) => [2, 4].map((lines) => ({ direction, compact, lines }))),
+ ),
+ )(
+ "keeps $lines-line $direction labels off both terminal rows with compact=$compact",
+ ({ direction, compact, lines }) => {
+ const label = Array.from({ length: lines }, (_, index) => `line ${index + 1}`).join("
")
+ const route = layoutFlowchartDiagram(`flowchart ${direction}\n A[A] -->|${label}| B[B]`, { compact }).routes[0]!
+ const layout = flowchartRouteLabelLayout(route, visualLength)
+ const terminals = new Set([route.points[0]!.y, route.points.at(-1)!.y])
+
+ for (let y = layout.point.y; y < layout.point.y + layout.height; y++) expect(terminals.has(y)).toBe(false)
+ },
+ )
+
test("expands canvas for multiline back-edge labels", () => {
const output = renderFlowchartDiagram(`flowchart TD
A --> B
@@ -1393,10 +1677,10 @@ 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"))).toContain("┼")
+ expect(output.split("\n").find((line) => line.includes("API") && line.includes("DB"))).not.toContain("┼")
})
- test("merges horizontal routes through vertical subgraph borders", () => {
+ test("breaks vertical subgraph borders where horizontal routes pass through", () => {
const content = `flowchart LR
Outside[Outside] --> Inside
subgraph Group
@@ -1408,13 +1692,14 @@ graph LR
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, crossing.y)?.char).toBe("─")
+ expect(grid.getCell(crossing.x, crossing.y)?.style).not.toBe("group")
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", () => {
+ test("breaks horizontal subgraph borders where vertical routes pass through", () => {
const content = `flowchart TD
Outside[Outside] --> Inside
subgraph Outer [O]
@@ -1428,7 +1713,8 @@ graph LR
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, crossing.y)?.char).toBe("│")
+ expect(grid.getCell(crossing.x, crossing.y)?.style).not.toBe("group")
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("│")
@@ -1466,21 +1752,20 @@ graph LR
expect(output).not.toContain("
{
- const output = renderFlowchartDiagram(`
-flowchart TD
- subgraph Verse [verse]
- direction LR
- A[A] --> B[B]
- C[C] --> D[D]
+ test("keeps long Unicode subgraph titles from replacing entering arrowheads", () => {
+ const content = `flowchart TD
+ U[Up] --> A
+ subgraph G [界界界界界界]
+ A[A]
end
- B --> Join
- D --> Join
-`)
- const crossingLines = output.split("\n").filter((line) => line.includes("Join") || line.includes("├"))
+ A --> D[Down]`
+ const diagram = parseMermaidFlowchartDiagram(content)
+ const layout = layoutParsedFlowchartDiagram(diagram, { compact: true })
+ const grid = drawParsedFlowchartDiagramGrid(diagram, { compact: true })
+ const entry = layout.routes.find((route) => route.edge.from === "U")!
+ const end = entry.points.at(-1)!
- expect(output).toContain(" verse ")
- expect(crossingLines.join("\n").match(/┼/g)).toHaveLength(2)
+ expect(grid.getCell(end.x, end.y)?.char).toBe(diagramArrowHeadBetween(entry.points.at(-2)!, end))
})
test("lays out subgraph-local directions independently from the outer flow", () => {
@@ -1612,6 +1897,65 @@ flowchart TD
}
})
+ test("keeps nested local-direction layouts rigid across direction and compact matrices", () => {
+ const directions = ["LR", "RL", "TD", "BT"] as const
+ for (const global of directions) {
+ for (const outer of directions) {
+ for (const inner of directions) {
+ for (const compact of [false, true]) {
+ const layout = layoutFlowchartDiagram(
+ `flowchart ${global}
+ X[X] --> A
+ subgraph Outer [Outer]
+ direction ${outer}
+ subgraph Inner [Inner]
+ direction ${inner}
+ A[A] --> B[B]
+ end
+ B --> C[C]
+ end
+ C --> Y[Y]`,
+ { compact },
+ )
+ const nodes = [...layout.bounds.values()]
+ const innerFrame = layout.subgraphBounds.get("Inner")!
+ const outerFrame = layout.subgraphBounds.get("Outer")!
+ const a = layout.bounds.get("A")!
+ const b = layout.bounds.get("B")!
+ const c = layout.bounds.get("C")!
+
+ for (let left = 0; left < nodes.length; left++) {
+ for (let right = left + 1; right < nodes.length; right++) {
+ expect(boundsIntersect(nodes[left]!, nodes[right]!)).toBe(false)
+ }
+ }
+ expect(boundsContains(innerFrame, a)).toBe(true)
+ expect(boundsContains(innerFrame, b)).toBe(true)
+ expect(boundsContains(outerFrame, innerFrame)).toBe(true)
+ expect(boundsContains(outerFrame, c)).toBe(true)
+ expect(layout.routes.every((route) => route.points.length >= 2)).toBe(true)
+ expectFlowchartRoutesAvoidUnrelatedNodes(layout)
+ for (const frame of layout.subgraphBounds.values()) {
+ for (const route of layout.routes) {
+ expect(routeRunsAlongHorizontalBorder(route, frame)).toBe(false)
+ expect(routeRunsAlongVerticalBorder(route, frame)).toBe(false)
+ }
+ }
+
+ if (inner === "LR") expect(b.left).toBeGreaterThan(a.left)
+ if (inner === "RL") expect(b.left).toBeLessThan(a.left)
+ if (inner === "TD") expect(b.top).toBeGreaterThan(a.top)
+ if (inner === "BT") expect(b.top).toBeLessThan(a.top)
+ if (outer === "LR") expect(c.centerX).toBeGreaterThan(b.centerX)
+ if (outer === "RL") expect(c.centerX).toBeLessThan(b.centerX)
+ if (outer === "TD") expect(c.centerY).toBeGreaterThan(b.centerY)
+ if (outer === "BT") expect(c.centerY).toBeLessThan(b.centerY)
+ }
+ }
+ }
+ }
+ })
+
test("compacts stacked subgraph-local direction rows", () => {
const layout = layoutFlowchartDiagram(`
flowchart TD
@@ -2037,8 +2381,10 @@ flowchart LR
test("applies the global flowchart StyledText theme", () => {
const grid = drawFlowchartDiagramGrid("flowchart LR\n A[Alpha] --> B[Beta]")
const node = parseColor("#ff0000")
- const styled = renderGridStyledText(grid, resolveFlowchartStyleColors({ node }))
+ const nodeBorder = parseColor("#0000ff")
+ const styled = renderGridStyledText(grid, resolveFlowchartStyleColors({ node, nodeBorder }))
expect(styled.chunks.some((chunk) => chunk.text.includes("Alpha") && chunk.fg?.equals(node))).toBe(true)
+ expect(styled.chunks.some((chunk) => chunk.text.includes("╭") && chunk.fg?.equals(nodeBorder))).toBe(true)
})
})
diff --git a/packages/merman/src/flowchart/labels.ts b/packages/merman/src/flowchart/labels.ts
index 270e475bb41..d07ddbc669c 100644
--- a/packages/merman/src/flowchart/labels.ts
+++ b/packages/merman/src/flowchart/labels.ts
@@ -18,6 +18,7 @@ const LABEL_BUS_CLEARANCE = 3
const LABEL_NODE_CLEARANCE = 2
const LABEL_LINE_CLEARANCE = 2
const LABEL_PADDING = 1
+const LABEL_TERMINAL_CLEARANCE = 1
export interface FlowchartEdgeLabelLayout {
lines: string[]
@@ -63,6 +64,11 @@ function segmentLabelPoint(segment: DiagramSegment, labelWidth: number, labelHei
return clampPoint(shiftPoint(shiftPoint(segment.from, segment.direction, LABEL_LINE_CLEARANCE), "up", labelHeight))
}
+ const slot = insetSpan(segmentSpan(segment), LABEL_TERMINAL_CLEARANCE)
+ if (spanCapacity(slot) >= labelHeight) {
+ return clampPoint(point(segment.from.x + 1, centeredSpanStart(slot, labelHeight)))
+ }
+
const center = shiftPoint(pointOnSegment(segment, midpoint(segmentSpan(segment))), "right")
return clampPoint(shiftPoint(center, "up", Math.floor((labelHeight - 1) / 2)))
}
diff --git a/packages/merman/src/flowchart/layout.ts b/packages/merman/src/flowchart/layout.ts
index b3836dc5fc3..50e69b9c737 100644
--- a/packages/merman/src/flowchart/layout.ts
+++ b/packages/merman/src/flowchart/layout.ts
@@ -14,7 +14,7 @@ import {
flowchartVerticalBranchLabelGap,
} from "./labels.js"
import type { FlowchartDiagramRenderOptions } from "./options.js"
-import { routeFlowchartEdges } from "./routing.js"
+import { avoidFlowchartFrameBorders, routeFlowchartEdges } from "./routing.js"
import type {
FlowchartDiagram,
FlowchartDirection,
@@ -23,6 +23,7 @@ import type {
FlowchartNode,
FlowchartNodeBounds,
FlowchartNodeSize,
+ FlowchartPoint,
FlowchartSubgraphBounds,
} from "./types.js"
@@ -364,7 +365,8 @@ function layoutRankedNodes(
sizes: ReadonlyMap,
minNodeGap: number,
requestedMinRankGap: number,
-): Map {
+ targetWidth?: number,
+): { bounds: Map; wrapped: boolean } {
const horizontal = isHorizontalDirection(direction)
const ranks = rankNodes(diagram)
const maxRank = Math.max(0, ...ranks.values())
@@ -406,6 +408,7 @@ function layoutRankedNodes(
const horizontalGaps = horizontal ? horizontalRankGaps(diagram, normalizedRanks, rankKeys, requestedMinRankGap) : []
const verticalGaps = horizontal ? [] : verticalRankGaps(diagram, normalizedRanks, rankKeys, requestedMinRankGap)
const bounds = new Map()
+ let wrapped = false
if (horizontal) {
const columnWidths = rankKeys.map((rank) =>
@@ -442,68 +445,141 @@ function layoutRankedNodes(
x += columnWidth + (horizontalGaps[rankIndex] ?? 0)
}
} else {
- const rowHeights = rankKeys.map((rank) =>
- Math.max(...ranksByIndex.get(rank)!.map((node) => sizes.get(node.id)!.height)),
- )
- const rowWidths = rankKeys.map((rank) => {
+ const rankBands = rankKeys.map((rank) => {
const nodes = ranksByIndex.get(rank)!
- return (
+ const roomyNodeGap = verticalNodeGap(rank)
+ const naturalWidth =
nodes.reduce((total, node) => total + sizes.get(node.id)!.width, 0) +
- Math.max(0, nodes.length - 1) * verticalNodeGap(rank)
+ Math.max(0, nodes.length - 1) * roomyNodeGap
+ const labeledEdges = diagram.edges.filter(
+ (edge) => edge.label && (normalizedRanks.get(edge.from) === rank || normalizedRanks.get(edge.to) === rank),
)
- })
- const canvasWidth = Math.max(1, ...rowWidths)
- let y = 0
- for (let rankIndex = 0; rankIndex < rankKeys.length; rankIndex++) {
- const rank = rankKeys[rankIndex]!
- const nodes = ranksByIndex.get(rank)!
- const rowHeight = rowHeights[rankIndex]!
- const nodeGap = verticalNodeGap(rank)
- let x = Math.floor((canvasWidth - rowWidths[rankIndex]!) / 2)
+ const needsLabelLanes =
+ labeledEdges.length > 1 &&
+ labeledEdges.some((edge) => {
+ const targets = new Set(
+ labeledEdges.filter((candidate) => candidate.from === edge.from).map((candidate) => candidate.to),
+ )
+ const sources = new Set(
+ labeledEdges.filter((candidate) => candidate.to === edge.to).map((candidate) => candidate.from),
+ )
+ const grouped = (ids: readonly string[]) =>
+ !diagram.subgraphs?.length ||
+ diagram.subgraphs.some((subgraph) => ids.every((id) => subgraph.nodeIds.includes(id)))
+ return (
+ (targets.size > 1 && grouped([edge.from, ...targets])) ||
+ (sources.size > 1 && grouped([edge.to, ...sources]))
+ )
+ })
+ const nodeGap =
+ targetWidth !== undefined && naturalWidth > targetWidth && !needsLabelLanes ? minNodeGap : roomyNodeGap
+ const bands: { nodes: FlowchartNode[]; width: number; height: number }[] = []
for (const node of nodes) {
const size = sizes.get(node.id)!
- const top = y + Math.floor((rowHeight - size.height) / 2)
- bounds.set(node.id, {
- id: node.id,
- ...size,
- left: x,
- top,
- centerX: x + Math.floor(size.width / 2),
- centerY: top + Math.floor(size.height / 2),
- })
- x += size.width + nodeGap
+ const current = bands.at(-1)
+ const width = current ? current.width + nodeGap + size.width : size.width
+ if (current && targetWidth !== undefined && width > targetWidth) {
+ wrapped = true
+ bands.push({ nodes: [node], width: size.width, height: size.height })
+ continue
+ }
+ if (!current) {
+ bands.push({ nodes: [node], width: size.width, height: size.height })
+ continue
+ }
+ current.nodes.push(node)
+ current.width = width
+ current.height = Math.max(current.height, size.height)
}
- y += rowHeight + (verticalGaps[rankIndex] ?? 0)
+ return { bands, nodeGap }
+ })
+ const canvasWidth = Math.max(1, ...rankBands.flatMap((rank) => rank.bands.map((band) => band.width)))
+ let y = 0
+ for (let rankIndex = 0; rankIndex < rankKeys.length; rankIndex++) {
+ const rank = rankBands[rankIndex]!
+ for (const [bandIndex, band] of rank.bands.entries()) {
+ let x = Math.floor((canvasWidth - band.width) / 2)
+ for (const node of band.nodes) {
+ const size = sizes.get(node.id)!
+ const top = y + Math.floor((band.height - size.height) / 2)
+ bounds.set(node.id, {
+ id: node.id,
+ ...size,
+ left: x,
+ top,
+ centerX: x + Math.floor(size.width / 2),
+ centerY: top + Math.floor(size.height / 2),
+ })
+ x += size.width + rank.nodeGap
+ }
+ y += band.height + (bandIndex < rank.bands.length - 1 ? minNodeGap : 0)
+ }
+ y += verticalGaps[rankIndex] ?? 0
}
}
- return bounds
+ return { bounds, wrapped }
}
function layoutLocalSubgraphDirections(
diagram: FlowchartDiagram,
nodeBounds: Map,
- sizes: ReadonlyMap,
minNodeGap: number,
requestedMinRankGap: number,
-): void {
+ targetWidth?: number,
+): boolean {
+ let wrapped = false
for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) {
if (!subgraph.direction || subgraph.direction === diagram.direction) continue
- const nodeIds = new Set(subgraph.nodeIds)
- const nodes = diagram.nodes.filter((node) => nodeIds.has(node.id))
- if (nodes.length === 0) continue
+ const childSubgraphs = (diagram.subgraphs ?? []).filter((child) => child.parentId === subgraph.id)
+ const coveredNodeIds = new Set(childSubgraphs.flatMap((child) => [...collectSubgraphNodeIds(diagram, child.id)]))
+ const items = [
+ ...childSubgraphs.flatMap((child) => {
+ const nodeIds = [...collectSubgraphNodeIds(diagram, child.id)]
+ const content = boundsFromChildren(nodeIds.flatMap((id) => nodeBounds.get(id) ?? []))
+ const bounds = content ? subgraphBoundFromChildren(child.id, child.label, [content]) : undefined
+ return bounds ? [{ id: `subgraph:${child.id}`, nodeIds, bounds, childId: child.id }] : []
+ }),
+ ...subgraph.nodeIds.flatMap((id) => {
+ if (coveredNodeIds.has(id)) return []
+ const bounds = nodeBounds.get(id)
+ return bounds ? [{ id, nodeIds: [id], bounds, childId: undefined }] : []
+ }),
+ ]
+ if (items.length === 0) continue
- const currentBounds = boundsFromChildren(nodes.flatMap((node) => nodeBounds.get(node.id) ?? []))
+ const currentBounds = boundsFromChildren(items.map((item) => item.bounds))
if (!currentBounds) continue
+ const itemByEndpoint = new Map()
+ for (const item of items) {
+ for (const nodeId of item.nodeIds) itemByEndpoint.set(nodeId, item.id)
+ if (item.childId) itemByEndpoint.set(item.childId, item.id)
+ }
+ const nodes = items.map((item): FlowchartNode => ({ id: item.id, label: item.id, shape: "box" }))
const localDiagram: FlowchartDiagram = {
direction: subgraph.direction,
nodes,
- edges: diagram.edges.filter((edge) => nodeIds.has(edge.from) && nodeIds.has(edge.to)),
+ edges: diagram.edges.flatMap((edge) => {
+ const from = itemByEndpoint.get(edge.from)
+ const to = itemByEndpoint.get(edge.to)
+ return from && to && from !== to ? [{ ...edge, from, to }] : []
+ }),
subgraphs: [],
}
- const localNodeGap = isHorizontalDirection(subgraph.direction) ? Math.max(4, minNodeGap - 1) : minNodeGap
- const localBounds = layoutRankedNodes(localDiagram, subgraph.direction, sizes, localNodeGap, requestedMinRankGap)
+ const itemSizes = new Map(
+ items.map((item) => [item.id, { width: item.bounds.width, height: item.bounds.height, lines: [item.id] }]),
+ )
+ const localLayout = layoutRankedNodes(
+ localDiagram,
+ subgraph.direction,
+ itemSizes,
+ Math.max(minNodeGap, SUBGRAPH_PADDING_X * 2 + 1),
+ requestedMinRankGap,
+ targetWidth,
+ )
+ const localBounds = localLayout.bounds
+ wrapped ||= localLayout.wrapped
const localExtent = boundsFromChildren([...localBounds.values()])
if (!localExtent) continue
@@ -512,11 +588,66 @@ function layoutLocalSubgraphDirections(
const dx = targetLeft - localExtent.left
const dy = targetTop - localExtent.top
- for (const [nodeId, bound] of localBounds) {
- translateBounds(bound, dx, dy)
- nodeBounds.set(nodeId, bound)
+ const translations = new Map()
+ for (const item of items) {
+ const bound = localBounds.get(item.id)!
+ const itemDx = bound.left + dx - item.bounds.left
+ const itemDy = bound.top + dy - item.bounds.top
+ for (const nodeId of item.nodeIds) translations.set(nodeId, { dx: itemDx, dy: itemDy })
+ }
+
+ let groupOffset = { x: 0, y: 0 }
+ if (targetWidth !== undefined) {
+ const localNodeIds = new Set(translations.keys())
+ const external = [...nodeBounds.entries()].filter(([id]) => !localNodeIds.has(id)).map(([, bound]) => bound)
+ const overlaps = (offset: FlowchartPoint) =>
+ [...translations].some(([id, translation]) => {
+ const bound = nodeBounds.get(id)!
+ const left = bound.left + translation.dx + offset.x
+ const top = bound.top + translation.dy + offset.y
+ return external.some(
+ (other) =>
+ left < other.left + other.width + minNodeGap &&
+ left + bound.width + minNodeGap > other.left &&
+ top < other.top + other.height + minNodeGap &&
+ top + bound.height + minNodeGap > other.top,
+ )
+ })
+ if (overlaps(groupOffset)) {
+ const vertical = !isHorizontalDirection(diagram.direction)
+ const sign = diagram.direction === "RL" || diagram.direction === "BT" ? -1 : 1
+ let found = false
+ search: for (let distance = 1; distance < 1_000; distance++) {
+ const candidates = vertical
+ ? [
+ { x: 0, y: sign * distance },
+ { x: 0, y: -sign * distance },
+ { x: distance, y: 0 },
+ { x: -distance, y: 0 },
+ ]
+ : [
+ { x: sign * distance, y: 0 },
+ { x: -sign * distance, y: 0 },
+ { x: 0, y: distance },
+ { x: 0, y: -distance },
+ ]
+ for (const candidate of candidates) {
+ if (overlaps(candidate)) continue
+ groupOffset = candidate
+ found = true
+ break search
+ }
+ }
+ if (!found) throw new Error(`Subgraph ${subgraph.id} has no collision-free responsive position`)
+ }
+ }
+
+ for (const [nodeId, translation] of translations) {
+ const nodeBound = nodeBounds.get(nodeId)
+ if (nodeBound) translateBounds(nodeBound, translation.dx + groupOffset.x, translation.dy + groupOffset.y)
}
}
+ return wrapped
}
function edgeDirection(diagram: FlowchartDiagram, edge: FlowchartEdge): FlowchartDirection {
@@ -601,6 +732,7 @@ function separateTopLevelItems(
nodeBounds: Map,
subgraphBounds: ReadonlyMap,
gap: number,
+ targetWidth?: number,
): boolean {
const hasLocalDirection = (diagram.subgraphs ?? []).some(
(subgraph) => subgraph.direction && subgraph.direction !== diagram.direction,
@@ -734,6 +866,35 @@ function separateTopLevelItems(
crossCursor = start + shift + size + gap
}
}
+ if (targetWidth !== undefined && !horizontal) {
+ const intersects = (left: (typeof items)[number], right: (typeof items)[number]): boolean =>
+ [...left.nodeIds].some((leftId) => {
+ const leftBounds = nodeBounds.get(leftId)!
+ return [...right.nodeIds].some((rightId) => {
+ const rightBounds = nodeBounds.get(rightId)!
+ return (
+ leftBounds.left <= rightBounds.left + rightBounds.width - 1 &&
+ leftBounds.left + leftBounds.width - 1 >= rightBounds.left &&
+ leftBounds.top <= rightBounds.top + rightBounds.height - 1 &&
+ leftBounds.top + leftBounds.height - 1 >= rightBounds.top
+ )
+ })
+ })
+ for (let rightIndex = 1; rightIndex < items.length; rightIndex++) {
+ const right = items[rightIndex]!
+ for (let leftIndex = 0; leftIndex < rightIndex; leftIndex++) {
+ const left = items[leftIndex]!
+ if (!intersects(left, right)) continue
+ const leftBounds = boundsFromChildren([...left.nodeIds].map((id) => nodeBounds.get(id)!))!
+ const rightBounds = boundsFromChildren([...right.nodeIds].map((id) => nodeBounds.get(id)!))!
+ const shift = reversed
+ ? leftBounds.top - gap - (rightBounds.top + rightBounds.height)
+ : leftBounds.top + leftBounds.height + gap - rightBounds.top
+ moved ||= shift !== 0
+ moveItem(right, 0, shift)
+ }
+ }
+ }
return moved
}
@@ -771,6 +932,7 @@ function layoutFlowchartWithDirection(
sourceDiagram: FlowchartDiagram,
options: FlowchartDiagramRenderOptions,
direction: FlowchartDirection,
+ responsiveFallback = false,
): FlowchartLayout {
const diagram = direction === sourceDiagram.direction ? sourceDiagram : { ...sourceDiagram, direction }
const horizontal = isHorizontalDirection(direction)
@@ -786,29 +948,65 @@ function layoutFlowchartWithDirection(
: DEFAULT_MIN_VERTICAL_RANK_GAP,
)
const sizes = new Map(diagram.nodes.map((node) => [node.id, nodeSize(node)]))
- const bounds = layoutRankedNodes(diagram, direction, sizes, minNodeGap, requestedMinRankGap)
- layoutLocalSubgraphDirections(diagram, bounds, sizes, minNodeGap, requestedMinRankGap)
+ const targetWidth =
+ !horizontal && options.layoutMaxWidth !== undefined && Number.isFinite(options.layoutMaxWidth)
+ ? Math.max(1, Math.trunc(options.layoutMaxWidth))
+ : undefined
+ const ranked = layoutRankedNodes(diagram, direction, sizes, minNodeGap, requestedMinRankGap, targetWidth)
+ const bounds = ranked.bounds
+ const responsive = layoutLocalSubgraphDirections(diagram, bounds, minNodeGap, requestedMinRankGap, targetWidth)
+ const directionAligned = responsiveFallback || responsive || ranked.wrapped
const subgraphs = diagram.subgraphs ?? []
let subgraphBounds = new Map()
let routes: FlowchartEdgeRoute[]
if (subgraphs.length === 0) {
- routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
+ routes = routeFlowchartEdges(
+ diagram,
+ bounds,
+ (edge) => edgeDirection(diagram, edge),
+ undefined,
+ targetWidth,
+ directionAligned,
+ )
} else {
- routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
+ routes = routeFlowchartEdges(
+ diagram,
+ bounds,
+ (edge) => edgeDirection(diagram, edge),
+ undefined,
+ targetWidth,
+ directionAligned,
+ )
subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
const moved = separateTopLevelItems(
diagram,
bounds,
subgraphBounds,
Math.max(1, Math.floor(requestedMinRankGap / 2)),
+ targetWidth,
)
if (moved) {
- routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
+ routes = routeFlowchartEdges(
+ diagram,
+ bounds,
+ (edge) => edgeDirection(diagram, edge),
+ undefined,
+ targetWidth,
+ directionAligned,
+ )
subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
}
- routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge), subgraphBounds)
+ routes = routeFlowchartEdges(
+ diagram,
+ bounds,
+ (edge) => edgeDirection(diagram, edge),
+ subgraphBounds,
+ targetWidth,
+ directionAligned,
+ )
subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
+ avoidFlowchartFrameBorders(routes, bounds, subgraphBounds)
}
freezeRouteLabelPoints(routes)
const allBounds = [...bounds.values(), ...subgraphBounds.values(), ...routeRenderBounds(routes)]
@@ -834,5 +1032,5 @@ export function layoutFlowchartDiagram(
if (!isHorizontalDirection(direction) || maxWidth === undefined || !Number.isFinite(maxWidth)) return layout
if (layout.width <= Math.max(1, Math.trunc(maxWidth))) return layout
- return layoutFlowchartWithDirection(sourceDiagram, options, direction === "RL" ? "BT" : "TD")
+ return layoutFlowchartWithDirection(sourceDiagram, options, direction === "RL" ? "BT" : "TD", true)
}
diff --git a/packages/merman/src/flowchart/options.ts b/packages/merman/src/flowchart/options.ts
index 23a0964ee96..b1ec05e6d0a 100644
--- a/packages/merman/src/flowchart/options.ts
+++ b/packages/merman/src/flowchart/options.ts
@@ -7,6 +7,6 @@ export interface FlowchartDiagramRenderOptions {
borderStyle?: BorderStyle
minNodeGap?: number
minRankGap?: number
- /** Fold oversized horizontal layouts vertically when their rendered width exceeds this limit. */
+ /** Target rendered width. Oversized horizontal layouts fold vertically and broad vertical ranks wrap. */
layoutMaxWidth?: number
}
diff --git a/packages/merman/src/flowchart/routing.ts b/packages/merman/src/flowchart/routing.ts
index 3c053c088d4..0ef1d320d3a 100644
--- a/packages/merman/src/flowchart/routing.ts
+++ b/packages/merman/src/flowchart/routing.ts
@@ -11,9 +11,11 @@ import {
lane,
oppositeSide,
orthogonalPath,
+ orthogonalPathPoints,
pathThrough,
pathViaLane,
segmentBetween,
+ segmentSpan,
sideForDirection,
snapCoordinate,
shiftPoint,
@@ -138,11 +140,11 @@ function horizontalEdgePath(
})
}
-function selfEdgePath(bounds: FlowchartNodeBounds): FlowchartPoint[] {
+function selfEdgePath(bounds: FlowchartNodeBounds, laneOffset = 0): FlowchartPoint[] {
const start = boundsSidePoint(bounds, "right")
const end = boundsSidePoint(bounds, "bottom")
- const rightLaneX = bounds.left + bounds.width + BUS_CLEARANCE
- const bottomLaneY = bounds.top + bounds.height + 1
+ const rightLaneX = bounds.left + bounds.width + BUS_CLEARANCE + laneOffset
+ const bottomLaneY = bounds.top + bounds.height + 1 + laneOffset
return [start, { x: rightLaneX, y: start.y }, { x: rightLaneX, y: bottomLaneY }, { x: end.x, y: bottomLaneY }, end]
}
@@ -522,6 +524,8 @@ function routeVerticalFanIn(
function routeParallelEdges(
diagram: FlowchartDiagram,
bounds: Map,
+ directionForEdge: (edge: FlowchartEdge) => FlowchartDirection,
+ directionAligned: boolean,
handled: Set,
routes: FlowchartEdgeRoute[],
): void {
@@ -530,8 +534,18 @@ function routeParallelEdges(
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 parallelAxis = parallelLaneAxis(from, to)
+ if (!from || !to) continue
+ if (from.id === to.id) {
+ let laneOffset = 0
+ for (const edge of edges) {
+ routes.push({ edge, points: selfEdgePath(from, laneOffset) })
+ handled.add(edge)
+ laneOffset++
+ }
+ continue
+ }
+ const parallelAxis =
+ directionAligned && isVerticalDirection(directionForEdge(edges[0]!)) ? "x" : parallelLaneAxis(from, to)
let previousRoute: FlowchartEdgeRoute | undefined
for (const edge of edges) {
const height = labelHeight(edge)
@@ -539,7 +553,7 @@ function routeParallelEdges(
parallelAxis === "x"
? previousRoute
? rightRenderExtent(previousRoute) + NODE_CLEARANCE
- : Math.max(boundsSidePoint(from, "right").x, boundsSidePoint(to, "right").x)
+ : Math.max(boundsSidePoint(from, "right").x, boundsSidePoint(to, "right").x) + (directionAligned ? 1 : 0)
: 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)
@@ -795,6 +809,69 @@ function routeIntersectsLabels(route: FlowchartEdgeRoute, labels: readonly Flowc
)
}
+function pathsIntersect(left: readonly FlowchartPoint[], right: readonly FlowchartPoint[]): boolean {
+ const occupied = new Set(orthogonalPathPoints(left).map((point) => `${point.x}:${point.y}`))
+ return orthogonalPathPoints(right).some((point) => occupied.has(`${point.x}:${point.y}`))
+}
+
+function endpointDisjoint(left: FlowchartEdge, right: FlowchartEdge): boolean {
+ return left.from !== right.from && left.from !== right.to && left.to !== right.from && left.to !== right.to
+}
+
+function endpointConflictsWithRoutes(route: FlowchartEdgeRoute, otherRoutes: readonly FlowchartEdgeRoute[]): boolean {
+ const source = route.points[0]
+ const target = route.points.at(-1)
+ if (!source || !target) return false
+ return otherRoutes.some((other) => {
+ const otherSource = other.points[0]
+ const otherTarget = other.points.at(-1)
+ return (
+ (otherSource && target.x === otherSource.x && target.y === otherSource.y) ||
+ (otherTarget && source.x === otherTarget.x && source.y === otherTarget.y)
+ )
+ })
+}
+
+function pathRunsAlongFrame(points: readonly FlowchartPoint[], bounds: FlowchartSubgraphBounds): boolean {
+ const right = bounds.left + bounds.width - 1
+ const bottom = bounds.top + bounds.height - 1
+ for (let index = 1; index < points.length; index++) {
+ const segment = segmentBetween(points[index - 1]!, points[index]!)
+ if (!segment) continue
+ const span = segmentSpan(segment)
+ if (
+ segment.axis === "x" &&
+ (segment.from.y === bounds.top || segment.from.y === bottom) &&
+ Math.min(span.end, right) > Math.max(span.start, bounds.left)
+ ) {
+ return true
+ }
+ if (
+ segment.axis === "y" &&
+ (segment.from.x === bounds.left || segment.from.x === right) &&
+ Math.min(span.end, bottom) > Math.max(span.start, bounds.top)
+ ) {
+ return true
+ }
+ }
+ return false
+}
+
+function subgraphTitleBounds(bounds: FlowchartSubgraphBounds): {
+ left: number
+ top: number
+ width: number
+ height: number
+} {
+ const lines = splitDiagramLines(bounds.label)
+ return {
+ left: bounds.left + 2,
+ top: bounds.labelSide === "top" ? bounds.top : bounds.top + bounds.height - lines.length,
+ width: Math.max(...lines.map((line) => diagramTextWidth(` ${line} `))),
+ height: lines.length,
+ }
+}
+
function avoidNodeObstacles(
route: FlowchartEdgeRoute,
routes: readonly FlowchartEdgeRoute[],
@@ -815,10 +892,23 @@ function avoidNodeObstacles(
const allowedContact = isSource && isTarget ? "both" : isSource ? "source" : isTarget ? "target" : undefined
return pathIntersectsBounds(candidate.points, bound, allowedContact)
})
+ const intersectsStructuralObstacle = (candidate: FlowchartEdgeRoute): boolean =>
+ intersectsNode(candidate) ||
+ allSubgraphBounds.some(
+ (bound) =>
+ pathRunsAlongFrame(candidate.points, bound) ||
+ (bound.label.length > 0 && pathIntersectsBounds(candidate.points, subgraphTitleBounds(bound))),
+ )
+ const intersectsRoutingObstacle = (candidate: FlowchartEdgeRoute): boolean =>
+ intersectsStructuralObstacle(candidate) ||
+ endpointConflictsWithRoutes(candidate, otherRoutes) ||
+ otherRoutes.some(
+ (other) => endpointDisjoint(candidate.edge, other.edge) && pathsIntersect(candidate.points, other.points),
+ )
const intersectsObstacle = (candidate: FlowchartEdgeRoute): boolean => {
const label = candidate.edge.label ? flowchartRouteLabelLayout(candidate, diagramTextWidth) : undefined
return (
- intersectsNode(candidate) ||
+ intersectsRoutingObstacle(candidate) ||
allNodeBounds.some((bound) => labelIntersectsBounds(label, bound)) ||
allSubgraphBounds.some((bound) => labelIntersectsSubgraphFrame(label, bound)) ||
labelIntersectsLabels(label, otherLabels) ||
@@ -839,19 +929,19 @@ function avoidNodeObstacles(
const rightBusXs = [
...new Set([
rightBusX,
- ...otherLabels.map((label) => Math.max(rightBusX, label.point.x + label.width - 1 + BUS_CLEARANCE)),
+ ...otherLabels.map((label) => Math.max(rightBusX, label.point.x + label.width - 1 + NODE_CLEARANCE)),
]),
].sort((left, right) => left - right)
const leftBusXs = [
- ...new Set([leftBusX, ...otherLabels.map((label) => Math.min(leftBusX, label.point.x - BUS_CLEARANCE))]),
+ ...new Set([leftBusX, ...otherLabels.map((label) => Math.min(leftBusX, label.point.x - NODE_CLEARANCE))]),
].sort((left, right) => right - left)
const topBusYs = [
- ...new Set([topBusY, ...otherLabels.map((label) => Math.min(topBusY, label.point.y - BUS_CLEARANCE))]),
+ ...new Set([topBusY, ...otherLabels.map((label) => Math.min(topBusY, label.point.y - NODE_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)),
+ ...otherLabels.map((label) => Math.max(bottomBusY, label.point.y + label.height - 1 + NODE_CLEARANCE)),
]),
].sort((left, right) => left - right)
const busLimit = Math.max(1, Math.floor(Math.sqrt(ROUTING_CANDIDATE_BUDGET / 4)))
@@ -953,7 +1043,7 @@ function avoidNodeObstacles(
if (from.id === to.id)
return (
shortest(selfLoops, (candidate) => !intersectsObstacle(candidate)) ??
- shortest(selfLoops, (candidate) => !intersectsNode(candidate)) ??
+ shortest(selfLoops, (candidate) => !intersectsStructuralObstacle(candidate)) ??
route
)
const currentTargetSide = sideForOutsidePoint(to, route.points.at(-1)!)
@@ -1002,8 +1092,8 @@ function avoidNodeObstacles(
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)) ??
+ shortest(preservedSources, (candidate) => !intersectsStructuralObstacle(candidate)) ??
+ shortest(attachments, (candidate) => !intersectsStructuralObstacle(candidate)) ??
route
)
}
@@ -1012,8 +1102,8 @@ function avoidNodeObstacles(
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)) ??
+ shortest(attachments, (candidate) => !intersectsStructuralObstacle(candidate)) ??
+ shortest(preservedSources, (candidate) => !intersectsStructuralObstacle(candidate)) ??
route
)
}
@@ -1023,6 +1113,8 @@ function avoidLabelOverlap(
otherRoutes: readonly FlowchartEdgeRoute[],
bounds: ReadonlyMap,
subgraphBounds: ReadonlyMap | undefined,
+ targetWidth?: number,
+ includeLabelWidth = true,
): FlowchartEdgeRoute {
if (!route.edge.label) return route
const nodeBounds = [...bounds.values()]
@@ -1040,7 +1132,14 @@ function avoidLabelOverlap(
{ left: sourcePoint.x, top: sourcePoint.y, width: 1, height: 1 },
]
})
+ const hasParallelRoute = otherRoutes.some(
+ (other) => other.edge.from === route.edge.from && other.edge.to === route.edge.to,
+ )
const intersectsObstacle = (label: FlowchartEdgeLabelLayout): boolean =>
+ (targetWidth !== undefined &&
+ (hasParallelRoute || !includeLabelWidth
+ ? label.point.x > targetWidth
+ : label.point.x + label.width > targetWidth)) ||
nodeBounds.some((bound) => labelIntersectsBounds(label, bound)) ||
frameBounds.some((bound) => labelIntersectsSubgraphFrame(label, bound)) ||
labelIntersectsLabels(label, otherLabels) ||
@@ -1094,6 +1193,15 @@ function avoidLabelOverlap(
}
}
}
+ if (targetWidth !== undefined && includeLabelWidth && current.point.x + current.width > targetWidth) {
+ const x = Math.max(0, targetWidth - current.width)
+ for (let distance = 0; distance < 100; distance++) {
+ for (const y of distance === 0 ? [current.point.y] : [current.point.y - distance, current.point.y + distance]) {
+ if (y < 0 || intersectsObstacle({ ...current, point: { x, y } })) continue
+ return { ...route, labelPoint: { x, y } }
+ }
+ }
+ }
return route
}
@@ -1102,6 +1210,8 @@ export function routeFlowchartEdges(
bounds: Map,
directionForEdge: (edge: FlowchartEdge) => FlowchartDirection = () => diagram.direction,
subgraphBounds?: ReadonlyMap,
+ targetWidth?: number,
+ directionAligned = false,
): FlowchartEdgeRoute[] {
const routedDiagram = { ...diagram, edges: diagram.edges.filter((edge) => !edge.orderOnly) }
const handled = new Set()
@@ -1110,7 +1220,7 @@ export function routeFlowchartEdges(
? Math.min(...[...bounds.values(), ...subgraphBounds.values()].map((bound) => bound.left))
: undefined
- routeParallelEdges(routedDiagram, bounds, handled, routes)
+ routeParallelEdges(routedDiagram, bounds, directionForEdge, directionAligned, handled, routes)
for (const direction of ["LR", "RL"] satisfies FlowchartDirection[]) {
const horizontalEdges = routedDiagram.edges.filter(
@@ -1145,11 +1255,109 @@ export function routeFlowchartEdges(
for (let index = routes.length - 1; index >= 0; index--) {
routes[index] = avoidNodeObstacles(routes[index]!, routes, bounds, subgraphBounds, index)
}
+ const subgraphs = diagram.subgraphs ?? []
+ const subgraphById = new Map(subgraphs.map((subgraph) => [subgraph.id, subgraph]))
+ const containers = (id: string) => {
+ const ids = new Set()
+ let current = subgraphs.find((subgraph) => subgraph.nodeIds.includes(id))
+ while (current) {
+ ids.add(current.id)
+ current = current.parentId ? subgraphById.get(current.parentId) : undefined
+ }
+ return ids
+ }
+ const groupedLabelEdge = (edge: FlowchartEdge) => {
+ const fromContainers = containers(edge.from)
+ if (![...containers(edge.to)].some((id) => fromContainers.has(id))) return false
+ const targets = new Set(
+ routedDiagram.edges
+ .filter((candidate) => candidate.label && candidate.from === edge.from)
+ .map((candidate) => candidate.to),
+ )
+ const sources = new Set(
+ routedDiagram.edges
+ .filter((candidate) => candidate.label && candidate.to === edge.to)
+ .map((candidate) => candidate.from),
+ )
+ return targets.size > 1 || sources.size > 1
+ }
return routes.reduce((resolved, route, index) => {
- return [...resolved, avoidLabelOverlap(route, [...resolved, ...routes.slice(index + 1)], bounds, subgraphBounds)]
+ const grouped = groupedLabelEdge(route.edge)
+ return [
+ ...resolved,
+ avoidLabelOverlap(
+ route,
+ [...resolved, ...routes.slice(index + 1)],
+ bounds,
+ subgraphBounds,
+ targetWidth !== undefined && subgraphs.length > 0 && grouped ? Math.max(1, targetWidth - 5) : targetWidth,
+ subgraphs.length === 0 || grouped,
+ ),
+ ]
}, [])
}
+export function avoidFlowchartFrameBorders(
+ routes: readonly FlowchartEdgeRoute[],
+ bounds: ReadonlyMap,
+ subgraphBounds: ReadonlyMap,
+): void {
+ const inside = (node: FlowchartNodeBounds, frame: FlowchartSubgraphBounds) =>
+ node.left >= frame.left &&
+ node.top >= frame.top &&
+ node.left + node.width <= frame.left + frame.width &&
+ node.top + node.height <= frame.top + frame.height
+
+ for (const route of routes) {
+ const source = bounds.get(route.edge.from)
+ const target = bounds.get(route.edge.to)
+ for (const frame of subgraphBounds.values()) {
+ const inward = Boolean(source && target && inside(source, frame) && inside(target, frame))
+ const right = frame.left + frame.width - 1
+ const bottom = frame.top + frame.height - 1
+ const points: FlowchartPoint[] = [route.points[0]!]
+ for (let index = 1; index < route.points.length; index++) {
+ const from = route.points[index - 1]!
+ const to = route.points[index]!
+ const segment = segmentBetween(from, to)
+ if (!segment) continue
+ const span = segmentSpan(segment)
+ const horizontalSide =
+ segment.axis === "x" && Math.min(span.end, right) > Math.max(span.start, frame.left)
+ ? segment.from.y === frame.top
+ ? "top"
+ : segment.from.y === bottom
+ ? "bottom"
+ : undefined
+ : undefined
+ const verticalSide =
+ segment.axis === "y" && Math.min(span.end, bottom) > Math.max(span.start, frame.top)
+ ? segment.from.x === frame.left
+ ? "left"
+ : segment.from.x === right
+ ? "right"
+ : undefined
+ : undefined
+ if (!horizontalSide && !verticalSide) {
+ points.push(to)
+ continue
+ }
+
+ const offset = horizontalSide
+ ? horizontalSide === "top"
+ ? frame.top + (inward ? 1 : -1)
+ : bottom + (inward ? -1 : 1)
+ : verticalSide === "left"
+ ? frame.left + (inward ? 1 : -1)
+ : right + (inward ? -1 : 1)
+ if (horizontalSide) points.push({ x: from.x, y: offset }, { x: to.x, y: offset }, to)
+ else points.push({ x: offset, y: from.y }, { x: offset, y: to.y }, to)
+ }
+ route.points = pathThrough(points)
+ }
+ }
+}
+
function sideForOutsidePoint(bounds: FlowchartNodeBounds, sourcePoint: FlowchartPoint): DiagramSide {
if (sourcePoint.x < bounds.left) return "left"
if (sourcePoint.x >= bounds.left + bounds.width) return "right"
diff --git a/packages/merman/src/flowchart/style.ts b/packages/merman/src/flowchart/style.ts
index 40a90e8bd32..f4dae9bf270 100644
--- a/packages/merman/src/flowchart/style.ts
+++ b/packages/merman/src/flowchart/style.ts
@@ -10,7 +10,7 @@ import {
type DiagramRgb,
} from "../core/color/style.js"
-export type FlowchartBaseCellStyle = "node" | "database" | "edge" | "label" | "group"
+export type FlowchartBaseCellStyle = "node" | "nodeBorder" | "database" | "databaseBorder" | "edge" | "label" | "group"
export type FlowchartNodeEdgeFadeStyle = `nodeEdgeFade${DiagramFadeStep}`
export type FlowchartDatabaseEdgeFadeStyle = `databaseEdgeFade${DiagramFadeStep}`
export type FlowchartEdgeFadeStyle = FlowchartNodeEdgeFadeStyle | FlowchartDatabaseEdgeFadeStyle
@@ -22,7 +22,9 @@ export type FlowchartGrid = DiagramCanvas>
export const DEFAULT_THEME_RGB = {
node: [228, 239, 232],
+ nodeBorder: [141, 163, 151],
database: [228, 239, 232],
+ databaseBorder: [141, 163, 151],
edge: [134, 225, 200],
label: [134, 225, 200],
group: [76, 99, 89],
@@ -35,16 +37,20 @@ export function resolveFlowchartStyleColors(
colors: Partial> = {},
): FlowchartStyleColors {
const node = colors.node ?? rgba(DEFAULT_THEME_RGB.node)
+ const nodeBorder = colors.nodeBorder ?? rgba(DEFAULT_THEME_RGB.nodeBorder)
const database = colors.database ?? rgba(DEFAULT_THEME_RGB.database)
+ const databaseBorder = colors.databaseBorder ?? rgba(DEFAULT_THEME_RGB.databaseBorder)
const edge = colors.edge ?? rgba(DEFAULT_THEME_RGB.edge)
return {
node,
+ nodeBorder,
database,
+ databaseBorder,
edge,
label: colors.label ?? rgba(DEFAULT_THEME_RGB.label),
group: colors.group ?? rgba(DEFAULT_THEME_RGB.group),
- ...createColorRampTheme(NODE_EDGE_FADE_STYLES, node, edge),
- ...createColorRampTheme(DATABASE_EDGE_FADE_STYLES, database, edge),
+ ...createColorRampTheme(NODE_EDGE_FADE_STYLES, nodeBorder, edge),
+ ...createColorRampTheme(DATABASE_EDGE_FADE_STYLES, databaseBorder, edge),
}
}
diff --git a/packages/merman/src/layout-audit.test.ts b/packages/merman/src/layout-audit.test.ts
new file mode 100644
index 00000000000..d56187a457d
--- /dev/null
+++ b/packages/merman/src/layout-audit.test.ts
@@ -0,0 +1,81 @@
+import { expect, test } from "bun:test"
+import { auditAllFixtures, auditFixture, summarizeAudits, worstAudits } from "./test/layout-audit/harness.js"
+import { layoutFixtures } from "./test/layout-audit/fixtures.js"
+
+test("audits deterministic flowchart and state layout families", () => {
+ const fixtures = layoutFixtures()
+ const flowcharts = fixtures.filter((fixture) => fixture.kind === "flowchart")
+ const states = fixtures.filter((fixture) => fixture.kind === "state")
+ expect(flowcharts.length).toBeGreaterThanOrEqual(100)
+ expect(states.length).toBeGreaterThanOrEqual(100)
+ expect(new Set(fixtures.map((fixture) => fixture.id)).size).toBe(fixtures.length)
+
+ const startedAt = performance.now()
+ const audits = auditAllFixtures()
+ const elapsedMs = performance.now() - startedAt
+ const violations = audits.flatMap((audit) =>
+ audit.violations.map((violation) => `${audit.fixture.id} @${audit.viewport}: ${violation}`),
+ )
+ const summary = summarizeAudits(audits)
+
+ expect(audits.length).toBeGreaterThanOrEqual(fixtures.length)
+ expect(violations).toEqual([])
+ expect(elapsedMs).toBeLessThan(35_000)
+ for (const audit of audits.filter((audit) => audit.fixture.kind === "state")) {
+ expect(audit.viewport).toBe(audit.fixture.profile === "short" ? 60 : audit.fixture.profile === "unicode" ? 80 : 120)
+ }
+ for (const id of ["state/chain/lr-long", "state/chain/rl-long"]) {
+ const audit = audits.find((candidate) => candidate.fixture.id === id)!
+ expect(audit.viewport).toBe(120)
+ expect([audit.metrics.width, audit.metrics.height, audit.metrics.overflow]).toEqual([84, 41, 0])
+ }
+ expect(
+ audits
+ .filter((audit) => audit.fixture.id === "flowchart/deployment-architecture/curated")
+ .map((audit) => audit.viewport),
+ ).toEqual([60, 80, 120])
+ expect(summary.total.area.max).toBeLessThanOrEqual(11_011)
+ expect(summary.total.area.p95).toBeLessThanOrEqual(5_313)
+ expect(summary.total.bends.max).toBeLessThanOrEqual(30)
+ expect(summary.total.bends.p95).toBeLessThanOrEqual(11)
+ expect(summary.total.crossings.total).toBeLessThanOrEqual(40)
+ expect(summary.total.crossings.max).toBeLessThanOrEqual(3)
+ expect(summary.total.routeLength.max).toBeLessThanOrEqual(930)
+ expect(summary.total.routeLength.p95).toBeLessThanOrEqual(364)
+ expect(summary.total.sharedRouteCells.max).toBeLessThanOrEqual(547)
+ expect(summary.total.sharedRouteCells.p95).toBeLessThanOrEqual(122)
+ expect(summary.total.overflow.max).toBeLessThanOrEqual(170)
+ expect(summary.total.overflow.p95).toBeLessThanOrEqual(99)
+ expect(summary.state.crossings.total).toBe(0)
+
+ for (const fixture of [...Map.groupBy(fixtures, (candidate) => `${candidate.kind}/${candidate.family}`).values()].map(
+ (family) => family[0]!,
+ )) {
+ const first = auditFixture(fixture, 80)
+ const second = auditFixture(fixture, 80)
+ expect(second.output).toBe(first.output)
+ expect(second.metrics).toEqual(first.metrics)
+ expect(second.violations).toEqual(first.violations)
+ }
+
+ console.log(
+ `[layout-audit] ${fixtures.length} sources, ${audits.length} runs, ${elapsedMs.toFixed(0)}ms`,
+ JSON.stringify({
+ summary,
+ worst: {
+ area: worstAudits(audits, "area", 3).map((audit) => [audit.fixture.id, audit.viewport, audit.metrics.area]),
+ bends: worstAudits(audits, "bends", 3).map((audit) => [audit.fixture.id, audit.viewport, audit.metrics.bends]),
+ crossings: worstAudits(audits, "crossings", 3).map((audit) => [
+ audit.fixture.id,
+ audit.viewport,
+ audit.metrics.crossings,
+ ]),
+ overflow: worstAudits(audits, "overflow", 3).map((audit) => [
+ audit.fixture.id,
+ audit.viewport,
+ audit.metrics.overflow,
+ ]),
+ },
+ }),
+ )
+}, 40_000)
diff --git a/packages/merman/src/markdown.ts b/packages/merman/src/markdown.ts
index c94db60a6a4..9b7cc390278 100644
--- a/packages/merman/src/markdown.ts
+++ b/packages/merman/src/markdown.ts
@@ -51,7 +51,7 @@ interface PreparedDiagram {
export interface MermaidMarkdownRendererOptions {
/** Use terminal-optimized diagram spacing. Defaults to true. */
compact?: boolean
- /** Fold horizontal flowcharts that exceed this width. Defaults to 120 columns. */
+ /** Fold responsive horizontal diagrams that exceed this width. Defaults to 120 columns. */
layoutMaxWidth?: number
/** Gantt-specific terminal rendering options. */
gantt?: Omit
@@ -141,7 +141,9 @@ function prepareDiagram(
grid,
resolveFlowchartStyleColors({
node: color(colors.primary),
+ nodeBorder: color(colors.muted),
database: color(colors.primary),
+ databaseBorder: color(colors.muted),
edge: color(colors.secondary),
label: color(colors.text),
group: color(colors.muted),
@@ -215,8 +217,8 @@ function prepareDiagram(
}
}
case "state": {
- const grid = drawStateDiagramGrid(parseMermaidStateDiagram(source))
- const size = grid.getTextSize({ trimBottom: true })
+ const grid = drawStateDiagramGrid(parseMermaidStateDiagram(source), { layoutMaxWidth })
+ const size = grid.getTextSize({ trimTop: true, trimBottom: true })
return {
kind,
source,
diff --git a/packages/merman/src/state/diagram.test.ts b/packages/merman/src/state/diagram.test.ts
index abff6bc541a..7c1f5af1f27 100644
--- a/packages/merman/src/state/diagram.test.ts
+++ b/packages/merman/src/state/diagram.test.ts
@@ -3,7 +3,7 @@ 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 { createStateDiagramDrawing, drawStateDiagramGrid } from "./drawing.js"
import { createStateDiagramLayout } from "./layout.js"
import { parseMermaidStateDiagram } from "./parser.js"
import { prepareVisibleStateDiagram } from "./visible-model.js"
@@ -60,6 +60,35 @@ function expectCompleteStateDiagram(source: string, output = renderStateDiagram(
}
}
+type ResponsiveStateLabelProfile = "short" | "long" | "unicode"
+
+function responsiveStateChain(direction: "LR" | "RL", profile: ResponsiveStateLabelProfile): string {
+ const stateLabel = (id: string) => {
+ if (profile === "long") return `${id} deliberate state with a long descriptive label`
+ if (profile === "unicode") return `${id} 東京
résumé 🚀`
+ return `${id} node`
+ }
+ const transitionLabel = (id: string) => {
+ if (profile === "long") return `${id} transition carrying detailed context`
+ if (profile === "unicode") return `${id} 東京
✓ prêt`
+ return `${id} edge`
+ }
+ const ids = ["A", "B", "C", "D", "E"]
+ return [
+ "stateDiagram-v2",
+ `direction ${direction}`,
+ ...ids.map((id) => `state "${stateLabel(id)}" as ${id}`),
+ "[*] --> A",
+ ...ids.slice(0, -1).map((id, index) => `${id} --> ${ids[index + 1]}: ${transitionLabel(`E0${index + 1}`)}`),
+ "E --> [*]",
+ ].join("\n")
+}
+
+function renderedStateDimensions(output: string) {
+ const lines = output.split("\n")
+ return { width: Math.max(...lines.map((line) => stringWidth(line))), height: lines.length }
+}
+
describe("StateDiagram", () => {
test("detects and parses Mermaid state diagrams", () => {
const diagram = parseMermaidStateDiagram(`
@@ -179,6 +208,95 @@ stateDiagram-v2
expect(output).toContain("◀")
})
+ test("renders reverse vertical direction from bottom to top", () => {
+ const source = `stateDiagram-v2
+ direction BT
+ A --> B`
+ const drawing = createStateDiagramDrawing(parseMermaidStateDiagram(source))
+
+ expect(drawing.layout.bounds.get("A")!.top).toBeGreaterThan(drawing.layout.bounds.get("B")!.top)
+ expect(drawing.grid.toString({ trimTop: true, trimBottom: true })).toContain("▲")
+ })
+
+ test.each(
+ (["LR", "RL"] as const).flatMap((direction) =>
+ (["short", "long", "unicode"] as const).flatMap((profile) =>
+ ([60, 80, 120] as const).map((layoutMaxWidth) => [direction, profile, layoutMaxWidth] as const),
+ ),
+ ),
+ )("folds responsive %s %s chains at %d columns", (direction, profile, layoutMaxWidth) => {
+ const source = responsiveStateChain(direction, profile)
+ const horizontal = renderStateDiagram(source)
+ const responsive = renderStateDiagram(source, { layoutMaxWidth })
+ const vertical = renderStateDiagram(source, { direction: direction === "RL" ? "BT" : "TB" })
+
+ expect(renderedStateDimensions(horizontal).width).toBeGreaterThan(layoutMaxWidth)
+ expect(responsive).toBe(vertical)
+ expect(renderedStateDimensions(responsive).width).toBeLessThan(renderedStateDimensions(horizontal).width)
+ for (const content of ["A", "B", "C", "D", "E", "E01", "E02", "E03", "E04"]) {
+ expect(responsive).toContain(content)
+ }
+ })
+
+ test.each(["LR", "RL"] as const)("keeps the narrower %s orientation for broad ranks", (direction) => {
+ const source = `stateDiagram-v2
+ direction ${direction}
+${Array.from({ length: 8 }, (_, index) => ` A --> B${index}`).join("\n")}`
+ const horizontal = renderStateDiagram(source)
+ const vertical = renderStateDiagram(source, { direction: direction === "RL" ? "BT" : "TB" })
+ const responsive = renderStateDiagram(source, { layoutMaxWidth: 60 })
+
+ expect(renderedStateDimensions(horizontal).width).toBeLessThan(renderedStateDimensions(vertical).width)
+ expect(responsive).toBe(horizontal)
+ })
+
+ test("falls back before allocating an oversized horizontal canvas", () => {
+ const ids = Array.from({ length: 301 }, (_, index) => `S${index}`)
+ const label = "transition label carrying enough context to make the horizontal canvas too large"
+ const source = `stateDiagram-v2
+ direction LR
+${ids
+ .slice(0, -1)
+ .map((id, index) => ` ${id} --> ${ids[index + 1]}: ${label}`)
+ .join("\n")}`
+ const output = renderStateDiagram(source, { layoutMaxWidth: 80 })
+
+ expect(output).toContain("S0")
+ expect(output).toContain("S300")
+ expect(renderedStateDimensions(output).width).toBeLessThanOrEqual(stringWidth(label) + 8)
+ })
+
+ test.each(["TB", "TD", "BT"] as const)("preserves explicit %s layouts under a narrow width target", (direction) => {
+ const source = `stateDiagram-v2
+ direction ${direction}
+ A --> B: next`
+
+ expect(renderStateDiagram(source, { layoutMaxWidth: 1 })).toBe(renderStateDiagram(source))
+ })
+
+ test("preserves horizontal layouts that fit or have no finite width target", () => {
+ const source = `stateDiagram-v2
+ direction LR
+ A --> B`
+ const output = renderStateDiagram(source)
+
+ expect(renderStateDiagram(source, { layoutMaxWidth: 120 })).toBe(output)
+ expect(renderStateDiagram(source, { layoutMaxWidth: Number.POSITIVE_INFINITY })).toBe(output)
+ })
+
+ test("treats a single irreducibly wide state as soft overflow", () => {
+ const label = "界".repeat(40)
+ const output = renderStateDiagram(
+ `stateDiagram-v2
+ direction LR
+ state "${label}" as Wide`,
+ { layoutMaxWidth: 60 },
+ )
+
+ expect(renderedStateDimensions(output).width).toBeGreaterThan(60)
+ expect(output).toContain(label)
+ })
+
test("does not mutate a parsed diagram when rendering with a direction override", () => {
const diagram = parseMermaidStateDiagram(`stateDiagram-v2
direction LR
@@ -249,24 +367,21 @@ stateDiagram-v2
for (const line of labelLines) expect(output.split(line)).toHaveLength(2)
expect(output).toMatchInlineSnapshot(`
- "
- create from base image ╭─────────╮
+ " create from base image ╭─────────╮
●───────────────────────▶│ Running │
- ╰──┬──────╯ 💥 sandbox dies BEFORE hook fires
- ▲ │ ▲ (crash, our bug, race)
- ╭────────┼─┴───┼───────╮
+ ╰──┬──────╯
+ ▲ │ ▲ 💥 sandbox dies BEFORE hook fires
+ ╭────────┼─┴───┼───────╮(crash, our bug, race)
▼ ╭────┼─────╯ ▼
╭──────┴──╮ │ ╭──────╮
│ Dormant │ │ │ Lost │
╰─────────╯ │ ╰───┬──╯
│ │
- 📸 suspend hook fires │ │
- (WE must call it on idle)│ │
+ 📸 suspend hook fires │ │ wake from LAST snapshot
+ (WE must call it on idle)│ │ ⚠ files since then GONE
╰───────────────╯
wake from snapshot image
- (apt installs restored!)
- wake from LAST snapshot
- ⚠ files since then GONE"
+ (apt installs restored!)"
`)
})
@@ -505,6 +620,14 @@ stateDiagram-v2
expect(output.split("\n").filter((line) => line.trim())).toHaveLength(3)
})
+ test.each(["LR", "RL"] as const)("trims leading rows from standalone %s choices", (direction) => {
+ const output = renderStateDiagram(`stateDiagram-v2
+ direction ${direction}
+ state Decision <>`)
+
+ expect(output).toBe("◆")
+ })
+
test("renders parallel transitions without losing labels", () => {
const horizontal = renderStateDiagram(`stateDiagram-v2
direction LR
@@ -683,6 +806,135 @@ stateDiagram-v2
}
})
+ test("grows parallel vertical diagrams by the maximum label width rather than their sum", () => {
+ const render = (labels: readonly string[]) =>
+ renderStateDiagram(`stateDiagram-v2
+ direction TB
+${labels.map((label) => ` A --> B: ${label}`).join("\n")}`)
+ const shortLabels = ["one", "two", "three"]
+ const longLabels = [
+ "alpha route label that is deliberately long",
+ "beta route label that is deliberately long",
+ "gamma route label that is deliberately long",
+ ]
+ const width = (output: string) => Math.max(...output.split("\n").map((line) => stringWidth(line)))
+ const labelGrowth =
+ Math.max(...longLabels.map((label) => stringWidth(label))) -
+ Math.max(...shortLabels.map((label) => stringWidth(label)))
+
+ expect(width(render(longLabels)) - width(render(shortLabels))).toBeLessThanOrEqual(labelGrowth + 2)
+ })
+
+ test("keeps audited parallel labels clear of frames and rails", () => {
+ const output = renderStateDiagram(`stateDiagram-v2
+ direction TB
+ A --> B: alpha route label that is deliberately long
+ A --> B: beta route label that is deliberately long
+ A --> B: gamma route label that is deliberately long`)
+
+ expect(output).toMatchInlineSnapshot(`
+ "╭───╮ alpha route label that is deliberately long
+ │ A ├───┬──╮
+ ╰─┬─╯ │ │
+ │ │ │ gamma route label that is deliberately long
+ │ │ │ beta route label that is deliberately long
+ │ │ │
+ ▼ │ │
+ ╭───╮ │ │
+ │ B │◀──┴──╯
+ ╰───╯"
+ `)
+ })
+
+ test("keeps audited repeated self-transition lanes distinct and readable", () => {
+ const output = renderStateDiagram(`stateDiagram-v2
+ direction LR
+ A --> A: one
+ A --> A: two
+ A --> A: three`)
+
+ expect(output).toMatchInlineSnapshot(`
+ "╭───╮
+ │ A │
+ ╰─┬─╯
+ │ ▲ ▲ ▲
+ ├──╯ │ │
+ │ │ │
+ │ one │ │
+ ├──────╯ │
+ │ │
+ │ two │ three
+ ╰──────────╯"
+ `)
+ })
+
+ test("expands composites around self-transition labels without engulfing external states", () => {
+ const source = `stateDiagram-v2
+ direction LR
+ state Outer {
+ A --> A: loop-0
+ A --> A: loop-1
+ A --> A: loop-2
+ }
+ A --> C`
+ const drawing = createStateDiagramDrawing(parseMermaidStateDiagram(source))
+ const outer = drawing.layout.compositeBounds.get("Outer")!
+ const external = drawing.layout.bounds.get("C")!
+ const output = drawing.grid.toString({ trimTop: true, trimBottom: true })
+
+ expect(
+ external.left < outer.left + outer.width &&
+ external.left + external.width > outer.left &&
+ external.top < outer.top + outer.height &&
+ external.top + external.height > outer.top,
+ ).toBe(false)
+ for (const plan of drawing.transitionPlans.filter(
+ (plan) => plan.route.transition.from === plan.route.transition.to,
+ )) {
+ expect(plan.label).toBeDefined()
+ expect(plan.label!.x).toBeGreaterThan(outer.left)
+ expect(plan.label!.y).toBeGreaterThan(outer.top)
+ expect(plan.label!.x + Math.max(...plan.label!.lines.map((line) => stringWidth(line)))).toBeLessThan(
+ outer.left + outer.width,
+ )
+ expect(plan.label!.y + plan.label!.lines.length).toBeLessThan(outer.top + outer.height)
+ }
+ for (const label of ["loop-0", "loop-1", "loop-2"]) expect(output.match(new RegExp(label, "g"))).toHaveLength(1)
+ })
+
+ test("keeps audited nested note connectors direct and inside every frame", () => {
+ const output = renderStateDiagram(`stateDiagram-v2
+ direction TB
+ state Outer {
+ state Inner {
+ A --> B: down
+ B --> A: up
+ note right of B: note
+ }
+ }`)
+
+ expect(output).toMatchInlineSnapshot(`
+ "╭─ Outer ───────────────╮
+ │ │
+ │ ╭─ Inner ───────────╮ │
+ │ │ │ │
+ │ │ ╭───╮ │ │
+ │ │▶│ A │ │ │
+ │ ││╰─┬─╯ │ │
+ │ ││ │ │ │
+ │ ││ │ down │ │
+ │ ││ │ │ │
+ │ ││ ▼ │ │
+ │ ││╭───╮ ╔══════╗ │ │
+ │ │╰┤ B │════╣ note ║ │ │
+ │ │ ╰───╯ ╚══════╝ │ │
+ │ │up │ │
+ │ ╰───────────────────╯ │
+ │ │
+ ╰───────────────────────╯"
+ `)
+ })
+
test("keeps explicit choices visible in choice-only cycles", () => {
const output = renderStateDiagram(`stateDiagram-v2
direction TB
diff --git a/packages/merman/src/state/drawing.ts b/packages/merman/src/state/drawing.ts
index cafc57fc992..a6ba41a426d 100644
--- a/packages/merman/src/state/drawing.ts
+++ b/packages/merman/src/state/drawing.ts
@@ -1,5 +1,5 @@
import { BorderChars, type BorderCharacters, type BorderStyle } from "@opentui/core"
-import { DiagramCanvas, type DiagramCanvasCell } from "../core/canvas.js"
+import { DiagramCanvas, DiagramCanvasSizeError, type DiagramCanvasCell } from "../core/canvas.js"
import { directionBetween, orthogonalPathPoints, type DiagramDirection } from "../core/geometry.js"
import {
diagramArrowHead,
@@ -12,6 +12,7 @@ import {
createStateDiagramLayout,
expandCompositeBoundsForFeedback,
expandCompositeBoundsForInternalTransitions,
+ separateExternalBoundsFromComposites,
translateStateDiagramLayout,
type StateDiagramBoxBounds as BoxBounds,
type StateDiagramNoteBounds as StateNoteBounds,
@@ -67,23 +68,11 @@ function makeGrid(width: number, height: number): StateGrid {
})
}
-function setCell(
- grid: StateGrid,
- x: number,
- y: number,
- char: string,
- style?: StateCellStyle,
-): void {
+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 {
+function setText(grid: StateGrid, x: number, y: number, text: string, style?: StateCellStyle): void {
grid.setText(x, y, text, style)
}
@@ -108,12 +97,7 @@ 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))
}
@@ -125,6 +109,10 @@ function drawContainerFrame(
style: StateCellStyle,
): void {
drawDiagramFrame(bounds, chars, (x, y, char) => setCell(grid, x, y, char, style))
+ drawContainerLabel(grid, bounds, label, style)
+}
+
+function drawContainerLabel(grid: StateGrid, bounds: BoxBounds, label: string, style: StateCellStyle): void {
if (label) setText(grid, bounds.left + 2, bounds.top, ` ${label} `, style)
}
@@ -190,9 +178,7 @@ function drawTransitionRenderPlan(
setCell(grid, cell.x, cell.y, char, departure.get(`${cell.x}:${cell.y}`) ?? "transition")
}
if (plan.label) {
- plan.label.lines.forEach((line, index) =>
- setText(grid, plan.label!.x, plan.label!.y + index, line, "label"),
- )
+ plan.label.lines.forEach((line, index) => setText(grid, plan.label!.x, plan.label!.y + index, line, "label"))
}
}
@@ -210,8 +196,49 @@ function drawTransitionJunctionPlans(
}
export function drawStateDiagramGrid(sourceDiagram: StateDiagram, options: StateDiagramRenderOptions = {}): StateGrid {
- const directedDiagram = options.direction ? { ...sourceDiagram, direction: options.direction } : sourceDiagram
- const diagram = prepareVisibleStateDiagram(directedDiagram)
+ return createStateDiagramDrawing(sourceDiagram, options).grid
+}
+
+export function createStateDiagramDrawing(sourceDiagram: StateDiagram, options: StateDiagramRenderOptions = {}) {
+ const direction = options.direction ?? sourceDiagram.direction
+ if (direction !== "LR" && direction !== "RL") {
+ return createStateDiagramDrawingWithDirection(sourceDiagram, options, direction)
+ }
+ if (options.layoutMaxWidth === undefined || !Number.isFinite(options.layoutMaxWidth)) {
+ return createStateDiagramDrawingWithDirection(sourceDiagram, options, direction)
+ }
+ const fallbackDirection = direction === "RL" ? "BT" : "TB"
+ const maxWidth = Math.max(1, Math.trunc(options.layoutMaxWidth))
+ const drawing = (() => {
+ try {
+ return createStateDiagramDrawingWithDirection(sourceDiagram, options, direction)
+ } catch (error) {
+ if (error instanceof DiagramCanvasSizeError) return undefined
+ throw error
+ }
+ })()
+ if (!drawing) return createStateDiagramDrawingWithDirection(sourceDiagram, options, fallbackDirection)
+ if (drawing.grid.getTextSize({ trimTop: true, trimBottom: true }).width <= maxWidth) {
+ return drawing
+ }
+ const fallback = createStateDiagramDrawingWithDirection(sourceDiagram, options, fallbackDirection)
+ if (
+ fallback.grid.getTextSize({ trimTop: true, trimBottom: true }).width >=
+ drawing.grid.getTextSize({ trimTop: true, trimBottom: true }).width
+ ) {
+ return drawing
+ }
+ return fallback
+}
+
+function createStateDiagramDrawingWithDirection(
+ sourceDiagram: StateDiagram,
+ options: StateDiagramRenderOptions,
+ direction: StateDiagram["direction"],
+) {
+ const diagram = prepareVisibleStateDiagram(
+ direction === sourceDiagram.direction ? sourceDiagram : { ...sourceDiagram, direction },
+ )
const borderStyle = options.borderStyle ?? DEFAULT_STATE_BORDER_STYLE
const arrowHeadStyle = options.arrowHeadStyle ?? DEFAULT_STATE_ARROW_HEAD_STYLE
const minStateGap = normalizeStateMinStateGap(options.minStateGap)
@@ -226,21 +253,24 @@ export function drawStateDiagramGrid(sourceDiagram: StateDiagram, options: State
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,
- noteBounds,
- searchBudget,
- })
- expandCompositeBoundsForInternalTransitions(diagram, compositeBounds, transitionPlans)
+ let transitionPlans: StateTransitionRenderPlan[] = []
+ const separationAttempts = diagram.states.length + diagram.composites.length + 1
+ for (let attempt = 0; attempt < separationAttempts; attempt++) {
+ transitionPlans = createStateTransitionRenderPlans(diagram, bounds, feedbackLaneY, {
+ feedbackTopY,
+ noteBounds,
+ searchBudget,
+ })
+ expandCompositeBoundsForInternalTransitions(diagram, compositeBounds, transitionPlans)
+ if (!separateExternalBoundsFromComposites(diagram, layout)) break
+ if (attempt === separationAttempts - 1) throw new Error("State composite separation did not converge")
+ }
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] : []),
- ]),
+ ...transitionPlans.flatMap((plan) => [...plan.cells.map((cell) => cell.x), ...(plan.label ? [plan.label.x] : [])]),
)
const contentTop = Math.min(
0,
@@ -305,10 +335,15 @@ export function drawStateDiagramGrid(sourceDiagram: StateDiagram, options: State
drawTransitionJunctionPlans(grid, diagram, bounds, transitionPlans)
+ for (const composite of diagram.composites) {
+ const bound = compositeBounds.get(composite.id)
+ if (bound) drawContainerLabel(grid, bound, composite.label, "composite")
+ }
+
for (const noteBound of noteBounds) {
const target = bounds.get(noteBound.note.target)
if (target) drawNote(grid, noteBound, target)
}
- return grid
+ return { grid, diagram, layout, transitionPlans }
}
diff --git a/packages/merman/src/state/layout.test.ts b/packages/merman/src/state/layout.test.ts
index fc226c73be7..fcbb5b4f678 100644
--- a/packages/merman/src/state/layout.test.ts
+++ b/packages/merman/src/state/layout.test.ts
@@ -2,7 +2,7 @@ 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 { createStateDiagramLayout, expandCompositeBoundsForInternalTransitions } from "./layout.js"
import { stateDiagramNoteConnector } from "./note.js"
import { parseMermaidStateDiagram } from "./parser.js"
import { createStateTransitionRenderPlans } from "./routing.js"
@@ -192,4 +192,75 @@ describe("StateDiagramLayout", () => {
)
expect(plans.every((plan) => plan.path.every(([x, y]) => !noteCells.has(`${x}:${y}`)))).toBe(true)
})
+
+ test.each([
+ ["LR", -1],
+ ["RL", 1],
+ ] as const)("keeps a reciprocal pair on the %s axis", (direction, expectedSign) => {
+ const diagram = prepareVisibleStateDiagram(
+ parseMermaidStateDiagram(`stateDiagram-v2
+ direction ${direction}
+ A --> B: forward
+ B --> A: backward`),
+ )
+ const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
+ const a = layout.bounds.get("A")!
+ const b = layout.bounds.get("B")!
+
+ expect(a.centerY).toBe(b.centerY)
+ expect(Math.sign(a.centerX - b.centerX)).toBe(expectedSign)
+ })
+
+ test.each(["TB", "TD"] as const)(
+ "contains nested internal feedback with strict margins in %s diagrams",
+ (direction) => {
+ const diagram = prepareVisibleStateDiagram(
+ parseMermaidStateDiagram(`stateDiagram-v2
+ direction ${direction}
+ state Outer {
+ state Inner {
+ A --> B: down
+ B --> A: up
+ note right of B: nested note
+ }
+ }`),
+ )
+ const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
+ const plans = createStateTransitionRenderPlans(diagram, layout.bounds, 30, { noteBounds: layout.noteBounds })
+ expandCompositeBoundsForInternalTransitions(diagram, layout.compositeBounds, plans)
+ const inner = layout.compositeBounds.get("Inner")!
+ const outer = layout.compositeBounds.get("Outer")!
+ const note = layout.noteBounds[0]!
+
+ for (const composite of [inner, outer]) {
+ for (const plan of plans) {
+ expect(
+ plan.path.every(
+ ([x, y]) =>
+ x > composite.left &&
+ x < composite.left + composite.width - 1 &&
+ y > composite.top &&
+ y < composite.top + composite.height - 1,
+ ),
+ ).toBe(true)
+ }
+ expect(
+ note.connector!.points.every(
+ (point) =>
+ point.x > composite.left &&
+ point.x < composite.left + composite.width - 1 &&
+ point.y > composite.top &&
+ point.y < composite.top + composite.height - 1,
+ ),
+ ).toBe(true)
+ }
+ expect(new Set(note.connector!.points.map((point) => point.y))).toEqual(
+ new Set([layout.bounds.get("B")!.centerY]),
+ )
+ expect(inner.left - outer.left).toBeGreaterThanOrEqual(2)
+ expect(inner.top - outer.top).toBeGreaterThanOrEqual(2)
+ expect(outer.left + outer.width - (inner.left + inner.width)).toBeGreaterThanOrEqual(2)
+ expect(outer.top + outer.height - (inner.top + inner.height)).toBeGreaterThanOrEqual(2)
+ },
+ )
})
diff --git a/packages/merman/src/state/layout.ts b/packages/merman/src/state/layout.ts
index 7c6bf497d7c..188e958ef59 100644
--- a/packages/merman/src/state/layout.ts
+++ b/packages/merman/src/state/layout.ts
@@ -131,7 +131,8 @@ function computeMainPath(diagram: StateDiagram): string[] {
const fromParent = statesById.get(current)?.parentId
const toParent = statesById.get(transition.to)?.parentId
return Boolean(fromParent && toParent && fromParent !== toParent)
- })
+ }) ??
+ (path.length === 1 && candidates.length === 1 ? candidates[0] : undefined)
if (!next) break
path.push(next.to)
visited.add(next.to)
@@ -319,7 +320,12 @@ function findNoteConnector(
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)
+ !(
+ 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)) {
@@ -335,14 +341,7 @@ function findNoteConnector(
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,
- )
+ const path = findStateManhattanPath(starts, goal, search, { minX: 0, minY, maxX, maxY }, budget, isFree)
return path ? { connectorY, points: [...path, end] } : undefined
}
@@ -375,13 +374,27 @@ function expandCompositeBoundsForNotes(diagram: StateDiagram, layout: StateDiagr
)
if (descendantNotes.length === 0) continue
- const childBounds = [bound, ...descendantNotes]
- const noteTop = Math.min(...childBounds.map((child) => child.top), bound.top)
- const noteBottom = Math.max(...childBounds.map((child) => child.top + child.height), bound.top + bound.height)
- const left = Math.min(...childBounds.map((child) => child.left)) - 2
- const top = noteTop < bound.top ? noteTop - 1 : bound.top
- const right = Math.max(...childBounds.map((child) => child.left + child.width)) + 2
- const bottom = noteBottom > bound.top + bound.height ? noteBottom + 1 : bound.top + bound.height
+ const connectorPoints = descendantNotes.flatMap((note) => note.connector?.points ?? [])
+ const left = Math.min(
+ bound.left,
+ ...descendantNotes.map((note) => note.left - 2),
+ ...connectorPoints.map((point) => point.x - 1),
+ )
+ const top = Math.min(
+ bound.top,
+ ...descendantNotes.map((note) => note.top - 1),
+ ...connectorPoints.map((point) => point.y - 1),
+ )
+ const right = Math.max(
+ bound.left + bound.width,
+ ...descendantNotes.map((note) => note.left + note.width + 2),
+ ...connectorPoints.map((point) => point.x + 2),
+ )
+ const bottom = Math.max(
+ bound.top + bound.height,
+ ...descendantNotes.map((note) => note.top + note.height + 1),
+ ...connectorPoints.map((point) => point.y + 2),
+ )
bound.left = left
bound.top = top
@@ -392,13 +405,112 @@ function expandCompositeBoundsForNotes(diagram: StateDiagram, layout: StateDiagr
}
}
+function expandCompositeBoundsForInternalRouting(diagram: StateDiagram, layout: StateDiagramLayout): void {
+ if (diagram.direction === "LR" || diagram.direction === "RL") return
+ const statesById = new Map(diagram.states.map((state) => [state.id, state]))
+ const compositesById = new Map(diagram.composites.map((composite) => [composite.id, composite]))
+
+ for (const composite of [...diagram.composites].reverse()) {
+ const bound = layout.compositeBounds.get(composite.id)
+ if (!bound) continue
+ const internal = diagram.transitions.filter(
+ (transition) =>
+ transition.from !== transition.to &&
+ innermostCommonCompositeId(transition, statesById, compositesById) === composite.id,
+ )
+ const endpointOccurrences = new Map()
+ const sideRoutes = internal.filter((transition) => {
+ const from = layout.bounds.get(transition.from)
+ const to = layout.bounds.get(transition.to)
+ if (!from || !to) return false
+ const key = `${transition.from}\u0000${transition.to}`
+ const occurrence = endpointOccurrences.get(key) ?? 0
+ endpointOccurrences.set(key, occurrence + 1)
+ const fromParent = statesById.get(transition.from)?.parentId
+ const toParent = statesById.get(transition.to)?.parentId
+ return occurrence > 0 || from.centerY > to.centerY || fromParent !== toParent
+ })
+ if (sideRoutes.length === 0) continue
+ const childRight = Math.max(
+ ...diagram.states.flatMap((state) => {
+ if (!belongsToComposite(state.id, composite.id, statesById, compositesById)) return []
+ const child = layout.bounds.get(state.id)
+ return child ? [child.left + child.width] : []
+ }),
+ ...diagram.composites.flatMap((childComposite) => {
+ if (childComposite.parentId !== composite.id) return []
+ const child = layout.compositeBounds.get(childComposite.id)
+ return child ? [child.left + child.width] : []
+ }),
+ )
+ const labelWidth = Math.max(...sideRoutes.map((transition) => measureStateTransitionLabel(transition.label).width))
+ const right = childRight + labelWidth + sideRoutes.length * 3 + 6
+ if (right <= bound.left + bound.width) continue
+ bound.width = right - bound.left
+ bound.centerX = bound.left + Math.floor(bound.width / 2)
+ }
+
+ enforceCompositeMargins(diagram, layout.compositeBounds)
+}
+
+function innermostCommonCompositeId(
+ transition: StateDiagramTransition,
+ statesById: Map,
+ compositesById: Map,
+): string | undefined {
+ const containers = (id: string) => {
+ const ids: string[] = []
+ let parentId = statesById.get(id)?.parentId ?? compositesById.get(id)?.parentId
+ while (parentId) {
+ ids.push(parentId)
+ parentId = compositesById.get(parentId)?.parentId
+ }
+ return ids
+ }
+ const target = new Set(containers(transition.to))
+ return containers(transition.from).find((id) => target.has(id))
+}
+
+function enforceCompositeMargins(diagram: StateDiagram, compositeBounds: Map): void {
+ const compositesByParent = new Map()
+ for (const composite of diagram.composites) {
+ if (!composite.parentId) continue
+ const children = compositesByParent.get(composite.parentId) ?? []
+ children.push(composite)
+ compositesByParent.set(composite.parentId, children)
+ }
+
+ const expand = (composite: StateDiagramCompositeState): StateDiagramBoxBounds | undefined => {
+ const bound = compositeBounds.get(composite.id)
+ if (!bound) return undefined
+ const children = (compositesByParent.get(composite.id) ?? [])
+ .map(expand)
+ .filter((child): child is StateDiagramBoxBounds => Boolean(child))
+ if (children.length === 0) return bound
+ const left = Math.min(bound.left, ...children.map((child) => child.left - 2))
+ const top = Math.min(bound.top, ...children.map((child) => child.top - 2))
+ const right = Math.max(bound.left + bound.width, ...children.map((child) => child.left + child.width + 2))
+ const bottom = Math.max(bound.top + bound.height, ...children.map((child) => child.top + child.height + 2))
+ bound.left = left
+ bound.top = top
+ bound.width = right - left
+ bound.height = bottom - top
+ bound.centerX = left + Math.floor(bound.width / 2)
+ bound.centerY = top + Math.floor(bound.height / 2)
+ return bound
+ }
+
+ for (const composite of diagram.composites.filter((candidate) => !candidate.parentId)) expand(composite)
+}
+
function boundsIntersect(left: StateDiagramBoxBounds, right: StateDiagramBoxBounds): boolean {
return intersects(left.left, left.top, left.width, left.height, right, 0)
}
-function separateExternalBoundsFromComposites(diagram: StateDiagram, layout: StateDiagramLayout): void {
+export function separateExternalBoundsFromComposites(diagram: StateDiagram, layout: StateDiagramLayout): boolean {
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
const compositesById = new Map(diagram.composites.map((composite) => [composite.id, composite]))
+ let shifted = false
for (const composite of diagram.composites) {
const compositeBound = layout.compositeBounds.get(composite.id)
@@ -433,8 +545,10 @@ function separateExternalBoundsFromComposites(diagram: StateDiagram, layout: Sta
}
shiftBounds(uniqueBounds(boundsToShift), dx, 0)
+ shifted = true
}
}
+ return shifted
}
function finalizeLayout(
@@ -445,6 +559,7 @@ function finalizeLayout(
if (diagram.composites.length === 0 && diagram.notes.length === 0) return layout
addCompositeBounds(diagram, layout)
normalizeLayout(layout)
+ expandCompositeBoundsForInternalRouting(diagram, layout)
if (diagram.notes.length > 0) {
const allBounds = [...layout.bounds.values()]
placeStateDiagramNotesAroundTransitions(
@@ -464,6 +579,7 @@ function finalizeLayout(
)
}
expandCompositeBoundsForNotes(diagram, layout)
+ expandCompositeBoundsForInternalRouting(diagram, layout)
separateExternalBoundsFromComposites(diagram, layout)
normalizeLayout(layout)
return layout
@@ -479,9 +595,10 @@ export function createStateDiagramLayout(
}
const ranks = computeRanks(diagram)
+ const maxRank = Math.max(0, ...ranks.values())
const byRank = new Map()
for (const state of diagram.states) {
- const rank = ranks.get(state.id) ?? 0
+ const rank = diagram.direction === "BT" ? maxRank - (ranks.get(state.id) ?? 0) : (ranks.get(state.id) ?? 0)
const list = byRank.get(rank) ?? []
list.push(state)
byRank.set(rank, list)
@@ -491,9 +608,13 @@ export function createStateDiagramLayout(
const sizes = new Map(diagram.states.map((state) => [state.id, stateSize(state)]))
const bounds = new Map()
const outgoingLabelRows = new Map()
+ const selfTransitionCounts = new Map()
for (const transition of diagram.transitions) {
const rows = measureStateTransitionLabel(transition.label).height
outgoingLabelRows.set(transition.from, Math.max(outgoingLabelRows.get(transition.from) ?? 0, rows))
+ if (transition.from === transition.to) {
+ selfTransitionCounts.set(transition.from, (selfTransitionCounts.get(transition.from) ?? 0) + 1)
+ }
}
const singleColumnCenter = Math.max(
@@ -524,8 +645,12 @@ export function createStateDiagramLayout(
x += size.width + options.minStateGap + 8
}
const labelRows = states.reduce((rows, state) => Math.max(rows, outgoingLabelRows.get(state.id) ?? 0), 0)
+ const selfTransitionRows = states.reduce(
+ (rows, state) => Math.max(rows, (selfTransitionCounts.get(state.id) ?? 0) * 3 + 1),
+ 0,
+ )
const pseudoStateApproachClearance = states.some((state) => state.kind === "choice") ? 2 : 0
- y += rowHeight + Math.max(4, labelRows + 3) + pseudoStateApproachClearance
+ y += rowHeight + Math.max(4, labelRows + 3, selfTransitionRows) + pseudoStateApproachClearance
}
return finalizeLayout(diagram, emptyLayout(bounds, sizes), budget)
@@ -805,21 +930,21 @@ function placeStateDiagramNotesAroundTransitions(
size,
),
),
- )
- .flat()
+ ).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
+ 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, 1) ??
+ findPlacement(noteSpace, 1) ??
findPlacement(space, MAX_STRICT_NOTE_PLACEMENTS) ??
findPlacement(reserved, candidateBounds.length) ??
outsideNotePlacement(noteSpace, note, index, target, size)
@@ -857,10 +982,7 @@ function outsideNotePlacement(
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 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,
@@ -975,16 +1097,30 @@ export function expandCompositeBoundsForInternalTransitions(
belongsToComposite(plan.route.transition.from, composite.id, statesById, compositesById) &&
belongsToComposite(plan.route.transition.to, composite.id, statesById, compositesById),
)
- const occupiedYs = internalPlans.flatMap((plan) => [
- ...plan.cells.map((cell) => cell.y),
- ...(plan.label ? plan.label.lines.map((_, index) => plan.label!.y + index) : []),
+ const occupied = internalPlans.flatMap((plan) => [
+ ...plan.cells.map((cell) => ({ x: cell.x, y: cell.y })),
+ ...(plan.label
+ ? plan.label.lines.flatMap((line, row) =>
+ Array.from({ length: diagramTextWidth(line) }, (_, column) => ({
+ x: plan.label!.x + column,
+ y: plan.label!.y + row,
+ })),
+ )
+ : []),
])
- if (occupiedYs.length === 0) continue
+ if (occupied.length === 0) continue
- const top = Math.min(bound.top, Math.min(...occupiedYs) - 1)
- const bottom = Math.max(bound.top + bound.height, Math.max(...occupiedYs) + 2)
+ const left = Math.min(bound.left, Math.min(...occupied.map((point) => point.x)) - 1)
+ const top = Math.min(bound.top, Math.min(...occupied.map((point) => point.y)) - 1)
+ const right = Math.max(bound.left + bound.width, Math.max(...occupied.map((point) => point.x)) + 2)
+ const bottom = Math.max(bound.top + bound.height, Math.max(...occupied.map((point) => point.y)) + 2)
+ bound.left = left
bound.top = top
+ bound.width = right - left
bound.height = bottom - top
+ bound.centerX = bound.left + Math.floor(bound.width / 2)
bound.centerY = bound.top + Math.floor(bound.height / 2)
}
+
+ enforceCompositeMargins(diagram, compositeBounds)
}
diff --git a/packages/merman/src/state/parser.ts b/packages/merman/src/state/parser.ts
index fa2521b993b..c02a5af881a 100644
--- a/packages/merman/src/state/parser.ts
+++ b/packages/merman/src/state/parser.ts
@@ -16,14 +16,14 @@ const STATE_RE = /^state\s+"([^"]+)"\s+as\s+(\S+)$/i
const COMPOSITE_STATE_RE = /^state\s+(?:"([^"]+)"\s+as\s+)?(\S+)\s*\{$/i
const CHOICE_STATE_RE = /^state\s+(\S+)\s+<>$/i
const TRANSITION_RE = /^(\[\*\]|[^\s:]+)\s*-->\s*(\[\*\]|[^\s:]+)(?:\s*:\s*(.*))?$/
-const DIRECTION_RE = /^direction\s+(TB|TD|LR|RL)$/i
+const DIRECTION_RE = /^direction\s+(TB|TD|BT|LR|RL)$/i
const NOTE_INLINE_RE = /^note\s+(left|right)\s+of\s+(\S+)\s*:\s*(.*)$/i
const NOTE_START_RE = /^note\s+(left|right)\s+of\s+(\S+)\s*$/i
const NOTE_END_RE = /^end\s+note$/i
function normalizeDirection(value?: string): StateDiagramDirection {
const upper = value?.toUpperCase()
- if (upper === "TB" || upper === "TD" || upper === "LR" || upper === "RL") return upper
+ if (upper === "TB" || upper === "TD" || upper === "BT" || upper === "LR" || upper === "RL") return upper
return DEFAULT_DIRECTION
}
diff --git a/packages/merman/src/state/render-grid.ts b/packages/merman/src/state/render-grid.ts
index 50a0fb186cf..687d1aba2a0 100644
--- a/packages/merman/src/state/render-grid.ts
+++ b/packages/merman/src/state/render-grid.ts
@@ -7,11 +7,12 @@ import type { StateCellStyle } from "./types.js"
export type StateGrid = DiagramCanvas
export function renderStateGridText(grid: StateGrid): string {
- return grid.toString({ trimBottom: true })
+ return grid.toString({ trimTop: true, trimBottom: true })
}
export function renderStateGridStyledText(grid: StateGrid, colors: StateStyleColors): StyledText {
return renderDiagramGridStyledText(grid, (run) => (run.style ? colors[run.style] : undefined), undefined, {
+ trimTop: true,
trimBottom: true,
})
}
diff --git a/packages/merman/src/state/routing.test.ts b/packages/merman/src/state/routing.test.ts
index ce6088b20b3..fa8f351fdb7 100644
--- a/packages/merman/src/state/routing.test.ts
+++ b/packages/merman/src/state/routing.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test"
+import { diagramTextWidth } from "../core/text.js"
import type { StateDiagramBoxBounds } from "./layout.js"
import { createStateDiagramLayout } from "./layout.js"
import { parseMermaidStateDiagram } from "./parser.js"
@@ -286,10 +287,7 @@ describe("createStateTransitionRenderPlans", () => {
expect(
plan.path.some(
([x, y]) =>
- x >= sibling.left &&
- x < sibling.left + sibling.width &&
- y >= sibling.top &&
- y < sibling.top + sibling.height,
+ x >= sibling.left && x < sibling.left + sibling.width && y >= sibling.top && y < sibling.top + sibling.height,
),
).toBe(false)
})
@@ -348,6 +346,158 @@ describe("createStateTransitionRenderPlans", () => {
}),
).toBe(true)
})
+
+ test.each(["LR", "RL", "TB", "TD"] as const)(
+ "keeps endpoint-disjoint %s transitions on separate cells when a detour exists",
+ (direction) => {
+ const diagram = prepareVisibleStateDiagram(
+ parseMermaidStateDiagram(`stateDiagram-v2
+ direction ${direction}
+ state "A" as A
+ state "B" as B
+ state "C" as C
+ state "D" as D
+ B --> D: e0
+ C --> A: e1`),
+ )
+ const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
+ const plans = createStateTransitionRenderPlans(diagram, layout.bounds, 30)
+ const first = new Set(plans[0]!.path.map(([x, y]) => `${x}:${y}`))
+
+ expect(plans[1]!.path.every(([x, y]) => !first.has(`${x}:${y}`))).toBe(true)
+ },
+ )
+
+ test("anchors labels to final repaired transition geometry", () => {
+ const diagram = prepareVisibleStateDiagram(
+ parseMermaidStateDiagram(`stateDiagram-v2
+ direction TB
+ state "A" as A
+ state "B" as B
+ state "C" as C
+ A --> B: e0
+ A --> C: e1
+ B --> A: e2`),
+ )
+ const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
+ const plan = createStateTransitionRenderPlans(diagram, layout.bounds, 30).find(
+ (candidate) => candidate.route.transition.label === "e2",
+ )!
+ const width = Math.max(...plan.label!.lines.map(diagramTextWidth))
+ const distance = Math.min(
+ ...plan.path.map(([pathX, pathY]) => {
+ const dx =
+ pathX < plan.label!.x
+ ? plan.label!.x - pathX
+ : pathX >= plan.label!.x + width
+ ? pathX - (plan.label!.x + width - 1)
+ : 0
+ const dy =
+ pathY < plan.label!.y
+ ? plan.label!.y - pathY
+ : pathY >= plan.label!.y + plan.label!.lines.length
+ ? pathY - (plan.label!.y + plan.label!.lines.length - 1)
+ : 0
+ return dx + dy
+ }),
+ )
+
+ expect(plan.pathRepaired).toBe(true)
+ expect(distance).toBeLessThanOrEqual(4)
+ })
+
+ test.each(["LR", "RL", "TB", "TD"] as const)(
+ "allocates distinct connected self-transition lanes in %s diagrams",
+ (direction) => {
+ for (const count of [2, 3, 4]) {
+ const diagram = prepareVisibleStateDiagram(
+ parseMermaidStateDiagram(`stateDiagram-v2
+ direction ${direction}
+${Array.from({ length: count }, (_, index) => ` A --> A: loop-${index}`).join("\n")}`),
+ )
+ const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
+ const plans = createStateTransitionRenderPlans(diagram, layout.bounds, 30)
+
+ expect(new Set(plans.map((plan) => plan.path.map(([x, y]) => `${x}:${y}`).join("|"))).size).toBe(count)
+ for (const plan of plans) {
+ expect(
+ plan.path.slice(1).every(([x, y], index) => {
+ const previous = plan.path[index]!
+ return Math.abs(x - previous[0]) + Math.abs(y - previous[1]) === 1
+ }),
+ ).toBe(true)
+ }
+ }
+ },
+ )
+
+ test("uses fixed-width side lanes for long parallel vertical labels", () => {
+ const diagram = prepareVisibleStateDiagram(
+ parseMermaidStateDiagram(`stateDiagram-v2
+ direction TB
+ A --> B: alpha route label that is deliberately long
+ A --> B: beta route label that is deliberately long
+ A --> B: gamma route label that is deliberately long`),
+ )
+ const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
+ const rails = createStateTransitionRoutePlans(diagram, layout.bounds, 30)
+ .filter((plan) => plan.kind === "side-parallel")
+ .map((plan) => plan.railX)
+
+ expect(rails).toHaveLength(2)
+ expect(rails[1]! - rails[0]!).toBe(3)
+ })
+
+ test.each([
+ [
+ "parallel",
+ `stateDiagram-v2
+ direction TB
+ A --> B: alpha route label that is deliberately long
+ A --> B: beta route label that is deliberately long
+ A --> B: gamma route label that is deliberately long`,
+ 3,
+ ],
+ [
+ "self",
+ `stateDiagram-v2
+ direction LR
+ A --> A: one
+ A --> A: two
+ A --> A: three`,
+ 3,
+ ],
+ ] as const)("keeps %s labels one column clear of frames and route rails", (_, source, count) => {
+ const diagram = prepareVisibleStateDiagram(parseMermaidStateDiagram(source))
+ const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
+ const plans = createStateTransitionRenderPlans(diagram, layout.bounds, 30)
+ const routeCells = plans.flatMap((plan) => plan.path)
+
+ expect(plans).toHaveLength(count)
+ for (const plan of plans) {
+ const width = Math.max(...plan.label!.lines.map(diagramTextWidth))
+ for (const [row, line] of plan.label!.lines.entries()) {
+ const lineWidth = diagramTextWidth(line)
+ const y = plan.label!.y + row
+ expect(
+ routeCells
+ .filter(([, routeY]) => routeY === y)
+ .every(([routeX]) => routeX < plan.label!.x - 1 || routeX > plan.label!.x + lineWidth),
+ ).toBe(true)
+ for (const bound of layout.bounds.values()) {
+ if (y < bound.top || y >= bound.top + bound.height) continue
+ expect(bound.left + bound.width <= plan.label!.x - 1 || bound.left >= plan.label!.x + width + 1).toBe(true)
+ }
+ }
+ }
+
+ if (source.includes("A --> A")) {
+ const arrowXs = plans
+ .flatMap((plan) => plan.cells.filter((cell) => cell.arrowDirection).map((cell) => cell.x))
+ .sort((left, right) => left - right)
+ expect(arrowXs.slice(1).every((x, index) => x - arrowXs[index]! >= 4)).toBe(true)
+ }
+ })
})
describe("createStateTransitionJunctionPlans", () => {
diff --git a/packages/merman/src/state/routing.ts b/packages/merman/src/state/routing.ts
index 4e4b7df48b1..16d742ff4ad 100644
--- a/packages/merman/src/state/routing.ts
+++ b/packages/merman/src/state/routing.ts
@@ -1,6 +1,6 @@
import { BorderChars } from "@opentui/core"
import { diagramLineGlyph } from "../core/drawing.js"
-import type { DiagramDirection } from "../core/geometry.js"
+import { orthogonalPathPoints, 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, StateDiagramNoteBounds } from "./layout.js"
@@ -24,7 +24,7 @@ interface StateTransitionRoutePlanBase {
}
export type StateTransitionRoutePlan =
- | (StateTransitionRoutePlanBase & { kind: "self" })
+ | (StateTransitionRoutePlanBase & { kind: "self"; lane: number })
| (StateTransitionRoutePlanBase & { kind: "horizontal-forward"; leftToRight: boolean })
| (StateTransitionRoutePlanBase & { kind: "bottom-feedback"; railY: number; approachX: number })
| (StateTransitionRoutePlanBase & { kind: "top-feedback"; railY: number })
@@ -58,6 +58,7 @@ export interface StateTransitionRenderPlan {
cells: readonly StateTransitionRenderCell[]
path: readonly StateTransitionPathPoint[]
label?: StateTransitionRenderLabel
+ pathRepaired?: boolean
}
export interface StateTransitionRenderOptions {
@@ -350,6 +351,26 @@ function sideParallelTargetApproach(
})
}
+function containingCompositeIds(diagram: StateVisibleDiagram, id: string): string[] {
+ const statesById = new Map(diagram.states.map((state) => [state.id, state]))
+ const compositesById = new Map(diagram.composites.map((composite) => [composite.id, composite]))
+ const ids: string[] = []
+ let parentId = statesById.get(id)?.parentId ?? compositesById.get(id)?.parentId
+ while (parentId) {
+ ids.push(parentId)
+ parentId = compositesById.get(parentId)?.parentId
+ }
+ return ids
+}
+
+function innermostCommonComposite(
+ diagram: StateVisibleDiagram,
+ transition: StateVisibleTransition,
+): string | undefined {
+ const target = new Set(containingCompositeIds(diagram, transition.to))
+ return containingCompositeIds(diagram, transition.from).find((id) => target.has(id))
+}
+
export function createStateTransitionRoutePlans(
diagram: StateVisibleDiagram,
bounds: ReadonlyMap,
@@ -358,11 +379,13 @@ export function createStateTransitionRoutePlans(
): StateTransitionRoutePlan[] {
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
const endpointOccurrences = new Map()
+ const selfOccurrences = new Map()
const parallelLaneGap = Math.max(
3,
...diagram.transitions.map((transition) => measureStateTransitionLabel(transition.label).height + 2),
)
let nextSideRailX = Math.max(0, ...[...bounds.values()].map((bound) => bound.left + bound.width)) + 3
+ const sideRailLanes = new Map()
const feedbackAllocations = createFeedbackAllocations(diagram, bounds, feedbackLaneY, parallelLaneGap, feedbackTopY)
let nextBottomRailY =
Math.max(
@@ -371,9 +394,24 @@ export function createStateTransitionRoutePlans(
.filter((allocation) => allocation.side === "bottom")
.map((allocation) => allocation.railY),
) + parallelLaneGap
- const allocateSideRail = (label: string): number => {
+ const allocateSideRail = (transition: StateVisibleTransition): number => {
+ const compositeId = innermostCommonComposite(diagram, transition)
+ const composite = compositeId ? bounds.get(compositeId) : undefined
+ if (compositeId && composite) {
+ const lane = sideRailLanes.get(compositeId) ?? 0
+ sideRailLanes.set(compositeId, lane + 1)
+ const descendantRight = Math.max(
+ composite.left + 1,
+ ...diagram.states.flatMap((state) => {
+ if (!containingCompositeIds(diagram, state.id).includes(compositeId)) return []
+ const bound = bounds.get(state.id)
+ return bound ? [bound.left + bound.width] : []
+ }),
+ )
+ return Math.min(descendantRight + 2 + lane * 3, composite.left + composite.width - 2)
+ }
const railX = nextSideRailX
- nextSideRailX += Math.max(3, measureStateTransitionLabel(label).width + 2)
+ nextSideRailX += 3
return railX
}
const allocateBottomRail = (): number => {
@@ -392,7 +430,7 @@ export function createStateTransitionRoutePlans(
const targetIsHiddenMarker = isHiddenCompositeMarker(targetState)
const base = { transition, from, to, targetIsChoice, targetIsHiddenMarker }
const sideParallel = (): StateTransitionRoutePlan => {
- const railX = allocateSideRail(transition.label)
+ const railX = allocateSideRail(transition)
return {
...base,
kind: "side-parallel",
@@ -400,7 +438,11 @@ export function createStateTransitionRoutePlans(
targetApproach: sideParallelTargetApproach(diagram, transition, from, to, bounds, railX),
}
}
- if (transition.from === transition.to) return [{ ...base, kind: "self" }]
+ if (transition.from === transition.to) {
+ const lane = selfOccurrences.get(transition.from) ?? 0
+ selfOccurrences.set(transition.from, lane + 1)
+ return [{ ...base, kind: "self", lane }]
+ }
const endpointKey = `${transition.from}\u0000${transition.to}`
const parallelIndex = endpointOccurrences.get(endpointKey) ?? 0
endpointOccurrences.set(endpointKey, parallelIndex + 1)
@@ -449,7 +491,8 @@ export function createStateTransitionRoutePlans(
if (verticalCorridorCrossesUnrelatedState(diagram, transition, from, to, bounds)) {
return [sideParallel()]
}
- if (from.centerY > to.centerY) {
+ const verticalFeedback = diagram.direction === "BT" ? from.centerY < to.centerY : from.centerY > to.centerY
+ if (verticalFeedback) {
return [sideParallel()]
}
if (from.centerY === to.centerY) {
@@ -612,33 +655,36 @@ function addHorizontalForward(builder: StateTransitionRenderBuilder): void {
}
function addSelfTransition(builder: StateTransitionRenderBuilder): void {
- const { from: bounds, transition } = builder.route
+ const { from: bounds, transition, lane } = builder.route as Extract
if (bounds.width <= 1 || bounds.height <= 1) {
- const railX = bounds.left + 4
- const railY = bounds.top + 2
+ const railX = bounds.left + 4 + lane * 4
+ const railY = bounds.top + 2 + lane * 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: "│" })
+ addVerticalLine(builder, railX, bounds.top + 1, railY - 1, 1)
addCell(builder, { x: railX, y: railY, char: "╯" })
addHorizontalLine(builder, railX - 1, bounds.left + 1, railY, -1)
addCell(builder, { x: bounds.left, y: railY, char: "╰" })
+ for (let y = railY - 1; y > bounds.top + 1; y--) addCell(builder, { x: bounds.left, y, 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)
+ if (transition.label) addLabel(builder, railX + 2, lane === 0 ? bounds.top + 1 : railY - 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
- const targetX = Math.max(sourceX + 3, bounds.left + Math.min(bounds.width - 3, Math.ceil((bounds.width * 2) / 3)))
+ const railY = bottomY + 2 + lane * 3
+ const targetX =
+ Math.max(sourceX + 3, bounds.left + Math.min(bounds.width - 3, Math.ceil((bounds.width * 2) / 3))) + lane * 4
addBottomDeparture(builder, bounds, sourceX)
- addCell(builder, { x: sourceX, y: bottomY + 1, char: "│" })
+ addVerticalLine(builder, sourceX, bottomY + 1, railY - 1, 1)
addCell(builder, { x: sourceX, y: railY, char: "╰" })
for (let x = sourceX + 1; x < targetX; x++) addCell(builder, { x, y: railY, char: "─" })
addCell(builder, { x: targetX, y: railY, char: "╯" })
+ for (let y = railY - 1; y > bottomY + 1; y--) addCell(builder, { x: targetX, y, char: "│" })
addCell(builder, { x: targetX, y: bottomY + 1, arrowDirection: "up" })
- if (transition.label) addLabel(builder, targetX + 2, bottomY + 1, transition.label)
+ if (transition.label) addLabel(builder, targetX + 2, lane === 0 ? bottomY + 1 : railY - 1, transition.label)
}
function outsideBottomY(bounds: BoxBounds): number {
@@ -732,10 +778,8 @@ function addTopFeedbackTransition(builder: StateTransitionRenderBuilder): void {
}
function addSideParallelTransition(builder: StateTransitionRenderBuilder): void {
- const { from, to, targetIsChoice, targetIsHiddenMarker, transition, railX, targetApproach } = builder.route as Extract<
- StateTransitionRoutePlan,
- { kind: "side-parallel" }
- >
+ const { from, to, targetIsChoice, targetIsHiddenMarker, transition, railX, targetApproach } =
+ builder.route as Extract
const startX = from.left + from.width
const startY = from.centerY
const endY = targetApproach === "top" ? to.top - 2 : targetApproach === "bottom" ? to.top + to.height + 1 : to.centerY
@@ -757,7 +801,12 @@ function addSideParallelTransition(builder: StateTransitionRenderBuilder): void
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)
+ addLabel(
+ builder,
+ railX + 2,
+ Math.max(0, Math.floor((startY + to.centerY - metrics.height + 1) / 2)),
+ transition.label,
+ )
}
return
}
@@ -938,31 +987,62 @@ function routeIntersectsUnrelatedState(
"boundary",
stateDiagramNoteConnector(noteBound, target).points,
)
- return plan.path.some(([x, y]) =>
- connector.spans.some((span) => span.y === y && x >= span.fromX && x <= span.toX),
- )
+ 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,
+ diagram: StateVisibleDiagram,
bounds: ReadonlyMap,
plan: StateTransitionRenderPlan,
search: StateSearchSpace,
budget: StateSearchBudget,
): StateTransitionPathPoint[] | undefined {
const margin = Math.max(8, bounds.size * 2)
+ const compositeId = innermostCommonComposite(diagram, plan.route.transition)
+ const composite = compositeId ? bounds.get(compositeId) : undefined
+ const searchBounds = {
+ minX: composite ? composite.left + 1 : search.minX - margin,
+ minY: composite ? composite.top + 1 : Math.min(search.minY, ...plan.path.map((point) => point[1])) - margin,
+ maxX: composite
+ ? composite.left + composite.width - 2
+ : Math.max(search.maxX, ...plan.path.map((point) => point[0])) + margin,
+ maxY: composite
+ ? composite.top + composite.height - 2
+ : Math.max(search.maxY, ...plan.path.map((point) => point[1])) + margin,
+ }
+ const isFree = ([x, y]: StateTransitionPathPoint) =>
+ x >= searchBounds.minX &&
+ x <= searchBounds.maxX &&
+ y >= searchBounds.minY &&
+ y <= searchBounds.maxY &&
+ !search.blocked.has(`${x}:${y}`)
+ const pathMinX = Math.min(...plan.path.map((point) => point[0]))
+ const pathMinY = Math.min(...plan.path.map((point) => point[1]))
+ const pathMaxX = Math.max(...plan.path.map((point) => point[0]))
+ const pathMaxY = Math.max(...plan.path.map((point) => point[1]))
+ const directCandidates: StateTransitionPathPoint[][] = [
+ [start, [start[0], end[1]] as const, end],
+ [start, [end[0], start[1]] as const, end],
+ ...Array.from({ length: 4 }, (_, index) => index + 1).flatMap((offset): StateTransitionPathPoint[][] => [
+ [start, [start[0], pathMinY - offset], [end[0], pathMinY - offset], end],
+ [start, [start[0], pathMaxY + offset], [end[0], pathMaxY + offset], end],
+ [start, [pathMinX - offset, start[1]], [pathMinX - offset, end[1]], end],
+ [start, [pathMaxX + offset, start[1]], [pathMaxX + offset, end[1]], end],
+ ]),
+ ]
+ const direct = directCandidates
+ .map((points) => orthogonalPathPoints(points.map(([x, y]) => ({ x, y }))).map(({ x, y }) => [x, y] as const))
+ .filter((points) => points.every(isFree))
+ .sort((left, right) => left.length - right.length)[0]
+ if (direct) return direct
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,
- },
+ searchBounds,
budget,
)
return path?.map((point) => [point.x, point.y] as const)
@@ -975,17 +1055,29 @@ function bodySafeTransitionPlan(
noteBounds: readonly StateDiagramNoteBounds[],
search: StateSearchSpace,
budget: StateSearchBudget,
+ forceRepair = false,
): StateTransitionRenderPlan {
- if (!routeIntersectsUnrelatedState(plan, diagram, bounds, noteBounds)) return plan
+ if (!forceRepair && !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)
+ const safePath = findBodySafePath(
+ plan.path[sourceOutsideIndex]!,
+ plan.path[targetOutsideIndex]!,
+ diagram,
+ 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)
+ const repaired = renderBodySafeTransitionPlan(plan, safePath, prefix, suffix)
+ if (safePath.length <= plan.path.length + 4) return repaired
+ const alternate = alternateBodySafeTransitionPlan(plan, diagram, bounds, noteBounds, search, budget)
+ return alternate !== plan && alternate.path.length < repaired.path.length ? alternate : repaired
}
function alternateBodySafeTransitionPlan(
@@ -996,9 +1088,10 @@ function alternateBodySafeTransitionPlan(
search: StateSearchSpace,
budget: StateSearchBudget,
): StateTransitionRenderPlan {
+ const candidates: 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)
+ const safePath = findBodySafePath(source.outside, target.outside, diagram, bounds, plan, search, budget)
if (!safePath) continue
const prefix = plan.route.from.width > 1 && plan.route.from.height > 1 ? [source.border] : []
const suffix =
@@ -1006,10 +1099,10 @@ function alternateBodySafeTransitionPlan(
? ([[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
+ if (!routeIntersectsUnrelatedState(repaired, diagram, bounds, noteBounds)) candidates.push(repaired)
}
}
- return plan
+ return candidates.sort((left, right) => left.path.length - right.path.length)[0] ?? plan
}
function stateRoutePorts(bounds: BoxBounds): Array<{
@@ -1072,7 +1165,50 @@ function renderBodySafeTransitionPlan(
cells.push({ x: point[0], y: point[1], char: diagramLineGlyph(connections, "rounded") })
}
- return { ...plan, cells, path: fullPath }
+ return { ...plan, cells, path: fullPath, pathRepaired: true }
+}
+
+function labelDistanceToPath(
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ path: readonly StateTransitionPathPoint[],
+): number {
+ return Math.min(
+ ...path.map(([pathX, pathY]) => {
+ const dx = pathX < x ? x - pathX : pathX >= x + width ? pathX - (x + width - 1) : 0
+ const dy = pathY < y ? y - pathY : pathY >= y + height ? pathY - (y + height - 1) : 0
+ return dx + dy
+ }),
+ )
+}
+
+function stateTransitionLabelCandidates(
+ plan: StateTransitionRenderPlan,
+ width: number,
+ height: number,
+): Array<{ x: number; y: number }> {
+ const candidates = new Map()
+ const add = (x: number, y: number) => candidates.set(`${x}:${y}`, { x, y })
+ if (
+ plan.label &&
+ (!plan.pathRepaired || labelDistanceToPath(plan.label.x, plan.label.y, width, height, plan.path) <= 4)
+ ) {
+ add(plan.label.x, plan.label.y)
+ }
+ for (const [x, y] of plan.path) {
+ add(x + 2, y - Math.floor(height / 2))
+ add(x - width - 2, y - Math.floor(height / 2))
+ add(x - Math.floor(width / 2), y - height - 1)
+ add(x - Math.floor(width / 2), y + 2)
+ }
+ const preferred = plan.label ?? { x: plan.path[0]?.[0] ?? 0, y: plan.path[0]?.[1] ?? 0 }
+ return [...candidates.values()].sort((left, right) => {
+ const leftDistance = Math.abs(left.x - preferred.x) + Math.abs(left.y - preferred.y)
+ const rightDistance = Math.abs(right.x - preferred.x) + Math.abs(right.y - preferred.y)
+ return leftDistance - rightDistance
+ })
}
function placeStateTransitionLabels(
@@ -1096,6 +1232,19 @@ function placeStateTransitionLabels(
plan.path.map(([x, y]) => ({ x, y })),
),
),
+ ...diagram.composites.flatMap((composite) => {
+ const bound = bounds.get(composite.id)
+ if (!bound) return []
+ return [
+ spatialPathClaim(`composite:${composite.id}`, `composite:${composite.id}`, "boundary", [
+ { x: bound.left, y: bound.top },
+ { x: bound.left + bound.width - 1, y: bound.top },
+ { x: bound.left + bound.width - 1, y: bound.top + bound.height - 1 },
+ { x: bound.left, y: bound.top + bound.height - 1 },
+ { x: bound.left, y: bound.top },
+ ]),
+ ]
+ }),
...noteBounds.flatMap((noteBound) => {
const target = bounds.get(noteBound.note.target)
return [
@@ -1114,10 +1263,25 @@ function placeStateTransitionLabels(
}),
)
- return plans.map((plan, planIndex) => {
- if (!plan.label) return plan
+ const placed = new Map()
+ const endpointCounts = new Map()
+ for (const plan of plans) {
+ const key = `${plan.route.transition.from}\u0000${plan.route.transition.to}`
+ endpointCounts.set(key, (endpointCounts.get(key) ?? 0) + 1)
+ }
+ const placementOrder = [...plans.keys()].sort(
+ (left, right) => Number(Boolean(plans[left]!.pathRepaired)) - Number(Boolean(plans[right]!.pathRepaired)),
+ )
+ for (const planIndex of placementOrder) {
+ const plan = plans[planIndex]!
+ if (!plan.label) {
+ placed.set(planIndex, plan)
+ continue
+ }
const width = Math.max(...plan.label.lines.map(diagramTextWidth))
- const statePadding = plan.label.lines.length === 1 ? 0 : 1
+ const endpointKey = `${plan.route.transition.from}\u0000${plan.route.transition.to}`
+ const needsLaneClearance = (endpointCounts.get(endpointKey) ?? 0) > 1
+ const statePadding = needsLaneClearance || plan.label.lines.length > 1 ? 1 : 0
const labelClaim = (x: number, y: number) =>
spatialRectClaim(`label:${planIndex}`, `label:${planIndex}`, "label", {
left: x,
@@ -1131,19 +1295,35 @@ function placeStateTransitionLabels(
clearance: {
body: statePadding,
label: { x: 1, y: 0 },
+ route:
+ plan.pathRepaired || plan.route.kind === "side-parallel" || needsLaneClearance
+ ? {
+ x: 1,
+ y: 0,
+ }
+ : 0,
},
})
}
- let x = plan.label.x
- let y = plan.label.y
- if (!isClear(x, y)) {
+ const candidates = stateTransitionLabelCandidates(plan, width, plan.label.lines.length)
+ const nearby = candidates.find(
+ (candidate) =>
+ (!(plan.pathRepaired || plan.route.kind === "side-parallel" || needsLaneClearance) ||
+ labelDistanceToPath(candidate.x, candidate.y, width, plan.label!.lines.length, plan.path) >= 2) &&
+ isClear(candidate.x, candidate.y),
+ )
+ let x = nearby?.x ?? candidates[0]?.x ?? plan.label.x
+ let y = nearby?.y ?? candidates[0]?.y ?? plan.label.y
+ if (!nearby) {
search: for (let distance = 1; distance < 500; distance++) {
for (let dx = -distance; dx <= distance; dx++) {
const dy = distance - Math.abs(dx)
for (const candidateY of dy === 0 ? [y] : [y - dy, y + dy]) {
const candidateX = x + dx
if (!isClear(candidateX, candidateY)) continue
+ const pathDistance = labelDistanceToPath(candidateX, candidateY, width, plan.label!.lines.length, plan.path)
+ if (pathDistance < 2 || pathDistance > 8) continue
x = candidateX
y = candidateY
break search
@@ -1151,10 +1331,26 @@ function placeStateTransitionLabels(
}
}
}
+ if (!isClear(x, y)) {
+ fallback: for (let distance = 1; distance < 500; distance++) {
+ for (let dx = -distance; dx <= distance; dx++) {
+ const dy = distance - Math.abs(dx)
+ for (const candidateY of dy === 0 ? [y] : [y - dy, y + dy]) {
+ const candidateX = x + dx
+ if (!isClear(candidateX, candidateY)) continue
+ x = candidateX
+ y = candidateY
+ break fallback
+ }
+ }
+ }
+ }
+ if (!isClear(x, y)) throw new Error(`Transition ${endpointKey} has no clear label position`)
space = space.add(labelClaim(x, y))
- return { ...plan, label: { ...plan.label, x, y } }
- })
+ placed.set(planIndex, { ...plan, label: { ...plan.label, x, y } })
+ }
+ return plans.map((plan, index) => placed.get(index) ?? plan)
}
export function createStateTransitionRenderPlans(
@@ -1169,15 +1365,59 @@ export function createStateTransitionRenderPlans(
createStateTransitionRenderPlan,
)
if (options.repairRoutes === false) return placeStateTransitionLabels(plans, diagram, bounds, noteBounds)
- const routeSpace = createStateSearchSpace(transitionObstacles(diagram, bounds, noteBounds))
- return placeStateTransitionLabels(
- plans.map((plan) => bodySafeTransitionPlan(plan, diagram, bounds, noteBounds, routeSpace, budget)),
- diagram,
- bounds,
- noteBounds,
+ const baseObstacles = transitionObstacles(diagram, bounds, noteBounds)
+ const repaired: StateTransitionRenderPlan[] = []
+ for (const [index, plan] of plans.entries()) {
+ const disjoint = repaired.filter((previous) => transitionsHaveDisjointEndpoints(previous, plan))
+ const routeObstacles = repaired.filter(
+ (previous) => disjoint.includes(previous) || transitionsAreReciprocal(previous, plan),
+ )
+ const routeSpace = createStateSearchSpace(
+ baseObstacles.add(
+ ...routeObstacles.map((previous, previousIndex) =>
+ spatialPathClaim(
+ `transition:${index}:obstacle:${previousIndex}`,
+ `transition:${index}:obstacle:${previousIndex}`,
+ "route",
+ previous.path.map(([x, y]) => ({ x, y })),
+ ),
+ ),
+ ),
+ )
+ repaired.push(
+ bodySafeTransitionPlan(
+ plan,
+ diagram,
+ bounds,
+ noteBounds,
+ routeSpace,
+ budget,
+ disjoint.some((previous) => pathsIntersect(previous.path, plan.path)),
+ ),
+ )
+ }
+ return placeStateTransitionLabels(repaired, diagram, bounds, noteBounds)
+}
+
+function transitionsHaveDisjointEndpoints(left: StateTransitionRenderPlan, right: StateTransitionRenderPlan): boolean {
+ const leftEndpoints = new Set([left.route.transition.from, left.route.transition.to])
+ return !leftEndpoints.has(right.route.transition.from) && !leftEndpoints.has(right.route.transition.to)
+}
+
+function transitionsAreReciprocal(left: StateTransitionRenderPlan, right: StateTransitionRenderPlan): boolean {
+ return (
+ left.route.transition.from === right.route.transition.to && left.route.transition.to === right.route.transition.from
)
}
+function pathsIntersect(
+ left: readonly StateTransitionPathPoint[],
+ right: readonly StateTransitionPathPoint[],
+): boolean {
+ const occupied = new Set(left.map(([x, y]) => `${x}:${y}`))
+ return right.some(([x, y]) => occupied.has(`${x}:${y}`))
+}
+
function transitionObstacles(
diagram: StateVisibleDiagram,
bounds: ReadonlyMap,
diff --git a/packages/merman/src/state/types.ts b/packages/merman/src/state/types.ts
index baca23aaff0..1cf3658abd7 100644
--- a/packages/merman/src/state/types.ts
+++ b/packages/merman/src/state/types.ts
@@ -1,6 +1,6 @@
import type { BorderStyle } from "@opentui/core"
-export type StateDiagramDirection = "TB" | "TD" | "LR" | "RL"
+export type StateDiagramDirection = "TB" | "TD" | "BT" | "LR" | "RL"
export type StateDiagramArrowHeadStyle = "filled" | "line"
export interface StateDiagramState {
@@ -41,6 +41,8 @@ export interface StateDiagramRenderOptions {
borderStyle?: BorderStyle
arrowHeadStyle?: StateDiagramArrowHeadStyle
minStateGap?: number
+ /** Target rendered width. Oversized horizontal layouts fold vertically. */
+ layoutMaxWidth?: number
}
export type NoteConnectorRampStyle = `noteConnectorRamp${1 | 2 | 3}`
diff --git a/packages/merman/src/test/layout-audit/fixtures.ts b/packages/merman/src/test/layout-audit/fixtures.ts
new file mode 100644
index 00000000000..d1638415214
--- /dev/null
+++ b/packages/merman/src/test/layout-audit/fixtures.ts
@@ -0,0 +1,493 @@
+import type { FlowchartDirection } from "../../flowchart/types.js"
+import type { StateDiagramDirection } from "../../state/types.js"
+
+export type LayoutFixture = {
+ id: string
+ kind: "flowchart" | "state"
+ family: string
+ profile: LabelProfile
+ source: string
+ curated?: boolean
+}
+
+type LabelProfile = "short" | "long" | "unicode"
+
+const flowVariants = [
+ ["TB", "short"],
+ ["TD", "long"],
+ ["BT", "unicode"],
+ ["LR", "short"],
+ ["RL", "long"],
+ ["TB", "unicode"],
+ ["TD", "short"],
+ ["BT", "long"],
+ ["LR", "unicode"],
+ ["RL", "short"],
+ ["LR", "long"],
+ ["TD", "unicode"],
+ ["TB", "long"],
+ ["BT", "short"],
+ ["RL", "unicode"],
+] as const satisfies readonly (readonly [FlowchartDirection, LabelProfile])[]
+
+const stateVariants = [
+ ["TB", "short"],
+ ["TD", "long"],
+ ["LR", "unicode"],
+ ["RL", "short"],
+ ["TB", "long"],
+ ["TD", "unicode"],
+ ["LR", "short"],
+ ["RL", "long"],
+ ["LR", "long"],
+ ["TD", "short"],
+ ["TB", "unicode"],
+ ["RL", "unicode"],
+ ["BT", "short"],
+ ["BT", "long"],
+ ["BT", "unicode"],
+] as const satisfies readonly (readonly [StateDiagramDirection, LabelProfile])[]
+
+function nodeLabel(id: string, profile: LabelProfile): string {
+ if (profile === "long") return `${id} deliberate deployment stage with a long descriptive label`
+ if (profile === "unicode") return `${id} 東京
résumé 🚀`
+ return `${id} node`
+}
+
+function edgeLabel(id: string, profile: LabelProfile): string {
+ if (profile === "long") return `${id} transition carrying detailed deployment context`
+ if (profile === "unicode") return `${id} 東京
✓ prêt`
+ return `${id} edge`
+}
+
+function flowNode(id: string, profile: LabelProfile): string {
+ return ` ${id}["${nodeLabel(id, profile)}"]`
+}
+
+function flowEdge(from: string, to: string, id: string, profile: LabelProfile): string {
+ return ` ${from} -->|"${edgeLabel(id, profile)}"| ${to}`
+}
+
+function flowSource(
+ direction: FlowchartDirection,
+ profile: LabelProfile,
+ nodes: readonly string[],
+ edges: readonly [from: string, to: string, id: string][],
+ extra: readonly string[] = [],
+): string {
+ return [
+ `flowchart ${direction}`,
+ ...nodes.map((id) => flowNode(id, profile)),
+ ...extra,
+ ...edges.map(([from, to, id]) => flowEdge(from, to, id, profile)),
+ ].join("\n")
+}
+
+const flowFamilies = {
+ chain(direction: FlowchartDirection, profile: LabelProfile) {
+ return flowSource(
+ direction,
+ profile,
+ ["A", "B", "C", "D", "E", "F"],
+ [
+ ["A", "B", "E01"],
+ ["B", "C", "E02"],
+ ["C", "D", "E03"],
+ ["D", "E", "E04"],
+ ["E", "F", "E05"],
+ ],
+ )
+ },
+ fork(direction: FlowchartDirection, profile: LabelProfile) {
+ return flowSource(
+ direction,
+ profile,
+ ["A", "B", "C", "D", "E"],
+ [
+ ["A", "B", "E01"],
+ ["A", "C", "E02"],
+ ["A", "D", "E03"],
+ ["A", "E", "E04"],
+ ],
+ )
+ },
+ join(direction: FlowchartDirection, profile: LabelProfile) {
+ return flowSource(
+ direction,
+ profile,
+ ["A", "B", "C", "D", "E"],
+ [
+ ["A", "E", "E01"],
+ ["B", "E", "E02"],
+ ["C", "E", "E03"],
+ ["D", "E", "E04"],
+ ],
+ )
+ },
+ cycle(direction: FlowchartDirection, profile: LabelProfile) {
+ return flowSource(
+ direction,
+ profile,
+ ["A", "B", "C", "D"],
+ [
+ ["A", "B", "E01"],
+ ["B", "C", "E02"],
+ ["C", "D", "E03"],
+ ["D", "A", "E04"],
+ ["C", "A", "E05"],
+ ],
+ )
+ },
+ crossing(direction: FlowchartDirection, profile: LabelProfile) {
+ return flowSource(
+ direction,
+ profile,
+ ["A", "B", "C", "D", "E", "F"],
+ [
+ ["A", "C", "E01"],
+ ["A", "D", "E02"],
+ ["B", "C", "E03"],
+ ["B", "D", "E04"],
+ ["C", "E", "E05"],
+ ["D", "F", "E06"],
+ ],
+ )
+ },
+ parallel(direction: FlowchartDirection, profile: LabelProfile) {
+ return flowSource(
+ direction,
+ profile,
+ ["A", "B", "C"],
+ [
+ ["A", "B", "E01"],
+ ["A", "B", "E02"],
+ ["A", "B", "E03"],
+ ["B", "C", "E04"],
+ ["B", "C", "E05"],
+ ],
+ )
+ },
+ self(direction: FlowchartDirection, profile: LabelProfile) {
+ return flowSource(
+ direction,
+ profile,
+ ["A", "B", "C"],
+ [
+ ["A", "A", "E01"],
+ ["A", "B", "E02"],
+ ["B", "B", "E03"],
+ ["B", "C", "E04"],
+ ],
+ )
+ },
+ subgraph(direction: FlowchartDirection, profile: LabelProfile) {
+ return [
+ `flowchart ${direction}`,
+ ` subgraph Left["Left ${nodeLabel("SG1", profile)}"]`,
+ flowNode("A", profile),
+ flowNode("B", profile),
+ " end",
+ ` subgraph Right["Right ${nodeLabel("SG2", profile)}"]`,
+ flowNode("C", profile),
+ flowNode("D", profile),
+ " end",
+ flowEdge("A", "B", "E01", profile),
+ flowEdge("A", "C", "E02", profile),
+ flowEdge("B", "D", "E03", profile),
+ flowEdge("C", "D", "E04", profile),
+ ].join("\n")
+ },
+ "nested-subgraph"(direction: FlowchartDirection, profile: LabelProfile) {
+ const local = direction === "LR" || direction === "RL" ? "TB" : "LR"
+ return [
+ `flowchart ${direction}`,
+ ` subgraph Outer["Outer ${nodeLabel("SG1", profile)}"]`,
+ ` direction ${local}`,
+ ` subgraph Inner["Inner ${nodeLabel("SG2", profile)}"]`,
+ flowNode("A", profile),
+ flowNode("B", profile),
+ " end",
+ flowNode("C", profile),
+ " end",
+ flowNode("D", profile),
+ flowEdge("A", "B", "E01", profile),
+ flowEdge("A", "C", "E02", profile),
+ flowEdge("B", "D", "E03", profile),
+ flowEdge("C", "D", "E04", profile),
+ ].join("\n")
+ },
+} satisfies Record string>
+
+function stateDeclaration(id: string, profile: LabelProfile, indent = " "): string {
+ return `${indent}state "${nodeLabel(id, profile)}" as ${id}`
+}
+
+function stateTransition(from: string, to: string, id: string, profile: LabelProfile, indent = " "): string {
+ return `${indent}${from} --> ${to}: ${edgeLabel(id, profile)}`
+}
+
+function stateSource(
+ direction: StateDiagramDirection,
+ profile: LabelProfile,
+ states: readonly string[],
+ transitions: readonly [from: string, to: string, id: string][],
+ extra: readonly string[] = [],
+): string {
+ return [
+ "stateDiagram-v2",
+ ` direction ${direction}`,
+ ...states.map((id) => stateDeclaration(id, profile)),
+ ...extra,
+ ...transitions.map(([from, to, id]) => stateTransition(from, to, id, profile)),
+ ].join("\n")
+}
+
+const stateFamilies = {
+ chain(direction: StateDiagramDirection, profile: LabelProfile) {
+ return stateSource(
+ direction,
+ profile,
+ ["A", "B", "C", "D", "E"],
+ [
+ ["A", "B", "E01"],
+ ["B", "C", "E02"],
+ ["C", "D", "E03"],
+ ["D", "E", "E04"],
+ ],
+ [" [*] --> A", " E --> [*]"],
+ )
+ },
+ fork(direction: StateDiagramDirection, profile: LabelProfile) {
+ return stateSource(
+ direction,
+ profile,
+ ["A", "B", "C", "D"],
+ [
+ ["A", "B", "E01"],
+ ["A", "C", "E02"],
+ ["A", "D", "E03"],
+ ],
+ )
+ },
+ join(direction: StateDiagramDirection, profile: LabelProfile) {
+ return stateSource(
+ direction,
+ profile,
+ ["A", "B", "C", "D"],
+ [
+ ["A", "D", "E01"],
+ ["B", "D", "E02"],
+ ["C", "D", "E03"],
+ ],
+ )
+ },
+ cycle(direction: StateDiagramDirection, profile: LabelProfile) {
+ return stateSource(
+ direction,
+ profile,
+ ["A", "B", "C", "D"],
+ [
+ ["A", "B", "E01"],
+ ["B", "C", "E02"],
+ ["C", "D", "E03"],
+ ["D", "A", "E04"],
+ ["C", "A", "E05"],
+ ],
+ )
+ },
+ crossing(direction: StateDiagramDirection, profile: LabelProfile) {
+ return stateSource(
+ direction,
+ profile,
+ ["A", "B", "C", "D"],
+ [
+ ["A", "C", "E01"],
+ ["A", "D", "E02"],
+ ["B", "C", "E03"],
+ ["B", "D", "E04"],
+ ],
+ )
+ },
+ parallel(direction: StateDiagramDirection, profile: LabelProfile) {
+ return stateSource(
+ direction,
+ profile,
+ ["A", "B", "C"],
+ [
+ ["A", "B", "E01"],
+ ["A", "B", "E02"],
+ ["A", "B", "E03"],
+ ["B", "C", "E04"],
+ ],
+ )
+ },
+ self(direction: StateDiagramDirection, profile: LabelProfile) {
+ return stateSource(
+ direction,
+ profile,
+ ["A", "B", "C"],
+ [
+ ["A", "A", "E01"],
+ ["A", "B", "E02"],
+ ["B", "B", "E03"],
+ ["B", "C", "E04"],
+ ],
+ )
+ },
+ choice(direction: StateDiagramDirection, profile: LabelProfile) {
+ return stateSource(
+ direction,
+ profile,
+ ["A", "B", "C"],
+ [
+ ["A", "Choice", "E01"],
+ ["Choice", "B", "E02"],
+ ["Choice", "C", "E03"],
+ ["C", "A", "E04"],
+ ],
+ [" state Choice <>"],
+ )
+ },
+ notes(direction: StateDiagramDirection, profile: LabelProfile) {
+ return stateSource(
+ direction,
+ profile,
+ ["A", "B", "C"],
+ [
+ ["A", "B", "E01"],
+ ["B", "C", "E02"],
+ ["C", "A", "E03"],
+ ],
+ [
+ ` note left of A: ${edgeLabel("N01", profile)}`,
+ ` note right of B: ${edgeLabel("N02", profile)}`,
+ ` note right of C: ${edgeLabel("N03", profile)}`,
+ ],
+ )
+ },
+ composite(direction: StateDiagramDirection, profile: LabelProfile) {
+ return [
+ "stateDiagram-v2",
+ ` direction ${direction}`,
+ ` state "${nodeLabel("Outer", profile)}" as Outer {`,
+ stateDeclaration("A", profile, " "),
+ stateDeclaration("B", profile, " "),
+ " [*] --> A",
+ stateTransition("A", "B", "E01", profile, " "),
+ " B --> [*]",
+ " }",
+ stateDeclaration("Done", profile),
+ stateTransition("Outer", "Done", "E02", profile),
+ ` note right of B: ${edgeLabel("N01", profile)}`,
+ ].join("\n")
+ },
+ "nested-composite"(direction: StateDiagramDirection, profile: LabelProfile) {
+ return [
+ "stateDiagram-v2",
+ ` direction ${direction}`,
+ ` state "${nodeLabel("Session", profile)}" as Session {`,
+ " [*] --> Open",
+ ` state "${nodeLabel("Open", profile)}" as Open {`,
+ stateDeclaration("Clean", profile, " "),
+ stateDeclaration("Dirty", profile, " "),
+ " [*] --> Clean",
+ stateTransition("Clean", "Dirty", "E01", profile, " "),
+ stateTransition("Dirty", "Clean", "E02", profile, " "),
+ " Dirty --> [*]",
+ " }",
+ " Open --> [*]",
+ " }",
+ stateDeclaration("Done", profile),
+ stateTransition("Session", "Done", "E03", profile),
+ ` note right of Dirty: ${edgeLabel("N01", profile)}`,
+ ].join("\n")
+ },
+} satisfies Record string>
+
+export const deploymentArchitectureSource = `flowchart LR
+ Client[OpenCode client]
+
+ subgraph CF[Cloudflare]
+ DNS[opencode.ai]
+ Web[Console frontend Worker]
+ Proxy[Console API proxy Worker]
+ Infer[inference-next Worker]
+ KV[Model registry KV]
+ Redis[Upstash Redis]
+ Logs[Axiom / Cloudflare logs]
+ Lake[Pipeline to R2 data lake]
+ end
+
+ subgraph AWS[AWS]
+ EKS[EKS cluster]
+ API[Console API pod
1 replica]
+ OTEL[OTel collector]
+ ECR[ECR]
+ end
+
+ DB[(PlanetScale)]
+ Models[Anthropic / OpenAI / other providers]
+
+ Client -->|/inference/*| DNS --> Infer
+ Client -->|/console/*| DNS --> Web
+ Web -->|/console/api, /auth, etc.| Proxy
+ Proxy -->|Cloudflare VPC service| API
+
+ Infer -->|public DATABASE_URL| DB
+ Infer --> KV
+ Infer --> Redis
+ Infer --> Models
+ Infer --> Logs
+ Infer --> Lake
+
+ API -->|private DATABASE_AWS_URL| DB
+ API --> OTEL
+ ECR --> API`
+
+export function layoutFixtures(): readonly LayoutFixture[] {
+ const flowcharts = Object.entries(flowFamilies).flatMap(([family, source]) =>
+ flowVariants.map(([direction, profile]) => ({
+ id: `flowchart/${family}/${direction.toLowerCase()}-${profile}`,
+ kind: "flowchart" as const,
+ family,
+ profile,
+ source: source(direction, profile),
+ })),
+ )
+ const states = Object.entries(stateFamilies).flatMap(([family, source]) =>
+ stateVariants.map(([direction, profile]) => ({
+ id: `state/${family}/${direction.toLowerCase()}-${profile}`,
+ kind: "state" as const,
+ family,
+ profile,
+ source: source(direction, profile),
+ })),
+ )
+ return [
+ ...flowcharts,
+ {
+ id: "flowchart/deployment-architecture/curated",
+ kind: "flowchart" as const,
+ family: "deployment-architecture",
+ profile: "short" as const,
+ source: deploymentArchitectureSource,
+ curated: true,
+ },
+ {
+ id: "flowchart/grouped-fanout/curated",
+ kind: "flowchart" as const,
+ family: "grouped-fanout",
+ profile: "short" as const,
+ source: `flowchart TD
+ subgraph Group
+ S[Source]
+ S -->|route 0 detail| N0[Node 0]
+ S -->|route 1 detail| N1[Node 1]
+ S -->|route 2 detail| N2[Node 2]
+ S -->|route 3 detail| N3[Node 3]
+ end`,
+ curated: true,
+ },
+ ...states,
+ ]
+}
diff --git a/packages/merman/src/test/layout-audit/harness.ts b/packages/merman/src/test/layout-audit/harness.ts
new file mode 100644
index 00000000000..450ba4237dc
--- /dev/null
+++ b/packages/merman/src/test/layout-audit/harness.ts
@@ -0,0 +1,521 @@
+import { orthogonalPathPoints, segmentBetween, type DiagramPoint } from "../../core/geometry.js"
+import { SpatialIndex, spatialPathClaim, spatialRectClaim } from "../../core/spatial.js"
+import { diagramTextWidth } from "../../core/text.js"
+import { splitDiagramLines } from "../../core/text-lines.js"
+import { drawFlowchartDiagramGrid } from "../../flowchart/drawing.js"
+import { layoutFlowchartDiagram } from "../../flowchart/layout.js"
+import { flowchartRouteLabelLayout } from "../../flowchart/labels.js"
+import { parseMermaidFlowchartDiagram } from "../../flowchart/parser.js"
+import { createStateDiagramDrawing } from "../../state/drawing.js"
+import type { StateDiagramBoxBounds } from "../../state/layout.js"
+import { stateDiagramNoteConnector } from "../../state/note.js"
+import { parseMermaidStateDiagram } from "../../state/parser.js"
+import type { StateTransitionRenderPlan } from "../../state/routing.js"
+import { isHiddenCompositeMarker } from "../../state/visible-model.js"
+import { layoutFixtures, type LayoutFixture } from "./fixtures.js"
+
+export const auditViewports = [60, 80, 120] as const
+
+export type LayoutMetrics = {
+ width: number
+ height: number
+ area: number
+ routeLength: number
+ bends: number
+ crossings: number
+ sharedRouteCells: number
+ overflow: number
+}
+
+export type LayoutAudit = {
+ fixture: LayoutFixture
+ viewport: (typeof auditViewports)[number]
+ output: string
+ metrics: LayoutMetrics
+ violations: string[]
+}
+
+type Bounds = Pick
+type AuditedRoute = {
+ from: string
+ to: string
+ points: readonly DiagramPoint[]
+}
+
+function finiteBounds(bounds: Bounds): boolean {
+ return (
+ [bounds.left, bounds.top, bounds.width, bounds.height].every(Number.isFinite) &&
+ bounds.width > 0 &&
+ bounds.height > 0
+ )
+}
+
+function boundsOverlap(left: Bounds, right: Bounds): boolean {
+ return (
+ left.left < right.left + right.width &&
+ left.left + left.width > right.left &&
+ left.top < right.top + right.height &&
+ left.top + left.height > right.top
+ )
+}
+
+function boundsContain(outer: Bounds, inner: Bounds): boolean {
+ return (
+ inner.left >= outer.left &&
+ inner.top >= outer.top &&
+ inner.left + inner.width <= outer.left + outer.width &&
+ inner.top + inner.height <= outer.top + outer.height
+ )
+}
+
+function pointInBounds(point: DiagramPoint, bounds: Bounds): boolean {
+ return (
+ point.x >= bounds.left &&
+ point.x < bounds.left + bounds.width &&
+ point.y >= bounds.top &&
+ point.y < bounds.top + bounds.height
+ )
+}
+
+function pointTouchesBounds(point: DiagramPoint, bounds: Bounds): boolean {
+ if (pointInBounds(point, bounds)) return true
+ return (
+ ((point.x === bounds.left - 1 || point.x === bounds.left + bounds.width) &&
+ point.y >= bounds.top &&
+ point.y < bounds.top + bounds.height) ||
+ ((point.y === bounds.top - 1 || point.y === bounds.top + bounds.height) &&
+ point.x >= bounds.left &&
+ point.x < bounds.left + bounds.width)
+ )
+}
+
+function isOrthogonal(points: readonly DiagramPoint[]): boolean {
+ return points.every((point, index) => {
+ if (
+ !Number.isFinite(point.x) ||
+ !Number.isFinite(point.y) ||
+ !Number.isInteger(point.x) ||
+ !Number.isInteger(point.y)
+ )
+ return false
+ const previous = points[index - 1]
+ return !previous || previous.x === point.x || previous.y === point.y
+ })
+}
+
+function expandedPath(points: readonly DiagramPoint[]): DiagramPoint[] {
+ if (!isOrthogonal(points)) return []
+ return orthogonalPathPoints(points)
+}
+
+function routeLength(points: readonly DiagramPoint[]): number {
+ return routeSegments(points).reduce((total, segment) => total + segment.length, 0)
+}
+
+function routeBends(points: readonly DiagramPoint[]): number {
+ const directions = points.slice(1).flatMap((point, index) => {
+ const previous = points[index]
+ if (point.x === previous.x && point.y !== previous.y) return ["y"]
+ if (point.y === previous.y && point.x !== previous.x) return ["x"]
+ return []
+ })
+ return directions.slice(1).filter((axis, index) => axis !== directions[index]).length
+}
+
+function routeSegments(points: readonly DiagramPoint[]) {
+ return points.slice(1).flatMap((point, index) => segmentBetween(points[index]!, point) ?? [])
+}
+
+function crossingCount(routes: readonly AuditedRoute[]): number {
+ let count = 0
+ for (const [index, route] of routes.entries()) {
+ for (const other of routes.slice(index + 1)) {
+ for (const segment of routeSegments(route.points)) {
+ for (const otherSegment of routeSegments(other.points)) {
+ if (segment.axis === otherSegment.axis) continue
+ const horizontal = segment.axis === "x" ? segment : otherSegment
+ const vertical = segment.axis === "y" ? segment : otherSegment
+ const x = vertical.from.x
+ const y = horizontal.from.y
+ const horizontalMin = Math.min(horizontal.from.x, horizontal.to.x)
+ const horizontalMax = Math.max(horizontal.from.x, horizontal.to.x)
+ const verticalMin = Math.min(vertical.from.y, vertical.to.y)
+ const verticalMax = Math.max(vertical.from.y, vertical.to.y)
+ if (x <= horizontalMin || x >= horizontalMax || y <= verticalMin || y >= verticalMax) continue
+ count++
+ }
+ }
+ }
+ }
+ return count
+}
+
+function sharedRouteCellCount(routes: readonly AuditedRoute[]): number {
+ let count = 0
+ const cells = routes.map((route) => new Set(expandedPath(route.points).map((point) => `${point.x}:${point.y}`)))
+ for (const [index, routeCells] of cells.entries()) {
+ for (const other of cells.slice(index + 1)) {
+ for (const cell of routeCells) if (other.has(cell)) count++
+ }
+ }
+ return count
+}
+
+function metrics(
+ width: number,
+ height: number,
+ routes: readonly AuditedRoute[],
+ viewport: (typeof auditViewports)[number],
+): LayoutMetrics {
+ return {
+ width,
+ height,
+ area: width * height,
+ routeLength: routes.reduce((total, route) => total + routeLength(route.points), 0),
+ bends: routes.reduce((total, route) => total + routeBends(route.points), 0),
+ crossings: crossingCount(routes),
+ sharedRouteCells: sharedRouteCellCount(routes),
+ overflow: Math.max(0, width - viewport),
+ }
+}
+
+function requireOutputLines(output: string, lines: readonly string[], owner: string, violations: string[]): void {
+ for (const line of lines.map((line) => line.trim()).filter(Boolean)) {
+ if (!output.includes(line)) violations.push(`${owner} content missing: ${JSON.stringify(line)}`)
+ }
+}
+
+function validateRoutes(
+ routes: readonly AuditedRoute[],
+ bounds: ReadonlyMap,
+ bodyIds: readonly string[],
+ violations: string[],
+): void {
+ for (const [index, route] of routes.entries()) {
+ if (route.points.length < 2) {
+ violations.push(`route ${index} ${route.from}->${route.to} is empty`)
+ continue
+ }
+ if (!isOrthogonal(route.points))
+ violations.push(`route ${index} ${route.from}->${route.to} is not finite and orthogonal`)
+ const from = bounds.get(route.from)
+ const to = bounds.get(route.to)
+ if (!from || !to) {
+ violations.push(`route ${index} ${route.from}->${route.to} has a missing endpoint bound`)
+ continue
+ }
+ if (!pointTouchesBounds(route.points[0], from))
+ violations.push(`route ${index} does not touch source ${route.from}`)
+ if (!pointTouchesBounds(route.points.at(-1)!, to))
+ violations.push(`route ${index} does not touch target ${route.to}`)
+ if (!isOrthogonal(route.points)) continue
+ const bodySpace = SpatialIndex.empty().add(
+ ...bodyIds.flatMap((id) => {
+ const bound = bounds.get(id)
+ return id === route.from || id === route.to || !bound
+ ? []
+ : [spatialRectClaim(`body:${id}`, `body:${id}`, "body", bound)]
+ }),
+ )
+ const conflicts = bodySpace.conflicts(spatialPathClaim(`route:${index}`, `route:${index}`, "route", route.points))
+ for (const id of new Set(conflicts.map((conflict) => conflict.existing.owner.slice("body:".length)))) {
+ violations.push(`route ${index} ${route.from}->${route.to} intersects unrelated body ${id}`)
+ }
+ }
+}
+
+function validateBodies(bounds: ReadonlyMap, ids: readonly string[], violations: string[]): void {
+ let occupied = SpatialIndex.empty()
+ for (const id of ids) {
+ const bound = bounds.get(id)
+ if (!bound) {
+ violations.push(`missing body bound ${id}`)
+ continue
+ }
+ if (!finiteBounds(bound)) {
+ violations.push(`body ${id} has invalid bounds`)
+ continue
+ }
+ const claim = spatialRectClaim(`body:${id}`, `body:${id}`, "body", bound)
+ for (const otherId of new Set(
+ occupied.conflicts(claim).map((conflict) => conflict.existing.owner.slice("body:".length)),
+ )) {
+ violations.push(`bodies ${id} and ${otherId} overlap`)
+ }
+ occupied = occupied.add(claim)
+ }
+}
+
+function auditFlowchart(fixture: LayoutFixture, viewport: (typeof auditViewports)[number]): LayoutAudit {
+ const violations: string[] = []
+ const diagram = parseMermaidFlowchartDiagram(fixture.source)
+ const layout = layoutFlowchartDiagram(diagram, { compact: true, layoutMaxWidth: viewport })
+ const grid = drawFlowchartDiagramGrid(diagram, { compact: true, layoutMaxWidth: viewport })
+ const output = grid.toString({ trimTop: true, trimBottom: true })
+ const size = grid.getTextSize({ trimTop: true, trimBottom: true })
+ const routes = layout.routes.map((route) => ({ from: route.edge.from, to: route.edge.to, points: route.points }))
+ const bodyIds = layout.diagram.nodes.map((node) => node.id)
+
+ if (!Number.isFinite(size.width) || !Number.isFinite(size.height) || size.width <= 0 || size.height <= 0)
+ violations.push("rendered grid has invalid dimensions")
+ if (!Number.isFinite(layout.width) || !Number.isFinite(layout.height) || layout.width <= 0 || layout.height <= 0)
+ violations.push("layout has invalid dimensions")
+ if (layout.routes.length !== layout.diagram.edges.filter((edge) => !edge.orderOnly).length)
+ violations.push("rendered route count does not match visible edge count")
+ validateBodies(layout.bounds, bodyIds, violations)
+ validateRoutes(routes, layout.bounds, bodyIds, violations)
+
+ for (const node of layout.diagram.nodes)
+ requireOutputLines(output, layout.bounds.get(node.id)?.lines ?? [], `node ${node.id}`, violations)
+ for (const route of layout.routes) {
+ if (!route.edge.label) continue
+ requireOutputLines(
+ output,
+ flowchartRouteLabelLayout(route, diagramTextWidth).lines,
+ `edge ${route.edge.from}->${route.edge.to}`,
+ violations,
+ )
+ const targets = new Set(
+ layout.diagram.edges.filter((edge) => edge.label && edge.from === route.edge.from).map((edge) => edge.to),
+ )
+ const sources = new Set(
+ layout.diagram.edges.filter((edge) => edge.label && edge.to === route.edge.to).map((edge) => edge.from),
+ )
+ const label = flowchartRouteLabelLayout(route, diagramTextWidth)
+ if (
+ fixture.family === "grouped-fanout" &&
+ (targets.size > 1 || sources.size > 1) &&
+ label.point.x + label.width > viewport
+ ) {
+ violations.push(`grouped edge ${route.edge.from}->${route.edge.to} label exceeds viewport`)
+ }
+ }
+ for (const subgraph of layout.diagram.subgraphs ?? []) {
+ const bound = layout.subgraphBounds.get(subgraph.id)
+ if (!bound || !finiteBounds(bound)) violations.push(`subgraph ${subgraph.id} has invalid bounds`)
+ requireOutputLines(output, splitDiagramLines(subgraph.label), `subgraph ${subgraph.id}`, violations)
+ for (const nodeId of subgraph.nodeIds) {
+ const node = layout.bounds.get(nodeId)
+ if (bound && node && !boundsContain(bound, node))
+ violations.push(`subgraph ${subgraph.id} does not contain ${nodeId}`)
+ }
+ }
+ const subgraphs = layout.diagram.subgraphs ?? []
+ const subgraphById = new Map(subgraphs.map((subgraph) => [subgraph.id, subgraph]))
+ const ancestorOf = (ancestor: string, id: string) => {
+ let parentId = subgraphById.get(id)?.parentId
+ while (parentId) {
+ if (parentId === ancestor) return true
+ parentId = subgraphById.get(parentId)?.parentId
+ }
+ return false
+ }
+ for (const [index, subgraph] of subgraphs.entries()) {
+ const bound = layout.subgraphBounds.get(subgraph.id)
+ if (!bound) continue
+ for (const other of subgraphs.slice(index + 1)) {
+ if (ancestorOf(subgraph.id, other.id) || ancestorOf(other.id, subgraph.id)) continue
+ const otherBound = layout.subgraphBounds.get(other.id)
+ if (otherBound && boundsOverlap(bound, otherBound)) {
+ violations.push(`subgraphs ${subgraph.id} and ${other.id} overlap`)
+ }
+ }
+ }
+
+ return { fixture, viewport, output, metrics: metrics(size.width, size.height, routes, viewport), violations }
+}
+
+function stateRoute(route: StateTransitionRenderPlan): AuditedRoute {
+ return {
+ from: route.route.transition.from,
+ to: route.route.transition.to,
+ points: route.path.map(([x, y]) => ({ x, y })),
+ }
+}
+
+function auditState(fixture: LayoutFixture, viewport: (typeof auditViewports)[number]): LayoutAudit {
+ const violations: string[] = []
+ const parsed = parseMermaidStateDiagram(fixture.source)
+ const drawing = createStateDiagramDrawing(parsed, { minStateGap: 5, layoutMaxWidth: viewport })
+ const diagram = drawing.diagram
+ const layout = drawing.layout
+ const plans = drawing.transitionPlans
+ const grid = drawing.grid
+ const routes = plans.map(stateRoute)
+ const output = grid.toString({ trimTop: true, trimBottom: true })
+ const size = grid.getTextSize({ trimTop: true, trimBottom: true })
+ const bodyIds = diagram.states.filter((state) => !isHiddenCompositeMarker(state)).map((state) => state.id)
+
+ if (!Number.isFinite(size.width) || !Number.isFinite(size.height) || size.width <= 0 || size.height <= 0)
+ violations.push("rendered grid has invalid dimensions")
+ if (plans.length !== diagram.transitions.length)
+ violations.push("rendered route count does not match visible transition count")
+ validateBodies(layout.bounds, bodyIds, violations)
+ validateRoutes(routes, layout.bounds, bodyIds, violations)
+ if (diagram.direction === "BT" && fixture.family === "chain") {
+ for (const transition of diagram.transitions) {
+ if (transition.from === transition.to) continue
+ const from = layout.bounds.get(transition.from)
+ const to = layout.bounds.get(transition.to)
+ if (from && to && from.centerY <= to.centerY) {
+ violations.push(`BT transition ${transition.from}->${transition.to} does not travel upward`)
+ }
+ }
+ }
+
+ for (const state of diagram.states) {
+ if (isHiddenCompositeMarker(state)) continue
+ requireOutputLines(output, layout.sizes.get(state.id)?.lines ?? [state.label], `state ${state.id}`, violations)
+ }
+ for (const plan of plans) {
+ if (!plan.route.transition.label) continue
+ if (!plan.label)
+ violations.push(`transition ${plan.route.transition.from}->${plan.route.transition.to} has no label layout`)
+ requireOutputLines(
+ output,
+ plan.label?.lines ?? [],
+ `transition ${plan.route.transition.from}->${plan.route.transition.to}`,
+ violations,
+ )
+ }
+
+ const statesById = new Map(diagram.states.map((state) => [state.id, state]))
+ const compositesById = new Map(diagram.composites.map((composite) => [composite.id, composite]))
+ const descendantOf = (id: string, compositeId: string) => {
+ let parentId = statesById.get(id)?.parentId ?? compositesById.get(id)?.parentId
+ while (parentId) {
+ if (parentId === compositeId) return true
+ parentId = compositesById.get(parentId)?.parentId
+ }
+ return false
+ }
+ for (const composite of diagram.composites) {
+ const bound = layout.compositeBounds.get(composite.id)
+ if (!bound || !finiteBounds(bound)) {
+ violations.push(`composite ${composite.id} has invalid bounds`)
+ continue
+ }
+ requireOutputLines(output, splitDiagramLines(composite.label), `composite ${composite.id}`, violations)
+ for (const state of diagram.states.filter(
+ (state) => !isHiddenCompositeMarker(state) && descendantOf(state.id, composite.id),
+ )) {
+ const stateBound = layout.bounds.get(state.id)
+ if (stateBound && !boundsContain(bound, stateBound))
+ violations.push(`composite ${composite.id} does not contain ${state.id}`)
+ }
+ }
+
+ for (const [index, note] of layout.noteBounds.entries()) {
+ if (!finiteBounds(note)) violations.push(`note ${index} has invalid bounds`)
+ requireOutputLines(output, note.lines, `note ${index}`, violations)
+ for (const id of bodyIds) {
+ const bound = layout.bounds.get(id)
+ if (bound && boundsOverlap(note, bound)) violations.push(`note ${index} overlaps state ${id}`)
+ }
+ for (const other of layout.noteBounds.slice(index + 1)) {
+ if (boundsOverlap(note, other)) violations.push(`notes ${index} and ${other.id} overlap`)
+ }
+ const target = layout.bounds.get(note.note.target)
+ if (!target) {
+ violations.push(`note ${index} has no target bound`)
+ continue
+ }
+ for (const point of expandedPath(stateDiagramNoteConnector(note, target).points)) {
+ for (const id of bodyIds) {
+ if (id === note.note.target) continue
+ const bound = layout.bounds.get(id)
+ if (bound && pointInBounds(point, bound)) violations.push(`note ${index} connector intersects state ${id}`)
+ }
+ for (const other of layout.noteBounds) {
+ if (other === note) continue
+ if (pointInBounds(point, other)) violations.push(`note ${index} connector intersects note ${other.id}`)
+ }
+ }
+ }
+
+ return { fixture, viewport, output, metrics: metrics(size.width, size.height, routes, viewport), violations }
+}
+
+export function auditFixture(fixture: LayoutFixture, viewport: (typeof auditViewports)[number] = 120): LayoutAudit {
+ return fixture.kind === "flowchart" ? auditFlowchart(fixture, viewport) : auditState(fixture, viewport)
+}
+
+export function auditAllFixtures(): LayoutAudit[] {
+ return layoutFixtures().flatMap((fixture, index) => {
+ if (fixture.curated) return auditViewports.map((viewport) => auditFixture(fixture, viewport))
+ if (fixture.kind === "flowchart") {
+ return auditFixture(fixture, auditViewports[index % auditViewports.length])
+ }
+ const viewport = fixture.profile === "short" ? 60 : fixture.profile === "unicode" ? 80 : 120
+ return auditFixture(fixture, viewport)
+ })
+}
+
+function percentile(values: readonly number[], ratio: number): number {
+ if (values.length === 0) return 0
+ return [...values].sort((left, right) => left - right)[Math.ceil(values.length * ratio) - 1] ?? 0
+}
+
+export function summarizeAudits(audits: readonly LayoutAudit[]) {
+ const summarize = (selected: readonly LayoutAudit[]) => ({
+ runs: selected.length,
+ sources: new Set(selected.map((audit) => audit.fixture.id)).size,
+ violations: selected.reduce((total, audit) => total + audit.violations.length, 0),
+ area: {
+ p50: percentile(
+ selected.map((audit) => audit.metrics.area),
+ 0.5,
+ ),
+ p95: percentile(
+ selected.map((audit) => audit.metrics.area),
+ 0.95,
+ ),
+ max: Math.max(0, ...selected.map((audit) => audit.metrics.area)),
+ },
+ bends: {
+ p95: percentile(
+ selected.map((audit) => audit.metrics.bends),
+ 0.95,
+ ),
+ max: Math.max(0, ...selected.map((audit) => audit.metrics.bends)),
+ },
+ crossings: {
+ total: selected.reduce((total, audit) => total + audit.metrics.crossings, 0),
+ max: Math.max(0, ...selected.map((audit) => audit.metrics.crossings)),
+ },
+ routeLength: {
+ p95: percentile(
+ selected.map((audit) => audit.metrics.routeLength),
+ 0.95,
+ ),
+ max: Math.max(0, ...selected.map((audit) => audit.metrics.routeLength)),
+ },
+ sharedRouteCells: {
+ p95: percentile(
+ selected.map((audit) => audit.metrics.sharedRouteCells),
+ 0.95,
+ ),
+ max: Math.max(0, ...selected.map((audit) => audit.metrics.sharedRouteCells)),
+ },
+ overflow: {
+ p95: percentile(
+ selected.map((audit) => audit.metrics.overflow),
+ 0.95,
+ ),
+ max: Math.max(0, ...selected.map((audit) => audit.metrics.overflow)),
+ },
+ })
+ return {
+ total: summarize(audits),
+ flowchart: summarize(audits.filter((audit) => audit.fixture.kind === "flowchart")),
+ state: summarize(audits.filter((audit) => audit.fixture.kind === "state")),
+ }
+}
+
+export function worstAudits(audits: readonly LayoutAudit[], metric: keyof LayoutMetrics, limit = 10): LayoutAudit[] {
+ return [...audits]
+ .sort(
+ (left, right) => right.metrics[metric] - left.metrics[metric] || left.fixture.id.localeCompare(right.fixture.id),
+ )
+ .slice(0, limit)
+}
diff --git a/packages/merman/src/test/markdown.test.ts b/packages/merman/src/test/markdown.test.ts
index 57a75b99f80..878a83802eb 100644
--- a/packages/merman/src/test/markdown.test.ts
+++ b/packages/merman/src/test/markdown.test.ts
@@ -334,6 +334,33 @@ flowchart LR
expect(testRenderer.captureCharFrame()).toContain("GLOBAL registry")
})
+test("folds a horizontal state diagram to the Markdown context width", async () => {
+ const testRenderer = await createTestRenderer({ width: 60, height: 48 })
+ renderer = testRenderer.renderer
+ const markdown = new MarkdownRenderable(renderer, {
+ id: "markdown-horizontal-state",
+ content: `\`\`\`mermaid
+stateDiagram-v2
+ direction LR
+ [*] --> A
+ A --> B: first
+ B --> C: second
+ C --> D: third
+ D --> [*]
+\`\`\``,
+ syntaxStyle,
+ renderNode: createMermaidMarkdownRenderer(renderer),
+ })
+
+ renderer.root.add(markdown)
+ await renderMarkdown(markdown, testRenderer.renderOnce)
+
+ const diagram = markdown.getChildren()[0] as CodeRenderable
+ expect(diagram.scrollWidth).toBeLessThanOrEqual(diagram.width)
+ expect(diagram.scrollWidth).toBeLessThanOrEqual(60)
+ expect(testRenderer.captureCharFrame()).toContain("third")
+})
+
test("renders a Mermaid state fence inside MarkdownRenderable", async () => {
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
renderer = testRenderer.renderer
@@ -357,6 +384,27 @@ stateDiagram-v2
expect(frame).not.toContain("stateDiagram-v2")
})
+test("sizes a standalone state choice after trimming leading rows", async () => {
+ const testRenderer = await createTestRenderer({ width: 80, height: 6 })
+ renderer = testRenderer.renderer
+ const markdown = new MarkdownRenderable(renderer, {
+ id: "markdown-state-choice",
+ content: `\`\`\`mermaid
+stateDiagram-v2
+ direction LR
+ state Decision <>
+\`\`\``,
+ syntaxStyle,
+ renderNode: createMermaidMarkdownRenderer(renderer),
+ })
+
+ renderer.root.add(markdown)
+ await renderMarkdown(markdown, testRenderer.renderOnce)
+
+ expect(markdown.getChildren()[0]?.height).toBe(1)
+ expect(testRenderer.captureCharFrame()).toContain("◆")
+})
+
test("renders a Mermaid timeline fence inside MarkdownRenderable", async () => {
const testRenderer = await createTestRenderer({ width: 80, height: 18 })
renderer = testRenderer.renderer
diff --git a/packages/tui/src/feature-plugins/system/storybook/index.tsx b/packages/tui/src/feature-plugins/system/storybook/index.tsx
index f6c38ecfc48..c57e7f6f6e0 100644
--- a/packages/tui/src/feature-plugins/system/storybook/index.tsx
+++ b/packages/tui/src/feature-plugins/system/storybook/index.tsx
@@ -2,6 +2,7 @@ import { Plugin } from "@opencode-ai/plugin/tui"
import { useTerminalDimensions } from "@opentui/solid"
import { createSignal, For, type JSX } from "solid-js"
import { StoryFooter } from "./footer"
+import { mermanLayoutsStory } from "./merman-layouts"
import { sessionTabsStory } from "./session-tabs"
import { sessionLocationMissingStory } from "./session-location-missing"
@@ -15,7 +16,7 @@ export type Story = {
render: (context: Plugin.Context) => JSX.Element
}
-const stories: Story[] = [sessionTabsStory, sessionLocationMissingStory]
+const stories: Story[] = [mermanLayoutsStory, sessionTabsStory, sessionLocationMissingStory]
function Commands(props: { context: Plugin.Context }) {
props.context.keymap.layer(() => ({
diff --git a/packages/tui/src/feature-plugins/system/storybook/merman-layouts.tsx b/packages/tui/src/feature-plugins/system/storybook/merman-layouts.tsx
new file mode 100644
index 00000000000..e2227f35685
--- /dev/null
+++ b/packages/tui/src/feature-plugins/system/storybook/merman-layouts.tsx
@@ -0,0 +1,201 @@
+import type { Plugin } from "@opencode-ai/plugin/tui"
+import { useTerminalDimensions } from "@opentui/solid"
+import { createMemo, createSignal, Show } from "solid-js"
+import { useTheme, useThemes } from "../../../context/theme"
+import { usePlugin } from "../../../plugin/context"
+import type { Story } from "./index"
+import { StoryFooter } from "./footer"
+
+const fixtures = [
+ {
+ id: "deployment",
+ title: "Deployment architecture",
+ source: `flowchart LR
+ Client[OpenCode client]
+
+ subgraph CF[Cloudflare]
+ DNS[opencode.ai]
+ Web[Console frontend Worker]
+ Proxy[Console API proxy Worker]
+ Infer[inference-next Worker]
+ KV[Model registry KV]
+ Redis[Upstash Redis]
+ Logs[Axiom / Cloudflare logs]
+ Lake[Pipeline to R2 data lake]
+ end
+
+ subgraph AWS[AWS]
+ EKS[EKS cluster]
+ API[Console API pod
1 replica]
+ OTEL[OTel collector]
+ ECR[ECR]
+ end
+
+ DB[(PlanetScale)]
+ Models[Anthropic / OpenAI / other providers]
+
+ Client -->|/inference/*| DNS --> Infer
+ Client -->|/console/*| DNS --> Web
+ Web -->|/console/api, /auth, etc.| Proxy
+ Proxy -->|Cloudflare VPC service| API
+ Infer -->|public DATABASE_URL| DB
+ Infer --> KV
+ Infer --> Redis
+ Infer --> Models
+ Infer --> Logs
+ Infer --> Lake
+ API -->|private DATABASE_AWS_URL| DB
+ API --> OTEL
+ ECR --> API`,
+ },
+ {
+ id: "nested-flow",
+ title: "Nested directed groups",
+ source: `flowchart LR
+ Input([Input]) --> Parse
+ subgraph Outer[Outer orchestration]
+ direction RL
+ subgraph Inner[Inner pipeline]
+ direction TD
+ Parse[Parse request] --> Validate{Valid?}
+ Validate -->|yes| Cache[(Cache)]
+ Cache -->|stale| Validate
+ end
+ Validate --> Dispatch[[Dispatch work]]
+ Dispatch -->|requeue| Parse
+ end
+ Dispatch -. result .-> Output([Output])
+ Output -->|audit| Cache`,
+ },
+ {
+ id: "state-feedback",
+ title: "Dense state feedback",
+ source: `stateDiagram-v2
+ direction TB
+ [*] --> Root
+ Root --> Alpha: dispatch alpha
+ Root --> Beta: dispatch beta
+ Alpha --> Merge: alpha complete
+ Beta --> Merge: beta complete
+ Merge --> Alpha: retry alpha
+ Merge --> Beta: retry beta
+ Merge --> [*]: finish
+ note right of Merge
+ Retries preserve the original request
+ and remain visible after compaction
+ end note`,
+ },
+ {
+ id: "state-composite",
+ title: "Nested composite lifecycle",
+ source: `stateDiagram-v2
+ direction LR
+ state Session {
+ [*] --> Open
+ state Open {
+ [*] --> Clean
+ Clean --> Dirty: edit
+ Dirty --> Clean: save
+ }
+ Open --> Closing: request close
+ Closing --> Open: cancel
+ Closing --> [*]: closed
+ note right of Dirty: unsaved changes
+ }
+ [*] --> Session: hydrate
+ Session --> [*]: release`,
+ },
+] as const
+
+function MermanLayoutsStory(props: { context: Plugin.Context }) {
+ const dimensions = useTerminalDimensions()
+ const theme = useTheme()
+ const themes = useThemes()
+ const plugins = usePlugin()
+ const [selected, setSelected] = createSignal(0)
+ const [generation, setGeneration] = createSignal(0)
+ const fixture = createMemo(() => fixtures[selected()]!)
+ const rendered = createMemo(() => ({ fixture: fixture(), generation: generation() }))
+ const markdown = createMemo(() => `\`\`\`mermaid\n${fixture().source}\n\`\`\``)
+ const move = (offset: number) => setSelected((current) => (current + offset + fixtures.length) % fixtures.length)
+
+ props.context.keymap.layer(() => ({
+ commands: [
+ {
+ bind: "escape",
+ title: "Back to storybook",
+ group: "Storybook",
+ run: () => props.context.ui.router.navigate({ type: "plugin", name: "storybook" }),
+ },
+ {
+ bind: "left,k",
+ title: "Previous fixture",
+ group: "Storybook",
+ run: () => move(-1),
+ },
+ {
+ bind: "right,j",
+ title: "Next fixture",
+ group: "Storybook",
+ run: () => move(1),
+ },
+ {
+ bind: "r",
+ title: "Reset fixture",
+ group: "Storybook",
+ run: () => {
+ setSelected(0)
+ setGeneration((current) => current + 1)
+ },
+ },
+ ],
+ }))
+
+ return (
+
+
+ {(item) => (
+
+
+ {item.fixture.title}
+ {item.fixture.id}
+
+
+
+
+ )}
+
+
+
+ )
+}
+
+export const mermanLayoutsStory: Story = {
+ id: "merman-layouts",
+ title: "Mermaid layouts",
+ render: (context) => ,
+}