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