fix(merman): harden responsive diagram layouts (#44714)

This commit is contained in:
Kit Langton 2026-08-24 12:09:00 -04:00 committed by GitHub
parent 50a8539e4b
commit 23c3a1461c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 3343 additions and 244 deletions

View file

@ -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"
},

View file

@ -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 [
`<details${audit.fixture.curated || audit.violations.length > 0 ? " open" : ""}>`,
`<summary><code>${label(audit)}</code> · ${audit.metrics.width}x${audit.metrics.height} · area ${audit.metrics.area} · bends ${audit.metrics.bends} · crossings ${audit.metrics.crossings} · overflow ${audit.metrics.overflow}</summary>`,
"",
...(audit.violations.length > 0 ? ["Violations:", "", ...audit.violations.map((item) => `- ${item}`), ""] : []),
"Source:",
"",
"```mermaid",
audit.fixture.source,
"```",
"",
"Rendered output:",
"",
"```text",
audit.output,
"```",
"",
"</details>",
].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
}

View file

@ -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,

View file

@ -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<typeof layoutFlowchartDiagram>): 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("<br")
})
test.each(
(["TD", "BT"] as const).flatMap((direction) =>
[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("<br/>")
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("<br")
})
test("merges transition lines through subgraph frame borders", () => {
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)
})
})

View file

@ -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)))
}

View file

@ -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<string, FlowchartNodeSize>,
minNodeGap: number,
requestedMinRankGap: number,
): Map<string, FlowchartNodeBounds> {
targetWidth?: number,
): { bounds: Map<string, FlowchartNodeBounds>; 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<string, FlowchartNodeBounds>()
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<string, FlowchartNodeBounds>,
sizes: ReadonlyMap<string, FlowchartNodeSize>,
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<string, string>()
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<string, { dx: number; dy: number }>()
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<string, FlowchartNodeBounds>,
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds>,
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<string, FlowchartSubgraphBounds>()
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)
}

View file

@ -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
}

View file

@ -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<string, FlowchartNodeBounds>,
directionForEdge: (edge: FlowchartEdge) => FlowchartDirection,
directionAligned: boolean,
handled: Set<FlowchartEdge>,
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<string, FlowchartNodeBounds>,
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds> | 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<string, FlowchartNodeBounds>,
directionForEdge: (edge: FlowchartEdge) => FlowchartDirection = () => diagram.direction,
subgraphBounds?: ReadonlyMap<string, FlowchartSubgraphBounds>,
targetWidth?: number,
directionAligned = false,
): FlowchartEdgeRoute[] {
const routedDiagram = { ...diagram, edges: diagram.edges.filter((edge) => !edge.orderOnly) }
const handled = new Set<FlowchartEdge>()
@ -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<string>()
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<FlowchartEdgeRoute[]>((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<string, FlowchartNodeBounds>,
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds>,
): 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"

View file

@ -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<FlowchartCellStyle, FlowchartCellMetad
export type FlowchartStyleColors = Required<Record<FlowchartCellStyle, RGBA>>
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<Record<FlowchartCellStyle, RGBA | undefined>> = {},
): 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),
}
}

View file

@ -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)

View file

@ -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<GanttDiagramRenderOptions, "layoutMaxWidth">
@ -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,

View file

@ -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} 東京<br/>résumé 🚀`
return `${id} node`
}
const transitionLabel = (id: string) => {
if (profile === "long") return `${id} transition carrying detailed context`
if (profile === "unicode") return `${id} 東京<br/>✓ 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 <<choice>>`)
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

View file

@ -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 }
}

View file

@ -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)
},
)
})

View file

@ -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<string, number>()
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<string, StateDiagramState>,
compositesById: Map<string, StateDiagramCompositeState>,
): 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<string, StateDiagramBoxBounds>): void {
const compositesByParent = new Map<string, StateDiagramCompositeState[]>()
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<number, StateDiagramState[]>()
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<string, StateDiagramBoxBounds>()
const outgoingLabelRows = new Map<string, number>()
const selfTransitionCounts = new Map<string, number>()
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)
}

View file

@ -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+<<choice>>$/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
}

View file

@ -7,11 +7,12 @@ import type { StateCellStyle } from "./types.js"
export type StateGrid = DiagramCanvas<StateCellStyle>
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,
})
}

View file

@ -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", () => {

View file

@ -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<string, BoxBounds>,
@ -358,11 +379,13 @@ export function createStateTransitionRoutePlans(
): StateTransitionRoutePlan[] {
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
const endpointOccurrences = new Map<string, number>()
const selfOccurrences = new Map<string, number>()
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<string, number>()
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<StateTransitionRoutePlan, { kind: "self" }>
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<StateTransitionRoutePlan, { kind: "side-parallel" }>
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<string, BoxBounds>,
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<string, { x: number; y: number }>()
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<number, StateTransitionRenderPlan>()
const endpointCounts = new Map<string, number>()
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<string, BoxBounds>,

View file

@ -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}`

View file

@ -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} 東京<br/>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} 東京<br/>✓ 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, (direction: FlowchartDirection, profile: LabelProfile) => 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 <<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, (direction: StateDiagramDirection, profile: LabelProfile) => 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<br/>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,
]
}

View file

@ -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<StateDiagramBoxBounds, "left" | "top" | "width" | "height">
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<string, Bounds>,
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<string, Bounds>, 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)
}

View file

@ -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 <<choice>>
\`\`\``,
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

View file

@ -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(() => ({

View file

@ -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<br/>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 (
<box
width={dimensions().width}
height={dimensions().height}
flexDirection="column"
backgroundColor={theme.background.default}
>
<Show when={rendered()} keyed>
{(item) => (
<scrollbox flexGrow={1} minHeight={0} viewportOptions={{ paddingRight: 1 }}>
<box paddingLeft={2} paddingRight={2} paddingTop={1} flexDirection="column">
<text fg={theme.text.default}>{item.fixture.title}</text>
<text fg={theme.text.subdued}>{item.fixture.id}</text>
<box height={1} />
<markdown
width="100%"
syntaxStyle={themes.currentSyntax()}
content={markdown()}
internalBlockMode="top-level"
tableOptions={{ style: "grid", cellPaddingX: 1 }}
conceal={true}
fg={theme.markdown.text}
bg={theme.background.default}
renderNode={plugins.markdown()}
/>
</box>
</scrollbox>
)}
</Show>
<StoryFooter
context={props.context}
title="storybook / Mermaid layouts"
details={[`${selected() + 1}/${fixtures.length}`, fixture().id, `${dimensions().width}x${dimensions().height}`]}
controls={[
{ shortcut: "j/k or ←/→", label: "fixture" },
{ shortcut: "r", label: "reset" },
{ shortcut: "esc", label: "back" },
]}
/>
</box>
)
}
export const mermanLayoutsStory: Story = {
id: "merman-layouts",
title: "Mermaid layouts",
render: (context) => <MermanLayoutsStory context={context} />,
}