opentui-markdown-render 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +27 -0
- package/README.md +223 -2
- package/bin/otmd.tsx +99 -0
- package/native/Cargo.lock +3708 -0
- package/native/Cargo.toml +28 -0
- package/native/build.rs +3 -0
- package/native/src/lib.rs +880 -0
- package/package.json +67 -4
- package/prebuilds/linux-x64-gnu/opentui_markdown_render.node +4 -0
- package/src/app.tsx +180 -0
- package/src/cli.ts +63 -0
- package/src/config/index.ts +46 -0
- package/src/config/load.ts +337 -0
- package/src/config/resolve.ts +36 -0
- package/src/config/theme.ts +110 -0
- package/src/config/types.ts +105 -0
- package/src/core/document.ts +44 -0
- package/src/core/frame.ts +144 -0
- package/src/core/image.ts +9 -0
- package/src/core/index.ts +6 -0
- package/src/core/layout.ts +27 -0
- package/src/core/math.ts +170 -0
- package/src/core/parse.ts +208 -0
- package/src/core/tables.ts +58 -0
- package/src/graphics.ts +85 -0
- package/src/headings.ts +77 -0
- package/src/index.ts +107 -0
- package/src/math/index.ts +86 -0
- package/src/math/native.ts +39 -0
- package/src/math/renderer.ts +84 -0
- package/src/math/validate.ts +44 -0
- package/src/media.tsx +267 -0
- package/src/merman/class/dagre-layout.ts +171 -0
- package/src/merman/class/diagram.ts +8 -0
- package/src/merman/class/drawing.ts +343 -0
- package/src/merman/class/labels.ts +38 -0
- package/src/merman/class/layout.ts +531 -0
- package/src/merman/class/parser.ts +308 -0
- package/src/merman/class/render-grid.ts +18 -0
- package/src/merman/class/style.ts +27 -0
- package/src/merman/class/types.ts +57 -0
- package/src/merman/core/canvas.ts +228 -0
- package/src/merman/core/color/style.ts +56 -0
- package/src/merman/core/drawing.ts +307 -0
- package/src/merman/core/geometry.ts +347 -0
- package/src/merman/core/mermaid.ts +38 -0
- package/src/merman/core/render-grid.ts +51 -0
- package/src/merman/core/spatial.ts +243 -0
- package/src/merman/core/text-lines.ts +32 -0
- package/src/merman/core/text.ts +44 -0
- package/src/merman/detect.ts +19 -0
- package/src/merman/diagnostics.ts +16 -0
- package/src/merman/diagram.ts +268 -0
- package/src/merman/flowchart/drawing.ts +433 -0
- package/src/merman/flowchart/labels.ts +144 -0
- package/src/merman/flowchart/layout.ts +1057 -0
- package/src/merman/flowchart/options.ts +12 -0
- package/src/merman/flowchart/parser.ts +477 -0
- package/src/merman/flowchart/render.ts +10 -0
- package/src/merman/flowchart/routing.ts +1429 -0
- package/src/merman/flowchart/style.ts +79 -0
- package/src/merman/flowchart/types.ts +63 -0
- package/src/merman/gantt/diagram.ts +8 -0
- package/src/merman/gantt/drawing.ts +193 -0
- package/src/merman/gantt/parser.ts +198 -0
- package/src/merman/gantt/render-grid.ts +17 -0
- package/src/merman/gantt/style.ts +36 -0
- package/src/merman/gantt/types.ts +45 -0
- package/src/merman/gitgraph/diagram.ts +8 -0
- package/src/merman/gitgraph/drawing.ts +234 -0
- package/src/merman/gitgraph/parser.ts +234 -0
- package/src/merman/gitgraph/render-grid.ts +17 -0
- package/src/merman/gitgraph/style.ts +37 -0
- package/src/merman/gitgraph/types.ts +36 -0
- package/src/merman/palette.ts +60 -0
- package/src/merman/sequence/diagram.ts +8 -0
- package/src/merman/sequence/drawing.ts +288 -0
- package/src/merman/sequence/options.ts +8 -0
- package/src/merman/sequence/parser.ts +303 -0
- package/src/merman/sequence/placement.ts +692 -0
- package/src/merman/sequence/render-grid.ts +22 -0
- package/src/merman/sequence/style.ts +81 -0
- package/src/merman/sequence/types.ts +74 -0
- package/src/merman/state/diagram.ts +8 -0
- package/src/merman/state/drawing.ts +363 -0
- package/src/merman/state/endpoint.ts +15 -0
- package/src/merman/state/layout.ts +1134 -0
- package/src/merman/state/note.ts +59 -0
- package/src/merman/state/options.ts +11 -0
- package/src/merman/state/parser.ts +245 -0
- package/src/merman/state/render-grid.ts +22 -0
- package/src/merman/state/routing.ts +1522 -0
- package/src/merman/state/search.ts +103 -0
- package/src/merman/state/style.ts +52 -0
- package/src/merman/state/types.ts +63 -0
- package/src/merman/state/visible-model.ts +77 -0
- package/src/merman/timeline/diagram.ts +8 -0
- package/src/merman/timeline/drawing.ts +109 -0
- package/src/merman/timeline/parser.ts +119 -0
- package/src/merman/timeline/render-grid.ts +17 -0
- package/src/merman/timeline/style.ts +36 -0
- package/src/merman/timeline/types.ts +31 -0
- package/src/merman.ts +3 -0
- package/src/statusline.tsx +183 -0
- package/src/theme/colors.ts +11 -0
- package/src/theme/defaults.ts +190 -0
- package/src/theme/index.ts +7 -0
- package/src/theme/load.ts +39 -0
- package/src/theme/resolve.ts +167 -0
- package/src/theme/syntax.ts +96 -0
- package/src/theme/types.ts +151 -0
- package/src/view/code.tsx +27 -0
- package/src/view/document-slots.ts +111 -0
- package/src/view/document-table.tsx +98 -0
- package/src/view/document.tsx +195 -0
- package/src/view/frame.tsx +83 -0
- package/src/view/inline-flow.tsx +113 -0
- package/src/view/inline-layout.ts +149 -0
- package/src/view/inline-math.ts +42 -0
- package/src/view/inline-style.ts +103 -0
- package/src/view/math.tsx +85 -0
- package/src/view/mermaid.tsx +128 -0
- package/src/view/table.ts +100 -0
- package/themes/dark.json +32 -0
- package/themes/example.jsonc +98 -0
- package/themes/light.json +32 -0
- package/themes/solarized-dark.json +32 -0
- package/themes/solarized-light.json +32 -0
|
@@ -0,0 +1,1134 @@
|
|
|
1
|
+
import { orthogonalPathPoints, translateDiagramBounds, type DiagramPoint } from "../core/geometry.js"
|
|
2
|
+
import { SpatialIndex, spatialPathClaim, spatialRectClaim } from "../core/spatial.js"
|
|
3
|
+
import { diagramTextWidth, measureDiagramTextBox, splitDiagramLines } from "../core/text.js"
|
|
4
|
+
import { stateDiagramNoteConnector, type StateDiagramNoteConnector } from "./note.js"
|
|
5
|
+
import {
|
|
6
|
+
createStateTransitionRenderPlans,
|
|
7
|
+
hasReverseTransition,
|
|
8
|
+
isStateHorizontalFeedback,
|
|
9
|
+
measureStateTransitionLabel,
|
|
10
|
+
type StateTransitionRenderPlan,
|
|
11
|
+
} from "./routing.js"
|
|
12
|
+
import {
|
|
13
|
+
createStateSearchBudget,
|
|
14
|
+
createStateSearchSpace,
|
|
15
|
+
findStateManhattanPath,
|
|
16
|
+
type StateSearchBudget,
|
|
17
|
+
type StateSearchSpace,
|
|
18
|
+
} from "./search.js"
|
|
19
|
+
import type {
|
|
20
|
+
StateDiagram,
|
|
21
|
+
StateDiagramCompositeState,
|
|
22
|
+
StateDiagramNote,
|
|
23
|
+
StateDiagramState,
|
|
24
|
+
StateDiagramTransition,
|
|
25
|
+
} from "./types.js"
|
|
26
|
+
|
|
27
|
+
const MAX_STRICT_NOTE_PLACEMENTS = 24
|
|
28
|
+
|
|
29
|
+
export interface StateDiagramBoxBounds {
|
|
30
|
+
id: string
|
|
31
|
+
left: number
|
|
32
|
+
top: number
|
|
33
|
+
width: number
|
|
34
|
+
height: number
|
|
35
|
+
centerX: number
|
|
36
|
+
centerY: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface StateDiagramLayout {
|
|
40
|
+
bounds: Map<string, StateDiagramBoxBounds>
|
|
41
|
+
sizes: Map<string, { width: number; height: number; lines: string[] }>
|
|
42
|
+
compositeBounds: Map<string, StateDiagramBoxBounds>
|
|
43
|
+
noteBounds: StateDiagramNoteBounds[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface StateDiagramNoteBounds extends StateDiagramBoxBounds {
|
|
47
|
+
note: StateDiagramNote
|
|
48
|
+
lines: string[]
|
|
49
|
+
connector?: StateDiagramNoteConnector
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface StateDiagramLayoutOptions {
|
|
53
|
+
minStateGap: number
|
|
54
|
+
searchBudget?: StateSearchBudget
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function computeRanks(diagram: StateDiagram): Map<string, number> {
|
|
58
|
+
const ranks = new Map<string, number>()
|
|
59
|
+
const outgoing = new Map<string, string[]>()
|
|
60
|
+
for (const transition of diagram.transitions) {
|
|
61
|
+
const list = outgoing.get(transition.from) ?? []
|
|
62
|
+
list.push(transition.to)
|
|
63
|
+
outgoing.set(transition.from, list)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const first = diagram.states.find((state) => state.kind === "start")?.id ?? diagram.states[0]?.id
|
|
67
|
+
if (!first) return ranks
|
|
68
|
+
ranks.set(first, 0)
|
|
69
|
+
const queue = [first]
|
|
70
|
+
while (queue.length > 0) {
|
|
71
|
+
const id = queue.shift()!
|
|
72
|
+
const rank = ranks.get(id) ?? 0
|
|
73
|
+
for (const to of outgoing.get(id) ?? []) {
|
|
74
|
+
const nextRank = rank + 1
|
|
75
|
+
if ((ranks.get(to) ?? Number.POSITIVE_INFINITY) <= nextRank) continue
|
|
76
|
+
ranks.set(to, nextRank)
|
|
77
|
+
queue.push(to)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const state of diagram.states) {
|
|
82
|
+
if (!ranks.has(state.id)) ranks.set(state.id, ranks.size)
|
|
83
|
+
}
|
|
84
|
+
return ranks
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function outgoingTransitions(diagram: StateDiagram): Map<string, StateDiagramTransition[]> {
|
|
88
|
+
const outgoing = new Map<string, StateDiagramTransition[]>()
|
|
89
|
+
for (const transition of diagram.transitions) {
|
|
90
|
+
const list = outgoing.get(transition.from) ?? []
|
|
91
|
+
list.push(transition)
|
|
92
|
+
outgoing.set(transition.from, list)
|
|
93
|
+
}
|
|
94
|
+
return outgoing
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function reaches(
|
|
98
|
+
outgoing: ReadonlyMap<string, readonly StateDiagramTransition[]>,
|
|
99
|
+
from: string,
|
|
100
|
+
target: string,
|
|
101
|
+
): boolean {
|
|
102
|
+
const visited = new Set<string>()
|
|
103
|
+
const stack = [from]
|
|
104
|
+
while (stack.length > 0) {
|
|
105
|
+
const id = stack.pop()!
|
|
106
|
+
if (id === target) return true
|
|
107
|
+
if (visited.has(id)) continue
|
|
108
|
+
visited.add(id)
|
|
109
|
+
for (const transition of outgoing.get(id) ?? []) stack.push(transition.to)
|
|
110
|
+
}
|
|
111
|
+
return false
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function computeMainPath(diagram: StateDiagram): string[] {
|
|
115
|
+
const outgoing = outgoingTransitions(diagram)
|
|
116
|
+
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
|
|
117
|
+
const start = diagram.states.find((state) => state.kind === "start")?.id ?? diagram.states[0]?.id
|
|
118
|
+
if (!start) return []
|
|
119
|
+
|
|
120
|
+
const path = [start]
|
|
121
|
+
const visited = new Set(path)
|
|
122
|
+
let current = start
|
|
123
|
+
while (true) {
|
|
124
|
+
const candidates = (outgoing.get(current) ?? []).filter((transition) => !visited.has(transition.to))
|
|
125
|
+
if (candidates.length === 0) break
|
|
126
|
+
const next =
|
|
127
|
+
candidates.find((transition) => statesById.get(transition.to)?.kind === "end") ??
|
|
128
|
+
candidates.find((transition) => !reaches(outgoing, transition.to, current)) ??
|
|
129
|
+
candidates.find((transition) => !hasReverseTransition(diagram, transition)) ??
|
|
130
|
+
candidates.find((transition) => {
|
|
131
|
+
const fromParent = statesById.get(current)?.parentId
|
|
132
|
+
const toParent = statesById.get(transition.to)?.parentId
|
|
133
|
+
return Boolean(fromParent && toParent && fromParent !== toParent)
|
|
134
|
+
}) ??
|
|
135
|
+
(candidates.length === 1 ? candidates[0] : undefined)
|
|
136
|
+
if (!next) break
|
|
137
|
+
path.push(next.to)
|
|
138
|
+
visited.add(next.to)
|
|
139
|
+
current = next.to
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return path
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function stateSize(state: StateDiagramState): { width: number; height: number; lines: string[] } {
|
|
146
|
+
if (state.kind !== "state") return { width: 1, height: 1, lines: [state.label] }
|
|
147
|
+
return measureDiagramTextBox(state.label, { paddingX: 2, paddingY: 1 })
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function noteLines(note: StateDiagramNote): string[] {
|
|
151
|
+
const lines = note.lines.flatMap(splitDiagramLines).map((line) => line.trim())
|
|
152
|
+
return lines.length > 0 ? lines : [""]
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function noteSize(note: StateDiagramNote): { width: number; height: number; lines: string[] } {
|
|
156
|
+
const lines = noteLines(note)
|
|
157
|
+
const innerWidth = Math.max(...lines.map(diagramTextWidth), 1)
|
|
158
|
+
return { width: innerWidth + 4, height: lines.length + 2, lines }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function emptyLayout(
|
|
162
|
+
bounds: Map<string, StateDiagramBoxBounds>,
|
|
163
|
+
sizes: Map<string, { width: number; height: number; lines: string[] }>,
|
|
164
|
+
): StateDiagramLayout {
|
|
165
|
+
return { bounds, sizes, compositeBounds: new Map(), noteBounds: [] }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isNoteBound(bound: StateDiagramBoxBounds): bound is StateDiagramNoteBounds {
|
|
169
|
+
return "note" in bound
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function shiftBounds(bounds: Iterable<StateDiagramBoxBounds>, dx: number, dy: number): void {
|
|
173
|
+
for (const bound of bounds) {
|
|
174
|
+
translateDiagramBounds(bound, dx, dy)
|
|
175
|
+
if (!isNoteBound(bound) || !bound.connector) continue
|
|
176
|
+
bound.connector = {
|
|
177
|
+
connectorY: bound.connector.connectorY + dy,
|
|
178
|
+
points: bound.connector.points.map((point) => ({ x: point.x + dx, y: point.y + dy })),
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function translateStateDiagramLayout(layout: StateDiagramLayout, dx: number, dy: number): void {
|
|
184
|
+
shiftBounds(uniqueBounds(layout.bounds.values(), layout.compositeBounds.values(), layout.noteBounds), dx, dy)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function uniqueBounds(...bounds: Iterable<StateDiagramBoxBounds>[]): StateDiagramBoxBounds[] {
|
|
188
|
+
return [...new Set(bounds.flatMap((group) => [...group]))]
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function normalizeLayout(layout: StateDiagramLayout): void {
|
|
192
|
+
const allBounds = uniqueBounds(layout.bounds.values(), layout.compositeBounds.values(), layout.noteBounds)
|
|
193
|
+
if (allBounds.length === 0) return
|
|
194
|
+
const connectorPoints = layout.noteBounds.flatMap((bound) => bound.connector?.points ?? [])
|
|
195
|
+
const minX = Math.min(0, ...allBounds.map((bound) => bound.left), ...connectorPoints.map((point) => point.x))
|
|
196
|
+
const minY = Math.min(0, ...allBounds.map((bound) => bound.top), ...connectorPoints.map((point) => point.y))
|
|
197
|
+
if (minX === 0 && minY === 0) return
|
|
198
|
+
shiftBounds(allBounds, -minX, -minY)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function addCompositeBounds(diagram: StateDiagram, layout: StateDiagramLayout): void {
|
|
202
|
+
const statesByParent = new Map<string, string[]>()
|
|
203
|
+
const compositesByParent = new Map<string, StateDiagramCompositeState[]>()
|
|
204
|
+
for (const state of diagram.states) {
|
|
205
|
+
if (!state.parentId) continue
|
|
206
|
+
const states = statesByParent.get(state.parentId) ?? []
|
|
207
|
+
states.push(state.id)
|
|
208
|
+
statesByParent.set(state.parentId, states)
|
|
209
|
+
}
|
|
210
|
+
for (const composite of diagram.composites) {
|
|
211
|
+
if (!composite.parentId) continue
|
|
212
|
+
const composites = compositesByParent.get(composite.parentId) ?? []
|
|
213
|
+
composites.push(composite)
|
|
214
|
+
compositesByParent.set(composite.parentId, composites)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const addComposite = (composite: StateDiagramCompositeState): StateDiagramBoxBounds | undefined => {
|
|
218
|
+
const existing = layout.compositeBounds.get(composite.id)
|
|
219
|
+
if (existing) return existing
|
|
220
|
+
|
|
221
|
+
for (const child of compositesByParent.get(composite.id) ?? []) addComposite(child)
|
|
222
|
+
|
|
223
|
+
const childBounds = [
|
|
224
|
+
...(statesByParent.get(composite.id) ?? []),
|
|
225
|
+
...(compositesByParent.get(composite.id) ?? []).map((child) => child.id),
|
|
226
|
+
]
|
|
227
|
+
.map((id) => layout.bounds.get(id))
|
|
228
|
+
.filter((bound): bound is StateDiagramBoxBounds => Boolean(bound))
|
|
229
|
+
if (childBounds.length === 0) return undefined
|
|
230
|
+
|
|
231
|
+
const left = Math.min(...childBounds.map((bound) => bound.left)) - 2
|
|
232
|
+
const top = Math.min(...childBounds.map((bound) => bound.top)) - 2
|
|
233
|
+
const right = Math.max(...childBounds.map((bound) => bound.left + bound.width)) + 2
|
|
234
|
+
const bottom = Math.max(...childBounds.map((bound) => bound.top + bound.height)) + 2
|
|
235
|
+
const width = Math.max(right - left, diagramTextWidth(composite.label) + 5)
|
|
236
|
+
const bound = {
|
|
237
|
+
id: composite.id,
|
|
238
|
+
left,
|
|
239
|
+
top,
|
|
240
|
+
width,
|
|
241
|
+
height: bottom - top,
|
|
242
|
+
centerX: left + Math.floor(width / 2),
|
|
243
|
+
centerY: top + Math.floor((bottom - top) / 2),
|
|
244
|
+
}
|
|
245
|
+
layout.compositeBounds.set(composite.id, bound)
|
|
246
|
+
layout.bounds.set(composite.id, bound)
|
|
247
|
+
return bound
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
for (const composite of diagram.composites) addComposite(composite)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function intersects(
|
|
254
|
+
left: number,
|
|
255
|
+
top: number,
|
|
256
|
+
width: number,
|
|
257
|
+
height: number,
|
|
258
|
+
bound: StateDiagramBoxBounds,
|
|
259
|
+
padding = 1,
|
|
260
|
+
): boolean {
|
|
261
|
+
return (
|
|
262
|
+
left < bound.left + bound.width + padding &&
|
|
263
|
+
left + width + padding > bound.left &&
|
|
264
|
+
top < bound.top + bound.height + padding &&
|
|
265
|
+
top + height + padding > bound.top
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function createNoteBound(
|
|
270
|
+
note: StateDiagramNote,
|
|
271
|
+
index: number,
|
|
272
|
+
left: number,
|
|
273
|
+
top: number,
|
|
274
|
+
size: { width: number; height: number; lines: string[] },
|
|
275
|
+
): StateDiagramNoteBounds {
|
|
276
|
+
return {
|
|
277
|
+
id: `${note.target}-note-${index}`,
|
|
278
|
+
left,
|
|
279
|
+
top,
|
|
280
|
+
width: size.width,
|
|
281
|
+
height: size.height,
|
|
282
|
+
centerX: left + Math.floor(size.width / 2),
|
|
283
|
+
centerY: top + Math.floor(size.height / 2),
|
|
284
|
+
note,
|
|
285
|
+
lines: size.lines,
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function findNoteConnector(
|
|
290
|
+
search: StateSearchSpace,
|
|
291
|
+
bounds: StateDiagramNoteBounds,
|
|
292
|
+
target: StateDiagramBoxBounds,
|
|
293
|
+
budget: StateSearchBudget,
|
|
294
|
+
): StateDiagramNoteConnector | undefined {
|
|
295
|
+
const connectorY = Math.max(bounds.top + 1, Math.min(target.centerY, bounds.top + bounds.height - 2))
|
|
296
|
+
const end = {
|
|
297
|
+
x: bounds.note.position === "right" ? bounds.left - 1 : bounds.left + bounds.width,
|
|
298
|
+
y: connectorY,
|
|
299
|
+
}
|
|
300
|
+
const goal = { x: end.x + (bounds.note.position === "right" ? -1 : 1), y: end.y }
|
|
301
|
+
const targetX = bounds.note.position === "right" ? target.left + target.width : target.left - 1
|
|
302
|
+
const preferredStarts = [
|
|
303
|
+
{ x: targetX, y: target.centerY },
|
|
304
|
+
{ x: targetX, y: target.top - 1 },
|
|
305
|
+
{ x: targetX, y: target.top + target.height },
|
|
306
|
+
]
|
|
307
|
+
const perimeterStarts = [
|
|
308
|
+
...Array.from({ length: target.height }, (_, offset) => ({ x: target.left - 1, y: target.top + offset })),
|
|
309
|
+
...Array.from({ length: target.height }, (_, offset) => ({
|
|
310
|
+
x: target.left + target.width,
|
|
311
|
+
y: target.top + offset,
|
|
312
|
+
})),
|
|
313
|
+
...Array.from({ length: target.width }, (_, offset) => ({ x: target.left + offset, y: target.top - 1 })),
|
|
314
|
+
...Array.from({ length: target.width }, (_, offset) => ({
|
|
315
|
+
x: target.left + offset,
|
|
316
|
+
y: target.top + target.height,
|
|
317
|
+
})),
|
|
318
|
+
]
|
|
319
|
+
const starts = [...preferredStarts, ...perimeterStarts]
|
|
320
|
+
const isFree = (point: DiagramPoint): boolean =>
|
|
321
|
+
point.x >= 0 &&
|
|
322
|
+
!search.blocked.has(`${point.x}:${point.y}`) &&
|
|
323
|
+
!(
|
|
324
|
+
point.x >= bounds.left &&
|
|
325
|
+
point.x < bounds.left + bounds.width &&
|
|
326
|
+
point.y >= bounds.top &&
|
|
327
|
+
point.y < bounds.top + bounds.height
|
|
328
|
+
)
|
|
329
|
+
if (!isFree(end) || !isFree(goal)) return undefined
|
|
330
|
+
|
|
331
|
+
for (const start of starts.filter(isFree)) {
|
|
332
|
+
const directPaths = [
|
|
333
|
+
[start, { x: start.x, y: goal.y }, goal, end],
|
|
334
|
+
[start, { x: goal.x, y: start.y }, goal, end],
|
|
335
|
+
]
|
|
336
|
+
const direct = directPaths.find((points) => orthogonalPathPoints(points).every(isFree))
|
|
337
|
+
if (direct) return { connectorY, points: direct }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const margin = 8
|
|
341
|
+
const minY = Math.min(target.top, bounds.top, search.minY) - margin
|
|
342
|
+
const maxX = Math.max(target.left + target.width, bounds.left + bounds.width, search.maxX) + margin
|
|
343
|
+
const maxY = Math.max(target.top + target.height, bounds.top + bounds.height, search.maxY) + margin
|
|
344
|
+
const path = findStateManhattanPath(starts, goal, search, { minX: 0, minY, maxX, maxY }, budget, isFree)
|
|
345
|
+
return path ? { connectorY, points: [...path, end] } : undefined
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function belongsToComposite(
|
|
349
|
+
id: string,
|
|
350
|
+
compositeId: string,
|
|
351
|
+
statesById: Map<string, StateDiagramState>,
|
|
352
|
+
compositesById: Map<string, StateDiagramCompositeState>,
|
|
353
|
+
): boolean {
|
|
354
|
+
let parentId = statesById.get(id)?.parentId ?? compositesById.get(id)?.parentId
|
|
355
|
+
|
|
356
|
+
while (parentId) {
|
|
357
|
+
if (parentId === compositeId) return true
|
|
358
|
+
parentId = compositesById.get(parentId)?.parentId
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return false
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function expandCompositeBoundsForNotes(diagram: StateDiagram, layout: StateDiagramLayout): void {
|
|
365
|
+
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
|
|
366
|
+
const compositesById = new Map(diagram.composites.map((composite) => [composite.id, composite]))
|
|
367
|
+
|
|
368
|
+
for (const composite of [...diagram.composites].reverse()) {
|
|
369
|
+
const bound = layout.compositeBounds.get(composite.id)
|
|
370
|
+
if (!bound) continue
|
|
371
|
+
|
|
372
|
+
const descendantNotes = layout.noteBounds.filter((noteBound) =>
|
|
373
|
+
belongsToComposite(noteBound.note.target, composite.id, statesById, compositesById),
|
|
374
|
+
)
|
|
375
|
+
if (descendantNotes.length === 0) continue
|
|
376
|
+
|
|
377
|
+
const connectorPoints = descendantNotes.flatMap((note) => note.connector?.points ?? [])
|
|
378
|
+
const left = Math.min(
|
|
379
|
+
bound.left,
|
|
380
|
+
...descendantNotes.map((note) => note.left - 2),
|
|
381
|
+
...connectorPoints.map((point) => point.x - 1),
|
|
382
|
+
)
|
|
383
|
+
const top = Math.min(
|
|
384
|
+
bound.top,
|
|
385
|
+
...descendantNotes.map((note) => note.top - 1),
|
|
386
|
+
...connectorPoints.map((point) => point.y - 1),
|
|
387
|
+
)
|
|
388
|
+
const right = Math.max(
|
|
389
|
+
bound.left + bound.width,
|
|
390
|
+
...descendantNotes.map((note) => note.left + note.width + 2),
|
|
391
|
+
...connectorPoints.map((point) => point.x + 2),
|
|
392
|
+
)
|
|
393
|
+
const bottom = Math.max(
|
|
394
|
+
bound.top + bound.height,
|
|
395
|
+
...descendantNotes.map((note) => note.top + note.height + 1),
|
|
396
|
+
...connectorPoints.map((point) => point.y + 2),
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
bound.left = left
|
|
400
|
+
bound.top = top
|
|
401
|
+
bound.width = Math.max(right - left, diagramTextWidth(composite.label) + 5)
|
|
402
|
+
bound.height = bottom - top
|
|
403
|
+
bound.centerX = bound.left + Math.floor(bound.width / 2)
|
|
404
|
+
bound.centerY = bound.top + Math.floor(bound.height / 2)
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function expandCompositeBoundsForInternalRouting(diagram: StateDiagram, layout: StateDiagramLayout): void {
|
|
409
|
+
if (diagram.direction === "LR" || diagram.direction === "RL") return
|
|
410
|
+
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
|
|
411
|
+
const compositesById = new Map(diagram.composites.map((composite) => [composite.id, composite]))
|
|
412
|
+
|
|
413
|
+
for (const composite of [...diagram.composites].reverse()) {
|
|
414
|
+
const bound = layout.compositeBounds.get(composite.id)
|
|
415
|
+
if (!bound) continue
|
|
416
|
+
const internal = diagram.transitions.filter(
|
|
417
|
+
(transition) =>
|
|
418
|
+
transition.from !== transition.to &&
|
|
419
|
+
innermostCommonCompositeId(transition, statesById, compositesById) === composite.id,
|
|
420
|
+
)
|
|
421
|
+
const endpointOccurrences = new Map<string, number>()
|
|
422
|
+
const hasNestedComposite = diagram.composites.some((candidate) => candidate.parentId === composite.id)
|
|
423
|
+
const sideRoutes = internal.filter((transition) => {
|
|
424
|
+
const from = layout.bounds.get(transition.from)
|
|
425
|
+
const to = layout.bounds.get(transition.to)
|
|
426
|
+
if (!from || !to) return false
|
|
427
|
+
const key = `${transition.from}\u0000${transition.to}`
|
|
428
|
+
const occurrence = endpointOccurrences.get(key) ?? 0
|
|
429
|
+
endpointOccurrences.set(key, occurrence + 1)
|
|
430
|
+
const fromParent = statesById.get(transition.from)?.parentId
|
|
431
|
+
const toParent = statesById.get(transition.to)?.parentId
|
|
432
|
+
return (
|
|
433
|
+
occurrence > 0 ||
|
|
434
|
+
(from.centerY > to.centerY && (!hasNestedComposite || from.left - 1 <= bound.left)) ||
|
|
435
|
+
(fromParent !== toParent &&
|
|
436
|
+
(from.centerX !== to.centerX ||
|
|
437
|
+
statesById.get(transition.from)?.kind !== "state" ||
|
|
438
|
+
statesById.get(transition.to)?.kind !== "state"))
|
|
439
|
+
)
|
|
440
|
+
})
|
|
441
|
+
if (sideRoutes.length === 0) continue
|
|
442
|
+
const childRight = Math.max(
|
|
443
|
+
...diagram.states.flatMap((state) => {
|
|
444
|
+
if (!belongsToComposite(state.id, composite.id, statesById, compositesById)) return []
|
|
445
|
+
const child = layout.bounds.get(state.id)
|
|
446
|
+
return child ? [child.left + child.width] : []
|
|
447
|
+
}),
|
|
448
|
+
...diagram.composites.flatMap((childComposite) => {
|
|
449
|
+
if (childComposite.parentId !== composite.id) return []
|
|
450
|
+
const child = layout.compositeBounds.get(childComposite.id)
|
|
451
|
+
return child ? [child.left + child.width] : []
|
|
452
|
+
}),
|
|
453
|
+
)
|
|
454
|
+
const labelWidth = Math.max(...sideRoutes.map((transition) => measureStateTransitionLabel(transition.label).width))
|
|
455
|
+
const right = childRight + labelWidth + sideRoutes.length * 3 + 6
|
|
456
|
+
if (right <= bound.left + bound.width) continue
|
|
457
|
+
bound.width = right - bound.left
|
|
458
|
+
bound.centerX = bound.left + Math.floor(bound.width / 2)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
enforceCompositeMargins(diagram, layout.compositeBounds)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function innermostCommonCompositeId(
|
|
465
|
+
transition: StateDiagramTransition,
|
|
466
|
+
statesById: Map<string, StateDiagramState>,
|
|
467
|
+
compositesById: Map<string, StateDiagramCompositeState>,
|
|
468
|
+
): string | undefined {
|
|
469
|
+
const containers = (id: string) => {
|
|
470
|
+
const ids: string[] = []
|
|
471
|
+
let parentId = statesById.get(id)?.parentId ?? compositesById.get(id)?.parentId
|
|
472
|
+
while (parentId) {
|
|
473
|
+
ids.push(parentId)
|
|
474
|
+
parentId = compositesById.get(parentId)?.parentId
|
|
475
|
+
}
|
|
476
|
+
return ids
|
|
477
|
+
}
|
|
478
|
+
const target = new Set(containers(transition.to))
|
|
479
|
+
return containers(transition.from).find((id) => target.has(id))
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function enforceCompositeMargins(diagram: StateDiagram, compositeBounds: Map<string, StateDiagramBoxBounds>): void {
|
|
483
|
+
const compositesByParent = new Map<string, StateDiagramCompositeState[]>()
|
|
484
|
+
for (const composite of diagram.composites) {
|
|
485
|
+
if (!composite.parentId) continue
|
|
486
|
+
const children = compositesByParent.get(composite.parentId) ?? []
|
|
487
|
+
children.push(composite)
|
|
488
|
+
compositesByParent.set(composite.parentId, children)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const expand = (composite: StateDiagramCompositeState): StateDiagramBoxBounds | undefined => {
|
|
492
|
+
const bound = compositeBounds.get(composite.id)
|
|
493
|
+
if (!bound) return undefined
|
|
494
|
+
const children = (compositesByParent.get(composite.id) ?? [])
|
|
495
|
+
.map(expand)
|
|
496
|
+
.filter((child): child is StateDiagramBoxBounds => Boolean(child))
|
|
497
|
+
if (children.length === 0) return bound
|
|
498
|
+
const left = Math.min(bound.left, ...children.map((child) => child.left - 2))
|
|
499
|
+
const top = Math.min(bound.top, ...children.map((child) => child.top - 2))
|
|
500
|
+
const right = Math.max(bound.left + bound.width, ...children.map((child) => child.left + child.width + 2))
|
|
501
|
+
const bottom = Math.max(bound.top + bound.height, ...children.map((child) => child.top + child.height + 2))
|
|
502
|
+
bound.left = left
|
|
503
|
+
bound.top = top
|
|
504
|
+
bound.width = right - left
|
|
505
|
+
bound.height = bottom - top
|
|
506
|
+
bound.centerX = left + Math.floor(bound.width / 2)
|
|
507
|
+
bound.centerY = top + Math.floor(bound.height / 2)
|
|
508
|
+
return bound
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
for (const composite of diagram.composites.filter((candidate) => !candidate.parentId)) expand(composite)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function boundsIntersect(left: StateDiagramBoxBounds, right: StateDiagramBoxBounds): boolean {
|
|
515
|
+
return intersects(left.left, left.top, left.width, left.height, right, 0)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export function separateExternalBoundsFromComposites(diagram: StateDiagram, layout: StateDiagramLayout): boolean {
|
|
519
|
+
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
|
|
520
|
+
const compositesById = new Map(diagram.composites.map((composite) => [composite.id, composite]))
|
|
521
|
+
let shifted = false
|
|
522
|
+
|
|
523
|
+
for (const composite of diagram.composites) {
|
|
524
|
+
const compositeBound = layout.compositeBounds.get(composite.id)
|
|
525
|
+
if (!compositeBound) continue
|
|
526
|
+
|
|
527
|
+
for (const state of diagram.states) {
|
|
528
|
+
if (belongsToComposite(state.id, composite.id, statesById, compositesById)) continue
|
|
529
|
+
const bound = layout.bounds.get(state.id)
|
|
530
|
+
if (!bound || !boundsIntersect(bound, compositeBound)) continue
|
|
531
|
+
|
|
532
|
+
const dx = compositeBound.left + compositeBound.width + 4 - bound.left
|
|
533
|
+
if (dx <= 0) continue
|
|
534
|
+
const leftThreshold = bound.left
|
|
535
|
+
const boundsToShift: StateDiagramBoxBounds[] = []
|
|
536
|
+
|
|
537
|
+
for (const candidate of diagram.states) {
|
|
538
|
+
if (belongsToComposite(candidate.id, composite.id, statesById, compositesById)) continue
|
|
539
|
+
const candidateBound = layout.bounds.get(candidate.id)
|
|
540
|
+
if (candidateBound && candidateBound.left >= leftThreshold) boundsToShift.push(candidateBound)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
for (const candidate of diagram.composites) {
|
|
544
|
+
if (candidate.id === composite.id || belongsToComposite(candidate.id, composite.id, statesById, compositesById))
|
|
545
|
+
continue
|
|
546
|
+
const candidateBound = layout.compositeBounds.get(candidate.id)
|
|
547
|
+
if (candidateBound && candidateBound.left >= leftThreshold) boundsToShift.push(candidateBound)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
for (const noteBound of layout.noteBounds) {
|
|
551
|
+
const target = layout.bounds.get(noteBound.note.target)
|
|
552
|
+
if (target && boundsToShift.includes(target)) boundsToShift.push(noteBound)
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
shiftBounds(uniqueBounds(boundsToShift), dx, 0)
|
|
556
|
+
shifted = true
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return shifted
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function finalizeLayout(
|
|
563
|
+
diagram: StateDiagram,
|
|
564
|
+
layout: StateDiagramLayout,
|
|
565
|
+
budget: StateSearchBudget,
|
|
566
|
+
): StateDiagramLayout {
|
|
567
|
+
if (diagram.composites.length === 0 && diagram.notes.length === 0) return layout
|
|
568
|
+
addCompositeBounds(diagram, layout)
|
|
569
|
+
normalizeLayout(layout)
|
|
570
|
+
expandCompositeBoundsForInternalRouting(diagram, layout)
|
|
571
|
+
if (diagram.notes.length > 0) {
|
|
572
|
+
const allBounds = [...layout.bounds.values()]
|
|
573
|
+
placeStateDiagramNotesAroundTransitions(
|
|
574
|
+
diagram,
|
|
575
|
+
layout,
|
|
576
|
+
createStateTransitionRenderPlans(
|
|
577
|
+
diagram,
|
|
578
|
+
layout.bounds,
|
|
579
|
+
Math.max(0, ...allBounds.map((bound) => bound.top + bound.height)) + 3,
|
|
580
|
+
{
|
|
581
|
+
feedbackTopY: Math.min(0, ...allBounds.map((bound) => bound.top)) - 3,
|
|
582
|
+
repairRoutes: false,
|
|
583
|
+
searchBudget: budget,
|
|
584
|
+
},
|
|
585
|
+
),
|
|
586
|
+
budget,
|
|
587
|
+
)
|
|
588
|
+
}
|
|
589
|
+
expandCompositeBoundsForNotes(diagram, layout)
|
|
590
|
+
expandCompositeBoundsForInternalRouting(diagram, layout)
|
|
591
|
+
separateExternalBoundsFromComposites(diagram, layout)
|
|
592
|
+
normalizeLayout(layout)
|
|
593
|
+
return layout
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
export function createStateDiagramLayout(
|
|
597
|
+
diagram: StateDiagram,
|
|
598
|
+
options: StateDiagramLayoutOptions,
|
|
599
|
+
): StateDiagramLayout {
|
|
600
|
+
const budget = options.searchBudget ?? createStateSearchBudget()
|
|
601
|
+
if (diagram.direction === "LR" || diagram.direction === "RL") {
|
|
602
|
+
return finalizeLayout(diagram, createHorizontalLayout(diagram, options), budget)
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const ranks = computeRanks(diagram)
|
|
606
|
+
const maxRank = Math.max(0, ...ranks.values())
|
|
607
|
+
const byRank = new Map<number, StateDiagramState[]>()
|
|
608
|
+
for (const state of diagram.states) {
|
|
609
|
+
const rank = diagram.direction === "BT" ? maxRank - (ranks.get(state.id) ?? 0) : (ranks.get(state.id) ?? 0)
|
|
610
|
+
const list = byRank.get(rank) ?? []
|
|
611
|
+
list.push(state)
|
|
612
|
+
byRank.set(rank, list)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const rankKeys = [...byRank.keys()].sort((a, b) => a - b)
|
|
616
|
+
const sizes = new Map(diagram.states.map((state) => [state.id, stateSize(state)]))
|
|
617
|
+
const bounds = new Map<string, StateDiagramBoxBounds>()
|
|
618
|
+
const outgoingLabelRows = new Map<string, number>()
|
|
619
|
+
const selfTransitionCounts = new Map<string, number>()
|
|
620
|
+
for (const transition of diagram.transitions) {
|
|
621
|
+
const rows = measureStateTransitionLabel(transition.label).height
|
|
622
|
+
outgoingLabelRows.set(transition.from, Math.max(outgoingLabelRows.get(transition.from) ?? 0, rows))
|
|
623
|
+
if (transition.from === transition.to) {
|
|
624
|
+
selfTransitionCounts.set(transition.from, (selfTransitionCounts.get(transition.from) ?? 0) + 1)
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const singleColumnCenter = Math.max(
|
|
629
|
+
0,
|
|
630
|
+
...rankKeys.flatMap((rank) => {
|
|
631
|
+
const states = byRank.get(rank)!
|
|
632
|
+
return states.length === 1 ? [Math.floor(sizes.get(states[0]!.id)!.width / 2)] : []
|
|
633
|
+
}),
|
|
634
|
+
)
|
|
635
|
+
let y = 0
|
|
636
|
+
for (const rank of rankKeys) {
|
|
637
|
+
const states = byRank.get(rank)!
|
|
638
|
+
const rowHeight = Math.max(...states.map((state) => sizes.get(state.id)!.height))
|
|
639
|
+
let x = 0
|
|
640
|
+
for (const state of states) {
|
|
641
|
+
const size = sizes.get(state.id)!
|
|
642
|
+
const top = y + Math.floor((rowHeight - size.height) / 2)
|
|
643
|
+
const left = states.length === 1 ? singleColumnCenter - Math.floor(size.width / 2) : x
|
|
644
|
+
bounds.set(state.id, {
|
|
645
|
+
id: state.id,
|
|
646
|
+
left,
|
|
647
|
+
top,
|
|
648
|
+
width: size.width,
|
|
649
|
+
height: size.height,
|
|
650
|
+
centerX: left + Math.floor(size.width / 2),
|
|
651
|
+
centerY: top + Math.floor(size.height / 2),
|
|
652
|
+
})
|
|
653
|
+
x += size.width + options.minStateGap + 8
|
|
654
|
+
}
|
|
655
|
+
const labelRows = states.reduce((rows, state) => Math.max(rows, outgoingLabelRows.get(state.id) ?? 0), 0)
|
|
656
|
+
const selfTransitionRows = states.reduce(
|
|
657
|
+
(rows, state) => Math.max(rows, (selfTransitionCounts.get(state.id) ?? 0) * 3 + 1),
|
|
658
|
+
0,
|
|
659
|
+
)
|
|
660
|
+
const pseudoStateApproachClearance = states.some((state) => state.kind === "choice") ? 2 : 0
|
|
661
|
+
y += rowHeight + Math.max(4, labelRows + 3, selfTransitionRows) + pseudoStateApproachClearance
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
return finalizeLayout(diagram, emptyLayout(bounds, sizes), budget)
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function createHorizontalLayout(diagram: StateDiagram, options: StateDiagramLayoutOptions): StateDiagramLayout {
|
|
668
|
+
const sizes = new Map(diagram.states.map((state) => [state.id, stateSize(state)]))
|
|
669
|
+
const bounds = new Map<string, StateDiagramBoxBounds>()
|
|
670
|
+
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
|
|
671
|
+
const mainPath = computeMainPath(diagram)
|
|
672
|
+
const mainIds = new Set(mainPath)
|
|
673
|
+
const mainPathIndex = new Map(mainPath.map((id, index) => [id, index]))
|
|
674
|
+
const baselineY = Math.max(
|
|
675
|
+
1,
|
|
676
|
+
...diagram.transitions.map((transition) => measureStateTransitionLabel(transition.label).height),
|
|
677
|
+
)
|
|
678
|
+
const defaultGap = options.minStateGap + 8
|
|
679
|
+
let x = 0
|
|
680
|
+
|
|
681
|
+
for (const [index, id] of mainPath.entries()) {
|
|
682
|
+
const state = statesById.get(id)
|
|
683
|
+
const size = sizes.get(id)
|
|
684
|
+
if (!state || !size) continue
|
|
685
|
+
const top = state.kind === "state" ? baselineY - Math.floor(size.height / 2) : baselineY
|
|
686
|
+
bounds.set(id, {
|
|
687
|
+
id,
|
|
688
|
+
left: x,
|
|
689
|
+
top,
|
|
690
|
+
width: size.width,
|
|
691
|
+
height: size.height,
|
|
692
|
+
centerX: x + Math.floor(size.width / 2),
|
|
693
|
+
centerY: top + Math.floor(size.height / 2),
|
|
694
|
+
})
|
|
695
|
+
const nextId = mainPath[index + 1]
|
|
696
|
+
const adjacentLabelWidth = diagram.transitions
|
|
697
|
+
.filter((transition) => transition.from === id && transition.to === nextId)
|
|
698
|
+
.reduce((width, transition) => Math.max(width, measureStateTransitionLabel(transition.label).width), 0)
|
|
699
|
+
const crossesCompositeBoundary = Boolean(
|
|
700
|
+
nextId && statesById.get(id)?.parentId !== statesById.get(nextId)?.parentId,
|
|
701
|
+
)
|
|
702
|
+
x += size.width + Math.max(defaultGap, adjacentLabelWidth + (crossesCompositeBoundary ? 6 : 2))
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const branchesByParent = new Map<string, string[]>()
|
|
706
|
+
for (const transition of diagram.transitions) {
|
|
707
|
+
if (!mainIds.has(transition.from) || mainIds.has(transition.to)) continue
|
|
708
|
+
const list = branchesByParent.get(transition.from) ?? []
|
|
709
|
+
if (!list.includes(transition.to)) list.push(transition.to)
|
|
710
|
+
branchesByParent.set(transition.from, list)
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
for (const [parentId, branchIds] of branchesByParent) {
|
|
714
|
+
const parent = bounds.get(parentId)
|
|
715
|
+
if (!parent) continue
|
|
716
|
+
const branchGap = defaultGap
|
|
717
|
+
const branchSizes = branchIds.map((id) => sizes.get(id)!).filter(Boolean)
|
|
718
|
+
const totalWidth =
|
|
719
|
+
branchSizes.reduce((sum, size) => sum + size.width, 0) + Math.max(0, branchSizes.length - 1) * branchGap
|
|
720
|
+
const parentIndex = mainPathIndex.get(parentId)
|
|
721
|
+
const joinIds = branchIds.map(
|
|
722
|
+
(id) =>
|
|
723
|
+
diagram.transitions.find(
|
|
724
|
+
(transition) =>
|
|
725
|
+
transition.from === id &&
|
|
726
|
+
mainIds.has(transition.to) &&
|
|
727
|
+
parentIndex !== undefined &&
|
|
728
|
+
(mainPathIndex.get(transition.to) ?? -1) > parentIndex + 1,
|
|
729
|
+
)?.to,
|
|
730
|
+
)
|
|
731
|
+
const commonJoin = joinIds[0] && joinIds.every((id) => id === joinIds[0]) ? joinIds[0] : undefined
|
|
732
|
+
const parallelLane = commonJoin && parentIndex !== undefined ? bounds.get(mainPath[parentIndex + 1]!) : undefined
|
|
733
|
+
let left = (parallelLane?.centerX ?? parent.centerX) - Math.floor(totalWidth / 2)
|
|
734
|
+
for (const branchId of branchIds) {
|
|
735
|
+
if (bounds.has(branchId)) continue
|
|
736
|
+
const size = sizes.get(branchId)
|
|
737
|
+
if (!size) continue
|
|
738
|
+
const top = availableStateTop(
|
|
739
|
+
[...bounds.values()],
|
|
740
|
+
left,
|
|
741
|
+
baselineY + (parallelLane ? 6 : 5),
|
|
742
|
+
size.width,
|
|
743
|
+
size.height,
|
|
744
|
+
)
|
|
745
|
+
bounds.set(branchId, {
|
|
746
|
+
id: branchId,
|
|
747
|
+
left,
|
|
748
|
+
top,
|
|
749
|
+
width: size.width,
|
|
750
|
+
height: size.height,
|
|
751
|
+
centerX: left + Math.floor(size.width / 2),
|
|
752
|
+
centerY: top + Math.floor(size.height / 2),
|
|
753
|
+
})
|
|
754
|
+
left += size.width + branchGap
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const ranks = computeRanks(diagram)
|
|
759
|
+
const fallbackStates = diagram.states
|
|
760
|
+
.filter((state) => !bounds.has(state.id))
|
|
761
|
+
.sort((left, right) => (ranks.get(left.id) ?? 0) - (ranks.get(right.id) ?? 0))
|
|
762
|
+
for (const state of fallbackStates) {
|
|
763
|
+
const size = sizes.get(state.id)!
|
|
764
|
+
const top = baselineY + 5
|
|
765
|
+
const rank = ranks.get(state.id) ?? bounds.size
|
|
766
|
+
let left = rank * (size.width + defaultGap)
|
|
767
|
+
while (true) {
|
|
768
|
+
const collision = [...bounds.values()].find(
|
|
769
|
+
(bound) =>
|
|
770
|
+
left < bound.left + bound.width + defaultGap &&
|
|
771
|
+
left + size.width + defaultGap > bound.left &&
|
|
772
|
+
top < bound.top + bound.height &&
|
|
773
|
+
top + size.height > bound.top,
|
|
774
|
+
)
|
|
775
|
+
if (!collision) break
|
|
776
|
+
left = collision.left + collision.width + defaultGap
|
|
777
|
+
}
|
|
778
|
+
bounds.set(state.id, {
|
|
779
|
+
id: state.id,
|
|
780
|
+
left,
|
|
781
|
+
top,
|
|
782
|
+
width: size.width,
|
|
783
|
+
height: size.height,
|
|
784
|
+
centerX: left + Math.floor(size.width / 2),
|
|
785
|
+
centerY: top + Math.floor(size.height / 2),
|
|
786
|
+
})
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const minX = Math.min(0, ...[...bounds.values()].map((bound) => bound.left))
|
|
790
|
+
if (minX < 0) {
|
|
791
|
+
for (const bound of bounds.values()) {
|
|
792
|
+
bound.left -= minX
|
|
793
|
+
bound.centerX -= minX
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
if (diagram.direction === "RL") {
|
|
798
|
+
const right = Math.max(0, ...[...bounds.values()].map((bound) => bound.left + bound.width))
|
|
799
|
+
for (const bound of bounds.values()) {
|
|
800
|
+
bound.left = right - bound.left - bound.width
|
|
801
|
+
bound.centerX = bound.left + Math.floor(bound.width / 2)
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const branchLabelGutter = Math.max(
|
|
805
|
+
0,
|
|
806
|
+
...diagram.transitions.flatMap((transition) => {
|
|
807
|
+
const from = bounds.get(transition.from)
|
|
808
|
+
const to = bounds.get(transition.to)
|
|
809
|
+
return from && to && from.centerY !== to.centerY
|
|
810
|
+
? [measureStateTransitionLabel(transition.label).width + 2]
|
|
811
|
+
: []
|
|
812
|
+
}),
|
|
813
|
+
)
|
|
814
|
+
if (branchLabelGutter > 0) {
|
|
815
|
+
shiftBounds(bounds.values(), branchLabelGutter, 0)
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
return emptyLayout(bounds, sizes)
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function availableStateTop(
|
|
823
|
+
bounds: readonly StateDiagramBoxBounds[],
|
|
824
|
+
left: number,
|
|
825
|
+
top: number,
|
|
826
|
+
width: number,
|
|
827
|
+
height: number,
|
|
828
|
+
): number {
|
|
829
|
+
let available = top
|
|
830
|
+
while (true) {
|
|
831
|
+
const collision = bounds.find((bound) => intersects(left, available, width, height, bound, 0))
|
|
832
|
+
if (!collision) return available
|
|
833
|
+
available = collision.top + collision.height + 3
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
export function expandCompositeBoundsForFeedback(
|
|
838
|
+
diagram: StateDiagram,
|
|
839
|
+
bounds: Map<string, StateDiagramBoxBounds>,
|
|
840
|
+
compositeBounds: Map<string, StateDiagramBoxBounds>,
|
|
841
|
+
feedbackLaneY: number,
|
|
842
|
+
): void {
|
|
843
|
+
if (diagram.direction !== "LR" && diagram.direction !== "RL") return
|
|
844
|
+
|
|
845
|
+
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
|
|
846
|
+
const compositesById = new Map(diagram.composites.map((composite) => [composite.id, composite]))
|
|
847
|
+
|
|
848
|
+
for (const composite of diagram.composites) {
|
|
849
|
+
const compositeBound = compositeBounds.get(composite.id)
|
|
850
|
+
if (!compositeBound) continue
|
|
851
|
+
|
|
852
|
+
const hasInternalFeedback = diagram.transitions.some((transition) => {
|
|
853
|
+
if (!belongsToComposite(transition.from, composite.id, statesById, compositesById)) return false
|
|
854
|
+
if (!belongsToComposite(transition.to, composite.id, statesById, compositesById)) return false
|
|
855
|
+
const from = bounds.get(transition.from)
|
|
856
|
+
const to = bounds.get(transition.to)
|
|
857
|
+
return Boolean(from && to && isStateHorizontalFeedback(diagram, from, to))
|
|
858
|
+
})
|
|
859
|
+
if (!hasInternalFeedback) continue
|
|
860
|
+
|
|
861
|
+
const bottom = Math.max(compositeBound.top + compositeBound.height, feedbackLaneY + 2)
|
|
862
|
+
compositeBound.height = bottom - compositeBound.top
|
|
863
|
+
compositeBound.centerY = compositeBound.top + Math.floor(compositeBound.height / 2)
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function placeStateDiagramNotesAroundTransitions(
|
|
868
|
+
diagram: StateDiagram,
|
|
869
|
+
layout: StateDiagramLayout,
|
|
870
|
+
transitionPlans: readonly StateTransitionRenderPlan[],
|
|
871
|
+
budget: StateSearchBudget,
|
|
872
|
+
): void {
|
|
873
|
+
if (diagram.notes.length === 0) return
|
|
874
|
+
const compositeIds = new Set(diagram.composites.map((composite) => composite.id))
|
|
875
|
+
let noteSpace = SpatialIndex.empty().add(
|
|
876
|
+
...[...layout.bounds.values()].flatMap((bound) =>
|
|
877
|
+
compositeIds.has(bound.id) ? [] : [spatialRectClaim(`state:${bound.id}`, `state:${bound.id}`, "body", bound)],
|
|
878
|
+
),
|
|
879
|
+
)
|
|
880
|
+
let reserved = noteSpace.add(
|
|
881
|
+
...transitionPlans.flatMap((plan, planIndex) => [
|
|
882
|
+
spatialPathClaim(
|
|
883
|
+
`transition-terminal:${planIndex}:source`,
|
|
884
|
+
`transition-terminal:${planIndex}:source`,
|
|
885
|
+
"route",
|
|
886
|
+
plan.path.slice(0, 2).map(([x, y]) => ({ x, y })),
|
|
887
|
+
),
|
|
888
|
+
spatialPathClaim(
|
|
889
|
+
`transition-terminal:${planIndex}:target`,
|
|
890
|
+
`transition-terminal:${planIndex}:target`,
|
|
891
|
+
"route",
|
|
892
|
+
plan.path.slice(-2).map(([x, y]) => ({ x, y })),
|
|
893
|
+
),
|
|
894
|
+
]),
|
|
895
|
+
)
|
|
896
|
+
let space = reserved.add(
|
|
897
|
+
...transitionPlans.flatMap((plan, planIndex) => [
|
|
898
|
+
spatialPathClaim(
|
|
899
|
+
`transition:${planIndex}`,
|
|
900
|
+
`transition:${planIndex}`,
|
|
901
|
+
"route",
|
|
902
|
+
plan.path.map(([x, y]) => ({ x, y })),
|
|
903
|
+
),
|
|
904
|
+
...(plan.label
|
|
905
|
+
? [
|
|
906
|
+
spatialRectClaim(`transition-label:${planIndex}`, `transition-label:${planIndex}`, "label", {
|
|
907
|
+
left: plan.label.x,
|
|
908
|
+
top: plan.label.y,
|
|
909
|
+
width: Math.max(...plan.label.lines.map(diagramTextWidth)),
|
|
910
|
+
height: plan.label.lines.length,
|
|
911
|
+
}),
|
|
912
|
+
]
|
|
913
|
+
: []),
|
|
914
|
+
]),
|
|
915
|
+
)
|
|
916
|
+
const noteBounds: StateDiagramNoteBounds[] = []
|
|
917
|
+
|
|
918
|
+
for (const [index, note] of diagram.notes.entries()) {
|
|
919
|
+
const target = layout.bounds.get(note.target)
|
|
920
|
+
if (!target) continue
|
|
921
|
+
const size = noteSize(note)
|
|
922
|
+
const gap = 4
|
|
923
|
+
const baseLeft = note.position === "right" ? target.left + target.width + gap : target.left - size.width - gap
|
|
924
|
+
const baseTop = target.centerY - Math.floor(size.height / 2)
|
|
925
|
+
const candidates = [
|
|
926
|
+
baseTop,
|
|
927
|
+
target.top - size.height - 2,
|
|
928
|
+
target.top + target.height + 2,
|
|
929
|
+
...Array.from({ length: 12 }, (_, distance) => [baseTop - distance - 1, baseTop + distance + 1]).flat(),
|
|
930
|
+
]
|
|
931
|
+
const candidateBounds = Array.from({ length: 5 }, (_, outward) =>
|
|
932
|
+
candidates.map((top) =>
|
|
933
|
+
createNoteBound(
|
|
934
|
+
note,
|
|
935
|
+
index,
|
|
936
|
+
baseLeft + (note.position === "right" ? 1 : -1) * outward * (size.width + 2),
|
|
937
|
+
top,
|
|
938
|
+
size,
|
|
939
|
+
),
|
|
940
|
+
),
|
|
941
|
+
).flat()
|
|
942
|
+
const findPlacement = (candidateSpace: SpatialIndex, limit: number) => {
|
|
943
|
+
const connectorSearch = createStateSearchSpace(candidateSpace, (role) => (role === "label" ? 1 : 0))
|
|
944
|
+
for (const bound of candidateBounds.slice(0, limit)) {
|
|
945
|
+
if (bound.left < 0) continue
|
|
946
|
+
const owner = `note:${index}`
|
|
947
|
+
if (!candidateSpace.isFree(spatialRectClaim(`${owner}:body`, owner, "body", bound), { clearance: 1 })) continue
|
|
948
|
+
const connector = findNoteConnector(connectorSearch, bound, target, budget)
|
|
949
|
+
if (connector) return { bound: { ...bound, connector }, connector }
|
|
950
|
+
}
|
|
951
|
+
return undefined
|
|
952
|
+
}
|
|
953
|
+
const placement =
|
|
954
|
+
findPlacement(space, 1) ??
|
|
955
|
+
findPlacement(noteSpace, 1) ??
|
|
956
|
+
findPlacement(space, MAX_STRICT_NOTE_PLACEMENTS) ??
|
|
957
|
+
findPlacement(reserved, candidateBounds.length) ??
|
|
958
|
+
outsideNotePlacement(noteSpace, note, index, target, size)
|
|
959
|
+
const owner = `note:${index}`
|
|
960
|
+
const claims = [
|
|
961
|
+
spatialRectClaim(`${owner}:body`, owner, "body", placement.bound),
|
|
962
|
+
spatialPathClaim(`${owner}:connector`, owner, "boundary", placement.connector.points),
|
|
963
|
+
] as const
|
|
964
|
+
noteBounds.push(placement.bound)
|
|
965
|
+
noteSpace = noteSpace.add(...claims)
|
|
966
|
+
reserved = reserved.add(...claims)
|
|
967
|
+
space = space.add(...claims)
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
layout.noteBounds.splice(0, layout.noteBounds.length, ...noteBounds)
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function outsideNotePlacement(
|
|
974
|
+
space: SpatialIndex,
|
|
975
|
+
note: StateDiagramNote,
|
|
976
|
+
index: number,
|
|
977
|
+
target: StateDiagramBoxBounds,
|
|
978
|
+
size: { width: number; height: number; lines: string[] },
|
|
979
|
+
): { bound: StateDiagramNoteBounds; connector: StateDiagramNoteConnector } {
|
|
980
|
+
const search = createStateSearchSpace(space)
|
|
981
|
+
const top = search.maxY + 4
|
|
982
|
+
const owner = `note:${index}:outside`
|
|
983
|
+
|
|
984
|
+
for (const position of [note.position, note.position === "left" ? "right" : "left"] as const) {
|
|
985
|
+
const aligned = createNoteBound(
|
|
986
|
+
{ ...note, position },
|
|
987
|
+
index,
|
|
988
|
+
position === "left" ? search.minX - size.width - 4 : search.maxX + 4,
|
|
989
|
+
target.centerY - Math.floor(size.height / 2),
|
|
990
|
+
size,
|
|
991
|
+
)
|
|
992
|
+
const alignedNoteX = position === "left" ? aligned.left + aligned.width : aligned.left - 1
|
|
993
|
+
const alignedConnectorY = Math.max(aligned.top + 1, Math.min(target.centerY, aligned.top + aligned.height - 2))
|
|
994
|
+
const alignedTargetX = position === "left" ? target.left - 1 : target.left + target.width
|
|
995
|
+
const alignedConnector = {
|
|
996
|
+
connectorY: alignedConnectorY,
|
|
997
|
+
points: [
|
|
998
|
+
{ x: alignedTargetX, y: target.centerY },
|
|
999
|
+
{ x: alignedNoteX, y: alignedConnectorY },
|
|
1000
|
+
],
|
|
1001
|
+
}
|
|
1002
|
+
if (
|
|
1003
|
+
space.isFree(spatialRectClaim(`${owner}:body`, owner, "body", aligned), { clearance: 1 }) &&
|
|
1004
|
+
space.isFree(spatialPathClaim(`${owner}:connector`, owner, "boundary", alignedConnector.points))
|
|
1005
|
+
)
|
|
1006
|
+
return { bound: { ...aligned, connector: alignedConnector }, connector: alignedConnector }
|
|
1007
|
+
|
|
1008
|
+
const railX = position === "left" ? search.minX - 2 : search.maxX + 2
|
|
1009
|
+
const bound = createNoteBound(
|
|
1010
|
+
{ ...note, position },
|
|
1011
|
+
index,
|
|
1012
|
+
position === "left" ? railX - size.width - 2 : railX + 3,
|
|
1013
|
+
top,
|
|
1014
|
+
size,
|
|
1015
|
+
)
|
|
1016
|
+
const noteX = position === "left" ? bound.left + bound.width : bound.left - 1
|
|
1017
|
+
const connectorY = bound.top + 1
|
|
1018
|
+
const sideX = position === "left" ? target.left - 1 : target.left + target.width
|
|
1019
|
+
const escapes = [
|
|
1020
|
+
...[target.top, target.centerY, target.top + target.height - 1].map((y) => [
|
|
1021
|
+
{ x: sideX, y },
|
|
1022
|
+
{ x: railX, y },
|
|
1023
|
+
]),
|
|
1024
|
+
[
|
|
1025
|
+
{ x: target.centerX, y: target.top - 1 },
|
|
1026
|
+
{ x: railX, y: target.top - 1 },
|
|
1027
|
+
],
|
|
1028
|
+
[
|
|
1029
|
+
{ x: target.centerX, y: target.top + target.height },
|
|
1030
|
+
{ x: railX, y: target.top + target.height },
|
|
1031
|
+
],
|
|
1032
|
+
]
|
|
1033
|
+
|
|
1034
|
+
for (const escape of escapes) {
|
|
1035
|
+
const connector = {
|
|
1036
|
+
connectorY,
|
|
1037
|
+
points: [...escape, { x: railX, y: connectorY }, { x: noteX, y: connectorY }],
|
|
1038
|
+
}
|
|
1039
|
+
if (
|
|
1040
|
+
space.isFree(spatialRectClaim(`${owner}:body`, owner, "body", bound), { clearance: 1 }) &&
|
|
1041
|
+
space.isFree(spatialPathClaim(`${owner}:connector`, owner, "boundary", connector.points))
|
|
1042
|
+
)
|
|
1043
|
+
return { bound: { ...bound, connector }, connector }
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
for (const vertical of ["below", "above"] as const) {
|
|
1048
|
+
const railY = vertical === "below" ? search.maxY + 2 : search.minY - 2
|
|
1049
|
+
const bound = createNoteBound(
|
|
1050
|
+
note,
|
|
1051
|
+
index,
|
|
1052
|
+
note.position === "left" ? search.minX - size.width - 4 : search.maxX + 4,
|
|
1053
|
+
vertical === "below" ? railY + 2 : railY - size.height - 2,
|
|
1054
|
+
size,
|
|
1055
|
+
)
|
|
1056
|
+
const noteX = note.position === "left" ? bound.left + bound.width : bound.left - 1
|
|
1057
|
+
const connectorY = bound.top + 1
|
|
1058
|
+
const targetY = vertical === "below" ? target.top + target.height : target.top - 1
|
|
1059
|
+
const escapes = [
|
|
1060
|
+
...[target.left, target.centerX, target.left + target.width - 1].map((x) => [
|
|
1061
|
+
{ x, y: targetY },
|
|
1062
|
+
{ x, y: railY },
|
|
1063
|
+
]),
|
|
1064
|
+
[
|
|
1065
|
+
{ x: target.left, y: targetY },
|
|
1066
|
+
{ x: target.left - 1, y: targetY },
|
|
1067
|
+
{ x: target.left - 1, y: railY },
|
|
1068
|
+
],
|
|
1069
|
+
[
|
|
1070
|
+
{ x: target.left + target.width - 1, y: targetY },
|
|
1071
|
+
{ x: target.left + target.width, y: targetY },
|
|
1072
|
+
{ x: target.left + target.width, y: railY },
|
|
1073
|
+
],
|
|
1074
|
+
]
|
|
1075
|
+
|
|
1076
|
+
for (const escape of escapes) {
|
|
1077
|
+
const connector = {
|
|
1078
|
+
connectorY,
|
|
1079
|
+
points: [...escape, { x: noteX, y: railY }, { x: noteX, y: connectorY }],
|
|
1080
|
+
}
|
|
1081
|
+
if (
|
|
1082
|
+
space.isFree(spatialRectClaim(`${owner}:body`, owner, "body", bound), { clearance: 1 }) &&
|
|
1083
|
+
space.isFree(spatialPathClaim(`${owner}:connector`, owner, "boundary", connector.points))
|
|
1084
|
+
)
|
|
1085
|
+
return { bound: { ...bound, connector }, connector }
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
throw new Error(`State ${note.target} has no exterior note corridor`)
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
export function expandCompositeBoundsForInternalTransitions(
|
|
1093
|
+
diagram: StateDiagram,
|
|
1094
|
+
compositeBounds: Map<string, StateDiagramBoxBounds>,
|
|
1095
|
+
transitionPlans: readonly StateTransitionRenderPlan[],
|
|
1096
|
+
): void {
|
|
1097
|
+
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
|
|
1098
|
+
const compositesById = new Map(diagram.composites.map((composite) => [composite.id, composite]))
|
|
1099
|
+
|
|
1100
|
+
for (const composite of diagram.composites) {
|
|
1101
|
+
const bound = compositeBounds.get(composite.id)
|
|
1102
|
+
if (!bound) continue
|
|
1103
|
+
const internalPlans = transitionPlans.filter(
|
|
1104
|
+
(plan) =>
|
|
1105
|
+
belongsToComposite(plan.route.transition.from, composite.id, statesById, compositesById) &&
|
|
1106
|
+
belongsToComposite(plan.route.transition.to, composite.id, statesById, compositesById),
|
|
1107
|
+
)
|
|
1108
|
+
const occupied = internalPlans.flatMap((plan) => [
|
|
1109
|
+
...plan.cells.map((cell) => ({ x: cell.x, y: cell.y })),
|
|
1110
|
+
...(plan.label
|
|
1111
|
+
? plan.label.lines.flatMap((line, row) =>
|
|
1112
|
+
Array.from({ length: diagramTextWidth(line) }, (_, column) => ({
|
|
1113
|
+
x: plan.label!.x + column,
|
|
1114
|
+
y: plan.label!.y + row,
|
|
1115
|
+
})),
|
|
1116
|
+
)
|
|
1117
|
+
: []),
|
|
1118
|
+
])
|
|
1119
|
+
if (occupied.length === 0) continue
|
|
1120
|
+
|
|
1121
|
+
const left = Math.min(bound.left, Math.min(...occupied.map((point) => point.x)) - 1)
|
|
1122
|
+
const top = Math.min(bound.top, Math.min(...occupied.map((point) => point.y)) - 1)
|
|
1123
|
+
const right = Math.max(bound.left + bound.width, Math.max(...occupied.map((point) => point.x)) + 2)
|
|
1124
|
+
const bottom = Math.max(bound.top + bound.height, Math.max(...occupied.map((point) => point.y)) + 2)
|
|
1125
|
+
bound.left = left
|
|
1126
|
+
bound.top = top
|
|
1127
|
+
bound.width = right - left
|
|
1128
|
+
bound.height = bottom - top
|
|
1129
|
+
bound.centerX = bound.left + Math.floor(bound.width / 2)
|
|
1130
|
+
bound.centerY = bound.top + Math.floor(bound.height / 2)
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
enforceCompositeMargins(diagram, compositeBounds)
|
|
1134
|
+
}
|