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