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,1522 @@
|
|
|
1
|
+
import { BorderChars } from "@opentui/core"
|
|
2
|
+
import { diagramLineGlyph } from "../core/drawing.js"
|
|
3
|
+
import { orthogonalPathPoints, type DiagramDirection } from "../core/geometry.js"
|
|
4
|
+
import { SpatialIndex, spatialPathClaim, spatialRectClaim } from "../core/spatial.js"
|
|
5
|
+
import { diagramTextWidth, splitDiagramLines } from "../core/text.js"
|
|
6
|
+
import type { StateDiagramBoxBounds as BoxBounds, StateDiagramNoteBounds } from "./layout.js"
|
|
7
|
+
import { stateDiagramNoteConnector } from "./note.js"
|
|
8
|
+
import {
|
|
9
|
+
createStateSearchBudget,
|
|
10
|
+
createStateSearchSpace,
|
|
11
|
+
findStateManhattanPath,
|
|
12
|
+
type StateSearchBudget,
|
|
13
|
+
type StateSearchSpace,
|
|
14
|
+
} from "./search.js"
|
|
15
|
+
import type { StateDiagram, StateDiagramState, StateDiagramTransition } from "./types.js"
|
|
16
|
+
import { isHiddenCompositeMarker, type StateVisibleDiagram, type StateVisibleTransition } from "./visible-model.js"
|
|
17
|
+
|
|
18
|
+
interface StateTransitionRoutePlanBase {
|
|
19
|
+
transition: StateVisibleTransition
|
|
20
|
+
from: BoxBounds
|
|
21
|
+
to: BoxBounds
|
|
22
|
+
targetIsChoice: boolean
|
|
23
|
+
targetIsHiddenMarker: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type StateTransitionRoutePlan =
|
|
27
|
+
| (StateTransitionRoutePlanBase & { kind: "self"; lane: number })
|
|
28
|
+
| (StateTransitionRoutePlanBase & { kind: "horizontal-forward"; leftToRight: boolean })
|
|
29
|
+
| (StateTransitionRoutePlanBase & { kind: "bottom-feedback"; railY: number; approachX: number })
|
|
30
|
+
| (StateTransitionRoutePlanBase & { kind: "top-feedback"; railY: number })
|
|
31
|
+
| (StateTransitionRoutePlanBase & { kind: "bottom-parallel"; railY: number; approachX: number })
|
|
32
|
+
| (StateTransitionRoutePlanBase & { kind: "vertical-elbow"; hasReverse: boolean; offsetConnector: boolean })
|
|
33
|
+
| (StateTransitionRoutePlanBase & {
|
|
34
|
+
kind: "side-parallel"
|
|
35
|
+
railX: number
|
|
36
|
+
targetApproach?: "top" | "bottom"
|
|
37
|
+
})
|
|
38
|
+
| (StateTransitionRoutePlanBase & { kind: "vertical" })
|
|
39
|
+
|
|
40
|
+
export type StateTransitionPathPoint = readonly [number, number]
|
|
41
|
+
|
|
42
|
+
interface StateTransitionRenderCellBase {
|
|
43
|
+
x: number
|
|
44
|
+
y: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type StateTransitionRenderCell = StateTransitionRenderCellBase &
|
|
48
|
+
({ char: string; arrowDirection?: never } | { char?: never; arrowDirection: DiagramDirection })
|
|
49
|
+
|
|
50
|
+
export interface StateTransitionRenderLabel {
|
|
51
|
+
x: number
|
|
52
|
+
y: number
|
|
53
|
+
lines: readonly string[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface StateTransitionRenderPlan {
|
|
57
|
+
route: StateTransitionRoutePlan
|
|
58
|
+
cells: readonly StateTransitionRenderCell[]
|
|
59
|
+
path: readonly StateTransitionPathPoint[]
|
|
60
|
+
label?: StateTransitionRenderLabel
|
|
61
|
+
pathRepaired?: boolean
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface StateTransitionRenderOptions {
|
|
65
|
+
feedbackTopY?: number
|
|
66
|
+
noteBounds?: readonly StateDiagramNoteBounds[]
|
|
67
|
+
repairRoutes?: boolean
|
|
68
|
+
searchBudget?: StateSearchBudget
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface StateTransitionJunctionPlan {
|
|
72
|
+
state: StateDiagramState
|
|
73
|
+
bounds: BoxBounds
|
|
74
|
+
connections: ReadonlySet<DiagramDirection>
|
|
75
|
+
transitions: readonly StateVisibleTransition[]
|
|
76
|
+
kind: "choice" | "fork" | "join" | "hidden-composite-marker"
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface StateTransitionRenderBuilder {
|
|
80
|
+
route: StateTransitionRoutePlan
|
|
81
|
+
cells: StateTransitionRenderCell[]
|
|
82
|
+
path: StateTransitionPathPoint[]
|
|
83
|
+
label?: StateTransitionRenderLabel
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function measureStateTransitionLabel(label: string): { lines: string[]; width: number; height: number } {
|
|
87
|
+
if (!label) return { lines: [], width: 0, height: 0 }
|
|
88
|
+
const lines = splitDiagramLines(label)
|
|
89
|
+
return { lines, width: Math.max(...lines.map(diagramTextWidth)), height: lines.length }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function hasReverseTransition(diagram: StateDiagram, transition: StateDiagramTransition): boolean {
|
|
93
|
+
return diagram.transitions.some((other) => other.from === transition.to && other.to === transition.from)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function isStateHorizontalFeedback(
|
|
97
|
+
diagram: Pick<StateDiagram, "direction">,
|
|
98
|
+
from: BoxBounds,
|
|
99
|
+
to: BoxBounds,
|
|
100
|
+
): boolean {
|
|
101
|
+
if (diagram.direction === "RL") return to.centerX > from.centerX
|
|
102
|
+
return to.centerX < from.centerX
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface FeedbackAllocation {
|
|
106
|
+
side: "bottom" | "top"
|
|
107
|
+
railY: number
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface AllocatedFeedbackInterval extends FeedbackAllocation {
|
|
111
|
+
left: number
|
|
112
|
+
right: number
|
|
113
|
+
lane: number
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface FeedbackInterval {
|
|
117
|
+
transition: StateVisibleTransition
|
|
118
|
+
left: number
|
|
119
|
+
right: number
|
|
120
|
+
side?: "bottom" | "top"
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function feedbackIntervalsOverlap(
|
|
124
|
+
left: { left: number; right: number },
|
|
125
|
+
right: { left: number; right: number },
|
|
126
|
+
): boolean {
|
|
127
|
+
return left.left <= right.right && right.left <= left.right
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function feedbackIntervalsCross(
|
|
131
|
+
left: { left: number; right: number },
|
|
132
|
+
right: { left: number; right: number },
|
|
133
|
+
): boolean {
|
|
134
|
+
return (
|
|
135
|
+
(left.left < right.left && right.left < left.right && left.right < right.right) ||
|
|
136
|
+
(right.left < left.left && left.left < right.right && right.right < left.right)
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function createFeedbackAllocations(
|
|
141
|
+
diagram: StateVisibleDiagram,
|
|
142
|
+
bounds: ReadonlyMap<string, BoxBounds>,
|
|
143
|
+
feedbackLaneY: number,
|
|
144
|
+
laneGap: number,
|
|
145
|
+
feedbackTopY?: number,
|
|
146
|
+
): ReadonlyMap<StateVisibleTransition, FeedbackAllocation> {
|
|
147
|
+
if (diagram.direction !== "LR" && diagram.direction !== "RL") return new Map()
|
|
148
|
+
const allocations = new Map<StateVisibleTransition, FeedbackAllocation>()
|
|
149
|
+
const sidedIntervals: Record<"bottom" | "top", FeedbackInterval[]> = { bottom: [], top: [] }
|
|
150
|
+
const canonicalSides = new Map<string, "bottom" | "top">()
|
|
151
|
+
const topLaneY = feedbackTopY ?? Math.min(...[...bounds.values()].map((bound) => bound.top)) - 3
|
|
152
|
+
const intervals: FeedbackInterval[] = []
|
|
153
|
+
|
|
154
|
+
for (const transition of diagram.transitions) {
|
|
155
|
+
const from = bounds.get(transition.from)
|
|
156
|
+
const to = bounds.get(transition.to)
|
|
157
|
+
if (!from || !to || transition.from === transition.to || !isStateHorizontalFeedback(diagram, from, to)) continue
|
|
158
|
+
if (from.centerY !== to.centerY && !(from.centerY > to.centerY)) continue
|
|
159
|
+
|
|
160
|
+
intervals.push({ transition, left: Math.min(from.centerX, to.centerX), right: Math.max(from.centerX, to.centerX) })
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
for (const interval of intervals) {
|
|
164
|
+
const endpointKey = `${interval.transition.from}\u0000${interval.transition.to}`
|
|
165
|
+
const side =
|
|
166
|
+
canonicalSides.get(endpointKey) ??
|
|
167
|
+
(["bottom", "top"] as const).find(
|
|
168
|
+
(candidate) => !sidedIntervals[candidate].some((existing) => feedbackIntervalsCross(existing, interval)),
|
|
169
|
+
)
|
|
170
|
+
if (!side) continue
|
|
171
|
+
canonicalSides.set(endpointKey, side)
|
|
172
|
+
interval.side = side
|
|
173
|
+
sidedIntervals[side].push(interval)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for (const side of ["bottom", "top"] as const) {
|
|
177
|
+
const occupied: AllocatedFeedbackInterval[] = []
|
|
178
|
+
const intervalsByWidth = [...sidedIntervals[side]].sort(
|
|
179
|
+
(left, right) => left.right - left.left - (right.right - right.left),
|
|
180
|
+
)
|
|
181
|
+
for (const interval of intervalsByWidth) {
|
|
182
|
+
let lane = 0
|
|
183
|
+
while (occupied.some((existing) => existing.lane === lane && feedbackIntervalsOverlap(existing, interval))) lane++
|
|
184
|
+
const railY = side === "bottom" ? feedbackLaneY + lane * laneGap : topLaneY - lane * laneGap
|
|
185
|
+
occupied.push({ ...interval, side, lane, railY })
|
|
186
|
+
allocations.set(interval.transition, { side, railY })
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return allocations
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function hasOpposingTopConnector(
|
|
194
|
+
diagram: StateVisibleDiagram,
|
|
195
|
+
transition: StateVisibleTransition,
|
|
196
|
+
bounds: ReadonlyMap<string, BoxBounds>,
|
|
197
|
+
): boolean {
|
|
198
|
+
const from = bounds.get(transition.from)
|
|
199
|
+
const to = bounds.get(transition.to)
|
|
200
|
+
if (!from || !to || from.centerY === to.centerY) return false
|
|
201
|
+
|
|
202
|
+
const lowerId = from.centerY > to.centerY ? transition.from : transition.to
|
|
203
|
+
const lower = bounds.get(lowerId)!
|
|
204
|
+
const leavesLower = transition.from === lowerId
|
|
205
|
+
return diagram.transitions.some((other) => {
|
|
206
|
+
if (other === transition || (leavesLower ? other.to !== lowerId : other.from !== lowerId)) return false
|
|
207
|
+
const otherFrom = bounds.get(other.from)
|
|
208
|
+
const otherTo = bounds.get(other.to)
|
|
209
|
+
if (!otherFrom || !otherTo || Math.max(otherFrom.centerY, otherTo.centerY) !== lower.centerY) return false
|
|
210
|
+
if (other.from !== lowerId) return true
|
|
211
|
+
return !(
|
|
212
|
+
(diagram.direction === "LR" || diagram.direction === "RL") &&
|
|
213
|
+
isStateHorizontalFeedback(diagram, otherFrom, otherTo)
|
|
214
|
+
)
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function verticalCorridorCrossesUnrelatedState(
|
|
219
|
+
diagram: StateVisibleDiagram,
|
|
220
|
+
transition: StateVisibleTransition,
|
|
221
|
+
from: BoxBounds,
|
|
222
|
+
to: BoxBounds,
|
|
223
|
+
bounds: ReadonlyMap<string, BoxBounds>,
|
|
224
|
+
): boolean {
|
|
225
|
+
const top = Math.min(from.top + from.height, to.top + to.height)
|
|
226
|
+
const bottom = Math.max(from.top - 1, to.top - 1)
|
|
227
|
+
return diagram.states.some((state) => {
|
|
228
|
+
if (state.id === transition.from || state.id === transition.to || isHiddenCompositeMarker(state)) return false
|
|
229
|
+
const bound = bounds.get(state.id)
|
|
230
|
+
return Boolean(
|
|
231
|
+
bound &&
|
|
232
|
+
from.centerX >= bound.left &&
|
|
233
|
+
from.centerX < bound.left + bound.width &&
|
|
234
|
+
top < bound.top + bound.height &&
|
|
235
|
+
bottom >= bound.top,
|
|
236
|
+
)
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function hasVerticalCorridor(from: BoxBounds, to: BoxBounds): boolean {
|
|
241
|
+
if (from.centerY < to.centerY) return from.top + from.height <= to.top - 1
|
|
242
|
+
return from.top - 1 >= to.top + to.height
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function horizontalCorridorCrossesUnrelatedState(
|
|
246
|
+
diagram: StateVisibleDiagram,
|
|
247
|
+
transition: StateVisibleTransition,
|
|
248
|
+
from: BoxBounds,
|
|
249
|
+
to: BoxBounds,
|
|
250
|
+
bounds: ReadonlyMap<string, BoxBounds>,
|
|
251
|
+
): boolean {
|
|
252
|
+
const leftToRight = from.centerX <= to.centerX
|
|
253
|
+
const startX = leftToRight ? from.left + from.width : from.left - 1
|
|
254
|
+
const endX = leftToRight ? to.left - 1 : to.left + to.width
|
|
255
|
+
const space = SpatialIndex.empty().add(
|
|
256
|
+
...diagram.states.flatMap((state) => {
|
|
257
|
+
if (state.id === transition.from || state.id === transition.to || isHiddenCompositeMarker(state)) return []
|
|
258
|
+
const bound = bounds.get(state.id)
|
|
259
|
+
return bound ? [spatialRectClaim(`state:${state.id}`, `state:${state.id}`, "body", bound)] : []
|
|
260
|
+
}),
|
|
261
|
+
)
|
|
262
|
+
const corridor = spatialPathClaim(
|
|
263
|
+
`corridor:${transition.from}:${transition.to}`,
|
|
264
|
+
`transition:${transition.from}:${transition.to}`,
|
|
265
|
+
"route",
|
|
266
|
+
[
|
|
267
|
+
{ x: startX, y: from.centerY },
|
|
268
|
+
{ x: endX, y: from.centerY },
|
|
269
|
+
],
|
|
270
|
+
)
|
|
271
|
+
return !space.isFree(corridor)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function bottomApproachX(
|
|
275
|
+
diagram: StateVisibleDiagram,
|
|
276
|
+
transition: StateVisibleTransition,
|
|
277
|
+
from: BoxBounds,
|
|
278
|
+
to: BoxBounds,
|
|
279
|
+
bounds: ReadonlyMap<string, BoxBounds>,
|
|
280
|
+
railY: number,
|
|
281
|
+
): number {
|
|
282
|
+
const targetX = to.width > 1 ? (from.centerX > to.centerX ? to.left + 1 : to.left + to.width - 2) : to.centerX
|
|
283
|
+
const targetBottomY = to.top + to.height
|
|
284
|
+
const top = Math.min(targetBottomY, railY)
|
|
285
|
+
const bottom = Math.max(targetBottomY, railY)
|
|
286
|
+
const isClear = (x: number): boolean =>
|
|
287
|
+
!diagram.states.some((state) => {
|
|
288
|
+
if (state.id === transition.from || state.id === transition.to || isHiddenCompositeMarker(state)) return false
|
|
289
|
+
const bound = bounds.get(state.id)
|
|
290
|
+
return Boolean(
|
|
291
|
+
bound &&
|
|
292
|
+
x >= bound.left &&
|
|
293
|
+
x < bound.left + bound.width &&
|
|
294
|
+
top < bound.top + bound.height &&
|
|
295
|
+
bottom >= bound.top,
|
|
296
|
+
)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
if (isClear(targetX)) return targetX
|
|
300
|
+
const maxX = Math.max(targetX, ...[...bounds.values()].map((bound) => bound.left + bound.width)) + 1
|
|
301
|
+
for (let distance = 1; distance <= maxX; distance++) {
|
|
302
|
+
const right = targetX + distance
|
|
303
|
+
if (isClear(right)) return right
|
|
304
|
+
const left = targetX - distance
|
|
305
|
+
if (left >= 0 && isClear(left)) return left
|
|
306
|
+
}
|
|
307
|
+
return targetX
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function sideParallelTargetApproach(
|
|
311
|
+
diagram: StateVisibleDiagram,
|
|
312
|
+
transition: StateVisibleTransition,
|
|
313
|
+
from: BoxBounds,
|
|
314
|
+
to: BoxBounds,
|
|
315
|
+
bounds: ReadonlyMap<string, BoxBounds>,
|
|
316
|
+
railX: number,
|
|
317
|
+
): "top" | "bottom" | undefined {
|
|
318
|
+
const space = SpatialIndex.empty().add(
|
|
319
|
+
...diagram.states.flatMap((state) => {
|
|
320
|
+
if (state.id === transition.from || state.id === transition.to || isHiddenCompositeMarker(state)) return []
|
|
321
|
+
const bound = bounds.get(state.id)
|
|
322
|
+
return bound ? [spatialRectClaim(`state:${state.id}`, `state:${state.id}`, "body", bound)] : []
|
|
323
|
+
}),
|
|
324
|
+
)
|
|
325
|
+
const targetSideX = to.left + to.width
|
|
326
|
+
const claim = (points: readonly { x: number; y: number }[]) =>
|
|
327
|
+
spatialPathClaim(`side-target:${transition.from}:${transition.to}`, "side-target", "route", points)
|
|
328
|
+
|
|
329
|
+
if (
|
|
330
|
+
space.isFree(
|
|
331
|
+
claim([
|
|
332
|
+
{ x: railX, y: to.centerY },
|
|
333
|
+
{ x: targetSideX, y: to.centerY },
|
|
334
|
+
]),
|
|
335
|
+
)
|
|
336
|
+
)
|
|
337
|
+
return undefined
|
|
338
|
+
|
|
339
|
+
const targetX = innerConnectorX(to, from.centerX)
|
|
340
|
+
const preferred = from.centerY > to.centerY ? "top" : "bottom"
|
|
341
|
+
return ([preferred, preferred === "top" ? "bottom" : "top"] as const).find((side) => {
|
|
342
|
+
const railY = side === "top" ? to.top - 2 : to.top + to.height + 1
|
|
343
|
+
const targetY = side === "top" ? to.top - 1 : to.top + to.height
|
|
344
|
+
return space.isFree(
|
|
345
|
+
claim([
|
|
346
|
+
{ x: railX, y: railY },
|
|
347
|
+
{ x: targetX, y: railY },
|
|
348
|
+
{ x: targetX, y: targetY },
|
|
349
|
+
]),
|
|
350
|
+
)
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function containingCompositeIds(diagram: StateVisibleDiagram, id: string): string[] {
|
|
355
|
+
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
|
|
356
|
+
const compositesById = new Map(diagram.composites.map((composite) => [composite.id, composite]))
|
|
357
|
+
const ids: string[] = []
|
|
358
|
+
let parentId = statesById.get(id)?.parentId ?? compositesById.get(id)?.parentId
|
|
359
|
+
while (parentId) {
|
|
360
|
+
ids.push(parentId)
|
|
361
|
+
parentId = compositesById.get(parentId)?.parentId
|
|
362
|
+
}
|
|
363
|
+
return ids
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function innermostCommonComposite(
|
|
367
|
+
diagram: StateVisibleDiagram,
|
|
368
|
+
transition: StateVisibleTransition,
|
|
369
|
+
): string | undefined {
|
|
370
|
+
const target = new Set(containingCompositeIds(diagram, transition.to))
|
|
371
|
+
return containingCompositeIds(diagram, transition.from).find((id) => target.has(id))
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function createStateTransitionRoutePlans(
|
|
375
|
+
diagram: StateVisibleDiagram,
|
|
376
|
+
bounds: ReadonlyMap<string, BoxBounds>,
|
|
377
|
+
feedbackLaneY: number,
|
|
378
|
+
feedbackTopY?: number,
|
|
379
|
+
): StateTransitionRoutePlan[] {
|
|
380
|
+
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
|
|
381
|
+
const endpointOccurrences = new Map<string, number>()
|
|
382
|
+
const selfOccurrences = new Map<string, number>()
|
|
383
|
+
const parallelLaneGap = Math.max(
|
|
384
|
+
3,
|
|
385
|
+
...diagram.transitions.map((transition) => measureStateTransitionLabel(transition.label).height + 2),
|
|
386
|
+
)
|
|
387
|
+
let nextSideRailX = Math.max(0, ...[...bounds.values()].map((bound) => bound.left + bound.width)) + 3
|
|
388
|
+
const sideRailLanes = new Map<string, number>()
|
|
389
|
+
const feedbackAllocations = createFeedbackAllocations(diagram, bounds, feedbackLaneY, parallelLaneGap, feedbackTopY)
|
|
390
|
+
let nextBottomRailY =
|
|
391
|
+
Math.max(
|
|
392
|
+
feedbackLaneY - parallelLaneGap,
|
|
393
|
+
...[...feedbackAllocations.values()]
|
|
394
|
+
.filter((allocation) => allocation.side === "bottom")
|
|
395
|
+
.map((allocation) => allocation.railY),
|
|
396
|
+
) + parallelLaneGap
|
|
397
|
+
const allocateSideRail = (transition: StateVisibleTransition): number => {
|
|
398
|
+
const compositeId = innermostCommonComposite(diagram, transition)
|
|
399
|
+
const composite = compositeId ? bounds.get(compositeId) : undefined
|
|
400
|
+
if (compositeId && composite) {
|
|
401
|
+
const lane = sideRailLanes.get(compositeId) ?? 0
|
|
402
|
+
sideRailLanes.set(compositeId, lane + 1)
|
|
403
|
+
const descendantRight = Math.max(
|
|
404
|
+
composite.left + 1,
|
|
405
|
+
...diagram.states.flatMap((state) => {
|
|
406
|
+
if (!containingCompositeIds(diagram, state.id).includes(compositeId)) return []
|
|
407
|
+
const bound = bounds.get(state.id)
|
|
408
|
+
return bound ? [bound.left + bound.width] : []
|
|
409
|
+
}),
|
|
410
|
+
)
|
|
411
|
+
return Math.min(descendantRight + 2 + lane * 3, composite.left + composite.width - 2)
|
|
412
|
+
}
|
|
413
|
+
const railX = nextSideRailX
|
|
414
|
+
nextSideRailX += 3
|
|
415
|
+
return railX
|
|
416
|
+
}
|
|
417
|
+
const allocateBottomRail = (): number => {
|
|
418
|
+
const railY = nextBottomRailY
|
|
419
|
+
nextBottomRailY += parallelLaneGap
|
|
420
|
+
return railY
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return diagram.transitions.flatMap((transition): StateTransitionRoutePlan[] => {
|
|
424
|
+
const from = bounds.get(transition.from)
|
|
425
|
+
const to = bounds.get(transition.to)
|
|
426
|
+
if (!from || !to) return []
|
|
427
|
+
|
|
428
|
+
const targetState = statesById.get(transition.to)
|
|
429
|
+
const targetIsChoice = targetState?.kind === "choice"
|
|
430
|
+
const targetIsHiddenMarker = isHiddenCompositeMarker(targetState)
|
|
431
|
+
const base = { transition, from, to, targetIsChoice, targetIsHiddenMarker }
|
|
432
|
+
const sideParallel = (): StateTransitionRoutePlan => {
|
|
433
|
+
const railX = allocateSideRail(transition)
|
|
434
|
+
return {
|
|
435
|
+
...base,
|
|
436
|
+
kind: "side-parallel",
|
|
437
|
+
railX,
|
|
438
|
+
targetApproach: sideParallelTargetApproach(diagram, transition, from, to, bounds, railX),
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (transition.from === transition.to) {
|
|
442
|
+
const lane = selfOccurrences.get(transition.from) ?? 0
|
|
443
|
+
selfOccurrences.set(transition.from, lane + 1)
|
|
444
|
+
return [{ ...base, kind: "self", lane }]
|
|
445
|
+
}
|
|
446
|
+
const endpointKey = `${transition.from}\u0000${transition.to}`
|
|
447
|
+
const parallelIndex = endpointOccurrences.get(endpointKey) ?? 0
|
|
448
|
+
endpointOccurrences.set(endpointKey, parallelIndex + 1)
|
|
449
|
+
const feedback =
|
|
450
|
+
(diagram.direction === "LR" || diagram.direction === "RL") && isStateHorizontalFeedback(diagram, from, to)
|
|
451
|
+
const feedbackAllocation = feedbackAllocations.get(transition)
|
|
452
|
+
if (feedbackAllocation) {
|
|
453
|
+
if (feedbackAllocation.side === "bottom") {
|
|
454
|
+
return [
|
|
455
|
+
{
|
|
456
|
+
...base,
|
|
457
|
+
kind: "bottom-feedback",
|
|
458
|
+
railY: feedbackAllocation.railY,
|
|
459
|
+
approachX: bottomApproachX(diagram, transition, from, to, bounds, feedbackAllocation.railY),
|
|
460
|
+
},
|
|
461
|
+
]
|
|
462
|
+
}
|
|
463
|
+
return [
|
|
464
|
+
{
|
|
465
|
+
...base,
|
|
466
|
+
kind: "top-feedback",
|
|
467
|
+
railY: feedbackAllocation.railY,
|
|
468
|
+
},
|
|
469
|
+
]
|
|
470
|
+
}
|
|
471
|
+
if (parallelIndex > 0) {
|
|
472
|
+
if (from.centerY === to.centerY) {
|
|
473
|
+
const railY = allocateBottomRail()
|
|
474
|
+
return [
|
|
475
|
+
{
|
|
476
|
+
...base,
|
|
477
|
+
kind: "bottom-parallel",
|
|
478
|
+
railY,
|
|
479
|
+
approachX: bottomApproachX(diagram, transition, from, to, bounds, railY),
|
|
480
|
+
},
|
|
481
|
+
]
|
|
482
|
+
}
|
|
483
|
+
return [sideParallel()]
|
|
484
|
+
}
|
|
485
|
+
if (diagram.direction !== "LR" && diagram.direction !== "RL") {
|
|
486
|
+
const fromParent = statesById.get(transition.from)?.parentId
|
|
487
|
+
const toParent = statesById.get(transition.to)?.parentId
|
|
488
|
+
if (fromParent && toParent && fromParent !== toParent) {
|
|
489
|
+
return [sideParallel()]
|
|
490
|
+
}
|
|
491
|
+
if (verticalCorridorCrossesUnrelatedState(diagram, transition, from, to, bounds)) {
|
|
492
|
+
return [sideParallel()]
|
|
493
|
+
}
|
|
494
|
+
const verticalFeedback = diagram.direction === "BT" ? from.centerY < to.centerY : from.centerY > to.centerY
|
|
495
|
+
if (verticalFeedback) {
|
|
496
|
+
return [sideParallel()]
|
|
497
|
+
}
|
|
498
|
+
if (from.centerY === to.centerY) {
|
|
499
|
+
if (hasReverseTransition(diagram, transition) && from.centerX > to.centerX) {
|
|
500
|
+
const railY = allocateBottomRail()
|
|
501
|
+
return [
|
|
502
|
+
{
|
|
503
|
+
...base,
|
|
504
|
+
kind: "bottom-parallel",
|
|
505
|
+
railY,
|
|
506
|
+
approachX: bottomApproachX(diagram, transition, from, to, bounds, railY),
|
|
507
|
+
},
|
|
508
|
+
]
|
|
509
|
+
}
|
|
510
|
+
return [{ ...base, kind: "horizontal-forward", leftToRight: from.centerX <= to.centerX }]
|
|
511
|
+
}
|
|
512
|
+
if (!hasVerticalCorridor(from, to)) {
|
|
513
|
+
return [sideParallel()]
|
|
514
|
+
}
|
|
515
|
+
if (from.centerX !== to.centerX) {
|
|
516
|
+
return [{ ...base, kind: "vertical-elbow", hasReverse: false, offsetConnector: false }]
|
|
517
|
+
}
|
|
518
|
+
return [{ ...base, kind: "vertical" }]
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (from.centerY !== to.centerY) {
|
|
522
|
+
if (!hasVerticalCorridor(from, to)) {
|
|
523
|
+
return [sideParallel()]
|
|
524
|
+
}
|
|
525
|
+
if (from.centerY > to.centerY && feedback)
|
|
526
|
+
return [
|
|
527
|
+
{
|
|
528
|
+
...base,
|
|
529
|
+
kind: "bottom-feedback",
|
|
530
|
+
railY: feedbackLaneY,
|
|
531
|
+
approachX: bottomApproachX(diagram, transition, from, to, bounds, feedbackLaneY),
|
|
532
|
+
},
|
|
533
|
+
]
|
|
534
|
+
const hasReverse = hasReverseTransition(diagram, transition)
|
|
535
|
+
return [
|
|
536
|
+
{
|
|
537
|
+
...base,
|
|
538
|
+
kind: "vertical-elbow",
|
|
539
|
+
hasReverse,
|
|
540
|
+
offsetConnector: hasReverse || hasOpposingTopConnector(diagram, transition, bounds),
|
|
541
|
+
},
|
|
542
|
+
]
|
|
543
|
+
}
|
|
544
|
+
if (feedback)
|
|
545
|
+
return [
|
|
546
|
+
{
|
|
547
|
+
...base,
|
|
548
|
+
kind: "bottom-feedback",
|
|
549
|
+
railY: feedbackLaneY,
|
|
550
|
+
approachX: bottomApproachX(diagram, transition, from, to, bounds, feedbackLaneY),
|
|
551
|
+
},
|
|
552
|
+
]
|
|
553
|
+
if (horizontalCorridorCrossesUnrelatedState(diagram, transition, from, to, bounds)) {
|
|
554
|
+
const railY = allocateBottomRail()
|
|
555
|
+
return [
|
|
556
|
+
{
|
|
557
|
+
...base,
|
|
558
|
+
kind: "bottom-parallel",
|
|
559
|
+
railY,
|
|
560
|
+
approachX: bottomApproachX(diagram, transition, from, to, bounds, railY),
|
|
561
|
+
},
|
|
562
|
+
]
|
|
563
|
+
}
|
|
564
|
+
return [{ ...base, kind: "horizontal-forward", leftToRight: from.centerX <= to.centerX }]
|
|
565
|
+
})
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function addCell(builder: StateTransitionRenderBuilder, cell: StateTransitionRenderCell): void {
|
|
569
|
+
builder.cells.push(cell)
|
|
570
|
+
builder.path.push([cell.x, cell.y])
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function addPathPoint(builder: StateTransitionRenderBuilder, x: number, y: number): void {
|
|
574
|
+
builder.path.push([x, y])
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function addLabel(builder: StateTransitionRenderBuilder, x: number, y: number, label: string): void {
|
|
578
|
+
const metrics = measureStateTransitionLabel(label)
|
|
579
|
+
if (metrics.lines.length > 0) builder.label = { x, y, lines: metrics.lines }
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function addHorizontalLine(
|
|
583
|
+
builder: StateTransitionRenderBuilder,
|
|
584
|
+
fromX: number,
|
|
585
|
+
toX: number,
|
|
586
|
+
y: number,
|
|
587
|
+
direction: 1 | -1,
|
|
588
|
+
): void {
|
|
589
|
+
for (let x = fromX; direction === 1 ? x <= toX : x >= toX; x += direction) {
|
|
590
|
+
addCell(builder, { x, y, char: "─" })
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function addVerticalLine(
|
|
595
|
+
builder: StateTransitionRenderBuilder,
|
|
596
|
+
x: number,
|
|
597
|
+
fromY: number,
|
|
598
|
+
toY: number,
|
|
599
|
+
direction: 1 | -1,
|
|
600
|
+
): void {
|
|
601
|
+
for (let y = fromY; direction === 1 ? y <= toY : y >= toY; y += direction) {
|
|
602
|
+
addCell(builder, { x, y, char: "│" })
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function addRightDeparture(builder: StateTransitionRenderBuilder, bounds: BoxBounds): void {
|
|
607
|
+
if (bounds.width <= 1 || bounds.height <= 1) return
|
|
608
|
+
addCell(builder, {
|
|
609
|
+
x: bounds.left + bounds.width - 1,
|
|
610
|
+
y: bounds.centerY,
|
|
611
|
+
char: BorderChars.rounded.leftT,
|
|
612
|
+
})
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function addLeftDeparture(builder: StateTransitionRenderBuilder, bounds: BoxBounds): void {
|
|
616
|
+
if (bounds.width <= 1 || bounds.height <= 1) return
|
|
617
|
+
addCell(builder, { x: bounds.left, y: bounds.centerY, char: BorderChars.rounded.rightT })
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function addBottomDeparture(builder: StateTransitionRenderBuilder, bounds: BoxBounds, x: number): void {
|
|
621
|
+
if (bounds.width <= 1 || bounds.height <= 1) return
|
|
622
|
+
addCell(builder, {
|
|
623
|
+
x,
|
|
624
|
+
y: bounds.top + bounds.height - 1,
|
|
625
|
+
char: BorderChars.rounded.topT,
|
|
626
|
+
})
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function addTopDeparture(builder: StateTransitionRenderBuilder, bounds: BoxBounds, x: number): void {
|
|
630
|
+
if (bounds.width <= 1 || bounds.height <= 1) return
|
|
631
|
+
addCell(builder, { x, y: bounds.top, char: BorderChars.rounded.bottomT })
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function addHorizontalForward(builder: StateTransitionRenderBuilder): void {
|
|
635
|
+
const { from, to, targetIsChoice, targetIsHiddenMarker, leftToRight, transition } = builder.route as Extract<
|
|
636
|
+
StateTransitionRoutePlan,
|
|
637
|
+
{ kind: "horizontal-forward" }
|
|
638
|
+
>
|
|
639
|
+
const y = from.centerY
|
|
640
|
+
if (leftToRight) addRightDeparture(builder, from)
|
|
641
|
+
else addLeftDeparture(builder, from)
|
|
642
|
+
const step = leftToRight ? 1 : -1
|
|
643
|
+
const startX = leftToRight ? from.left + from.width : from.left - 1
|
|
644
|
+
const endX = leftToRight ? to.left - 1 : to.left + to.width
|
|
645
|
+
addHorizontalLine(builder, startX, endX - step, y, step)
|
|
646
|
+
addCell(
|
|
647
|
+
builder,
|
|
648
|
+
targetIsHiddenMarker ? { x: endX, y, char: "─" } : { x: endX, y, arrowDirection: leftToRight ? "right" : "left" },
|
|
649
|
+
)
|
|
650
|
+
if (targetIsChoice || targetIsHiddenMarker) addPathPoint(builder, to.left, y)
|
|
651
|
+
if (!transition.label) return
|
|
652
|
+
const metrics = measureStateTransitionLabel(transition.label)
|
|
653
|
+
const labelX = Math.min(startX, endX) + Math.max(1, Math.floor((Math.abs(endX - startX) - metrics.width) / 2))
|
|
654
|
+
addLabel(builder, labelX, Math.max(0, y - metrics.height), transition.label)
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function addSelfTransition(builder: StateTransitionRenderBuilder): void {
|
|
658
|
+
const { from: bounds, transition, lane } = builder.route as Extract<StateTransitionRoutePlan, { kind: "self" }>
|
|
659
|
+
if (bounds.width <= 1 || bounds.height <= 1) {
|
|
660
|
+
const railX = bounds.left + 4 + lane * 4
|
|
661
|
+
const railY = bounds.top + 2 + lane * 2
|
|
662
|
+
addHorizontalLine(builder, bounds.left + 1, railX - 1, bounds.top, 1)
|
|
663
|
+
addCell(builder, { x: railX, y: bounds.top, char: "╮" })
|
|
664
|
+
addVerticalLine(builder, railX, bounds.top + 1, railY - 1, 1)
|
|
665
|
+
addCell(builder, { x: railX, y: railY, char: "╯" })
|
|
666
|
+
addHorizontalLine(builder, railX - 1, bounds.left + 1, railY, -1)
|
|
667
|
+
addCell(builder, { x: bounds.left, y: railY, char: "╰" })
|
|
668
|
+
for (let y = railY - 1; y > bounds.top + 1; y--) addCell(builder, { x: bounds.left, y, char: "│" })
|
|
669
|
+
addCell(builder, { x: bounds.left, y: bounds.top + 1, arrowDirection: "up" })
|
|
670
|
+
addPathPoint(builder, bounds.left, bounds.top)
|
|
671
|
+
if (transition.label) addLabel(builder, railX + 2, lane === 0 ? bounds.top + 1 : railY - 1, transition.label)
|
|
672
|
+
return
|
|
673
|
+
}
|
|
674
|
+
const sourceX = bounds.left + Math.max(2, Math.floor(bounds.width / 3))
|
|
675
|
+
const bottomY = bounds.top + bounds.height - 1
|
|
676
|
+
const railY = bottomY + 2 + lane * 3
|
|
677
|
+
const targetX =
|
|
678
|
+
Math.max(sourceX + 3, bounds.left + Math.min(bounds.width - 3, Math.ceil((bounds.width * 2) / 3))) + lane * 4
|
|
679
|
+
|
|
680
|
+
addBottomDeparture(builder, bounds, sourceX)
|
|
681
|
+
addVerticalLine(builder, sourceX, bottomY + 1, railY - 1, 1)
|
|
682
|
+
addCell(builder, { x: sourceX, y: railY, char: "╰" })
|
|
683
|
+
for (let x = sourceX + 1; x < targetX; x++) addCell(builder, { x, y: railY, char: "─" })
|
|
684
|
+
addCell(builder, { x: targetX, y: railY, char: "╯" })
|
|
685
|
+
for (let y = railY - 1; y > bottomY + 1; y--) addCell(builder, { x: targetX, y, char: "│" })
|
|
686
|
+
addCell(builder, { x: targetX, y: bottomY + 1, arrowDirection: "up" })
|
|
687
|
+
if (transition.label) addLabel(builder, targetX + 2, lane === 0 ? bottomY + 1 : railY - 1, transition.label)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function outsideBottomY(bounds: BoxBounds): number {
|
|
691
|
+
return bounds.top + bounds.height
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function outsideTopY(bounds: BoxBounds): number {
|
|
695
|
+
return bounds.top - 1
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function addBottomLaneTransition(builder: StateTransitionRenderBuilder): void {
|
|
699
|
+
const { from, to, targetIsChoice, targetIsHiddenMarker, transition, railY, approachX } = builder.route as Extract<
|
|
700
|
+
StateTransitionRoutePlan,
|
|
701
|
+
{ kind: "bottom-feedback" | "bottom-parallel" }
|
|
702
|
+
>
|
|
703
|
+
const sourceX = from.centerX
|
|
704
|
+
const targetX = to.width > 1 ? (sourceX > to.centerX ? to.left + 1 : to.left + to.width - 2) : to.centerX
|
|
705
|
+
const targetRailCutsSource = targetX >= from.left && targetX <= from.left + from.width - 1
|
|
706
|
+
const railTargetX = targetRailCutsSource ? Math.max(from.left + from.width, to.left + to.width) + 2 : approachX
|
|
707
|
+
const sourceBottomY = outsideBottomY(from)
|
|
708
|
+
const targetBottomY = outsideBottomY(to)
|
|
709
|
+
addBottomDeparture(builder, from, sourceX)
|
|
710
|
+
addVerticalLine(builder, sourceX, sourceBottomY, railY - 1, 1)
|
|
711
|
+
addCell(builder, { x: sourceX, y: railY, char: sourceX > railTargetX ? "╯" : "╰" })
|
|
712
|
+
if (sourceX !== railTargetX) {
|
|
713
|
+
const horizontalStep = sourceX < railTargetX ? 1 : -1
|
|
714
|
+
for (let x = sourceX + horizontalStep; x !== railTargetX; x += horizontalStep) {
|
|
715
|
+
addCell(builder, { x, y: railY, char: "─" })
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
addCell(builder, { x: railTargetX, y: railY, char: sourceX > railTargetX ? "╰" : "╯" })
|
|
719
|
+
for (let y = railY - 1; y > targetBottomY; y--) addCell(builder, { x: railTargetX, y, char: "│" })
|
|
720
|
+
if (railTargetX !== targetX) {
|
|
721
|
+
addCell(builder, { x: railTargetX, y: targetBottomY, char: railTargetX < targetX ? "╭" : "╮" })
|
|
722
|
+
const horizontalStep = railTargetX < targetX ? 1 : -1
|
|
723
|
+
for (let x = railTargetX + horizontalStep; x !== targetX; x += horizontalStep) {
|
|
724
|
+
addCell(builder, { x, y: targetBottomY, char: "─" })
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
addCell(
|
|
728
|
+
builder,
|
|
729
|
+
targetIsHiddenMarker
|
|
730
|
+
? { x: targetX, y: targetBottomY, char: "│" }
|
|
731
|
+
: { x: targetX, y: targetBottomY, arrowDirection: "up" },
|
|
732
|
+
)
|
|
733
|
+
if (targetIsChoice || targetIsHiddenMarker) addPathPoint(builder, to.left, to.top)
|
|
734
|
+
if (!transition.label) return
|
|
735
|
+
const metrics = measureStateTransitionLabel(transition.label)
|
|
736
|
+
const horizontalRoom = Math.abs(sourceX - railTargetX) - 2
|
|
737
|
+
const labelX =
|
|
738
|
+
metrics.width <= horizontalRoom
|
|
739
|
+
? Math.min(sourceX, railTargetX) + Math.max(1, Math.floor((Math.abs(sourceX - railTargetX) - metrics.width) / 2))
|
|
740
|
+
: railTargetX + 2
|
|
741
|
+
addLabel(builder, labelX, Math.max(0, railY - metrics.height), transition.label)
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function addTopFeedbackTransition(builder: StateTransitionRenderBuilder): void {
|
|
745
|
+
const { from, to, targetIsChoice, targetIsHiddenMarker, transition, railY } = builder.route as Extract<
|
|
746
|
+
StateTransitionRoutePlan,
|
|
747
|
+
{ kind: "top-feedback" }
|
|
748
|
+
>
|
|
749
|
+
const sourceX = from.centerX
|
|
750
|
+
const targetX = to.width > 1 ? (sourceX > to.centerX ? to.left + to.width - 2 : to.left + 1) : to.centerX
|
|
751
|
+
const sourceTopY = outsideTopY(from)
|
|
752
|
+
const targetTopY = outsideTopY(to)
|
|
753
|
+
addTopDeparture(builder, from, sourceX)
|
|
754
|
+
addVerticalLine(builder, sourceX, sourceTopY, railY + 1, -1)
|
|
755
|
+
addCell(builder, { x: sourceX, y: railY, char: sourceX > targetX ? "╮" : "╭" })
|
|
756
|
+
if (sourceX !== targetX) {
|
|
757
|
+
const horizontalStep = sourceX < targetX ? 1 : -1
|
|
758
|
+
for (let x = sourceX + horizontalStep; x !== targetX; x += horizontalStep)
|
|
759
|
+
addCell(builder, { x, y: railY, char: "─" })
|
|
760
|
+
}
|
|
761
|
+
addCell(builder, { x: targetX, y: railY, char: sourceX > targetX ? "╭" : "╮" })
|
|
762
|
+
for (let y = railY + 1; y < targetTopY; y++) addCell(builder, { x: targetX, y, char: "│" })
|
|
763
|
+
addCell(
|
|
764
|
+
builder,
|
|
765
|
+
targetIsHiddenMarker
|
|
766
|
+
? { x: targetX, y: targetTopY, char: "│" }
|
|
767
|
+
: { x: targetX, y: targetTopY, arrowDirection: "down" },
|
|
768
|
+
)
|
|
769
|
+
if (targetIsChoice || targetIsHiddenMarker) addPathPoint(builder, to.left, to.top)
|
|
770
|
+
if (!transition.label) return
|
|
771
|
+
const metrics = measureStateTransitionLabel(transition.label)
|
|
772
|
+
const horizontalRoom = Math.abs(sourceX - targetX) - 2
|
|
773
|
+
const labelX =
|
|
774
|
+
metrics.width <= horizontalRoom
|
|
775
|
+
? Math.min(sourceX, targetX) + Math.max(1, Math.floor((Math.abs(sourceX - targetX) - metrics.width) / 2))
|
|
776
|
+
: targetX + 2
|
|
777
|
+
addLabel(builder, labelX, railY - metrics.height, transition.label)
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function addSideParallelTransition(builder: StateTransitionRenderBuilder): void {
|
|
781
|
+
const { from, to, targetIsChoice, targetIsHiddenMarker, transition, railX, targetApproach } =
|
|
782
|
+
builder.route as Extract<StateTransitionRoutePlan, { kind: "side-parallel" }>
|
|
783
|
+
const startX = from.left + from.width
|
|
784
|
+
const startY = from.centerY
|
|
785
|
+
const endY = targetApproach === "top" ? to.top - 2 : targetApproach === "bottom" ? to.top + to.height + 1 : to.centerY
|
|
786
|
+
const verticalStep: 1 | -1 = startY <= endY ? 1 : -1
|
|
787
|
+
addRightDeparture(builder, from)
|
|
788
|
+
addHorizontalLine(builder, startX, railX - 1, startY, 1)
|
|
789
|
+
addCell(builder, { x: railX, y: startY, char: verticalStep === 1 ? "╮" : "╯" })
|
|
790
|
+
for (let y = startY + verticalStep; y !== endY; y += verticalStep) addCell(builder, { x: railX, y, char: "│" })
|
|
791
|
+
addCell(builder, { x: railX, y: endY, char: verticalStep === 1 ? "╯" : "╮" })
|
|
792
|
+
if (targetApproach) {
|
|
793
|
+
const targetX = innerConnectorX(to, from.centerX)
|
|
794
|
+
for (let x = railX - 1; x > targetX; x--) addCell(builder, { x, y: endY, char: "─" })
|
|
795
|
+
addCell(builder, { x: targetX, y: endY, char: targetApproach === "top" ? "╭" : "╰" })
|
|
796
|
+
addCell(builder, {
|
|
797
|
+
x: targetX,
|
|
798
|
+
y: targetApproach === "top" ? endY + 1 : endY - 1,
|
|
799
|
+
arrowDirection: targetApproach === "top" ? "down" : "up",
|
|
800
|
+
})
|
|
801
|
+
if (targetIsChoice || targetIsHiddenMarker) addPathPoint(builder, to.left, to.top)
|
|
802
|
+
if (transition.label) {
|
|
803
|
+
const metrics = measureStateTransitionLabel(transition.label)
|
|
804
|
+
addLabel(
|
|
805
|
+
builder,
|
|
806
|
+
railX + 2,
|
|
807
|
+
Math.max(0, Math.floor((startY + to.centerY - metrics.height + 1) / 2)),
|
|
808
|
+
transition.label,
|
|
809
|
+
)
|
|
810
|
+
}
|
|
811
|
+
return
|
|
812
|
+
}
|
|
813
|
+
const endX = to.left + to.width
|
|
814
|
+
for (let x = railX - 1; x > endX; x--) addCell(builder, { x, y: endY, char: "─" })
|
|
815
|
+
addCell(
|
|
816
|
+
builder,
|
|
817
|
+
targetIsHiddenMarker ? { x: endX, y: endY, char: "─" } : { x: endX, y: endY, arrowDirection: "left" },
|
|
818
|
+
)
|
|
819
|
+
if (targetIsChoice || targetIsHiddenMarker) addPathPoint(builder, to.left, to.top)
|
|
820
|
+
if (transition.label) {
|
|
821
|
+
const metrics = measureStateTransitionLabel(transition.label)
|
|
822
|
+
const labelY = Math.max(0, Math.floor((startY + endY - metrics.height + 1) / 2))
|
|
823
|
+
addLabel(builder, railX + 2, labelY, transition.label)
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function innerConnectorX(bounds: BoxBounds, preferredX: number): number {
|
|
828
|
+
if (bounds.width <= 2) return bounds.centerX
|
|
829
|
+
return Math.max(bounds.left + 1, Math.min(bounds.left + bounds.width - 2, preferredX))
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function addVerticalElbowTransition(builder: StateTransitionRenderBuilder): void {
|
|
833
|
+
const { from, to, transition, targetIsChoice, targetIsHiddenMarker, hasReverse, offsetConnector } =
|
|
834
|
+
builder.route as Extract<StateTransitionRoutePlan, { kind: "vertical-elbow" }>
|
|
835
|
+
const topToBottom = from.centerY < to.centerY
|
|
836
|
+
const offset = offsetConnector ? (topToBottom ? -2 : 2) : 0
|
|
837
|
+
const startX = innerConnectorX(from, from.centerX + offset)
|
|
838
|
+
const endX = innerConnectorX(to, to.centerX + offset)
|
|
839
|
+
const startY = topToBottom ? from.top + from.height : from.top - 1
|
|
840
|
+
const endY = topToBottom ? to.top - 1 : to.top + to.height
|
|
841
|
+
const verticalStep = topToBottom ? 1 : -1
|
|
842
|
+
if (topToBottom) addBottomDeparture(builder, from, startX)
|
|
843
|
+
else addTopDeparture(builder, from, startX)
|
|
844
|
+
const availableApproach = Math.max(0, Math.abs(endY - startY) - 1)
|
|
845
|
+
const bendY = startX === endX ? endY : topToBottom ? endY - verticalStep * Math.min(2, availableApproach) : startY
|
|
846
|
+
const targetApproachLength = Math.abs(endY - bendY)
|
|
847
|
+
const hasTargetApproach = targetApproachLength > 0
|
|
848
|
+
if (startY !== bendY) addVerticalLine(builder, startX, startY, bendY - verticalStep, verticalStep)
|
|
849
|
+
if (startX !== endX) {
|
|
850
|
+
const horizontalStep = startX < endX ? 1 : -1
|
|
851
|
+
addCell(builder, {
|
|
852
|
+
x: startX,
|
|
853
|
+
y: bendY,
|
|
854
|
+
char: topToBottom ? (startX < endX ? "╰" : "╯") : startX < endX ? "╭" : "╮",
|
|
855
|
+
})
|
|
856
|
+
for (let x = startX + horizontalStep; x !== endX; x += horizontalStep) addCell(builder, { x, y: bendY, char: "─" })
|
|
857
|
+
if (hasTargetApproach) {
|
|
858
|
+
addCell(builder, {
|
|
859
|
+
x: endX,
|
|
860
|
+
y: bendY,
|
|
861
|
+
char: topToBottom ? (startX < endX ? "╮" : "╭") : startX < endX ? "╯" : "╰",
|
|
862
|
+
})
|
|
863
|
+
for (let distance = 1; distance < targetApproachLength; distance++) {
|
|
864
|
+
addCell(builder, { x: endX, y: bendY + verticalStep * distance, char: "│" })
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
const targetChar = targetIsHiddenMarker
|
|
869
|
+
? hasTargetApproach || startX === endX
|
|
870
|
+
? "│"
|
|
871
|
+
: topToBottom
|
|
872
|
+
? "┬"
|
|
873
|
+
: "┴"
|
|
874
|
+
: undefined
|
|
875
|
+
addCell(builder, {
|
|
876
|
+
x: endX,
|
|
877
|
+
y: endY,
|
|
878
|
+
...(targetChar ? { char: targetChar } : { arrowDirection: topToBottom ? "down" : "up" }),
|
|
879
|
+
})
|
|
880
|
+
if (targetIsChoice || targetIsHiddenMarker) addPathPoint(builder, to.left, to.top)
|
|
881
|
+
if (!transition.label) return
|
|
882
|
+
const metrics = measureStateTransitionLabel(transition.label)
|
|
883
|
+
if (topToBottom) {
|
|
884
|
+
const leftLabelX = startX - metrics.width - 2
|
|
885
|
+
const labelX =
|
|
886
|
+
from.width === 1 && Math.abs(endX - startX) >= metrics.width + 2
|
|
887
|
+
? Math.min(startX, endX) + Math.floor((Math.abs(endX - startX) - metrics.width) / 2)
|
|
888
|
+
: hasReverse || endX < startX
|
|
889
|
+
? leftLabelX >= 0
|
|
890
|
+
? leftLabelX
|
|
891
|
+
: startX + 4
|
|
892
|
+
: startX + 2
|
|
893
|
+
addLabel(
|
|
894
|
+
builder,
|
|
895
|
+
labelX,
|
|
896
|
+
hasTargetApproach ? Math.max(0, bendY - metrics.height) : Math.min(startY + 1, endY),
|
|
897
|
+
transition.label,
|
|
898
|
+
)
|
|
899
|
+
} else {
|
|
900
|
+
const labelX = Math.min(startX, endX) + Math.max(1, Math.floor((Math.abs(endX - startX) - metrics.width) / 2))
|
|
901
|
+
addLabel(
|
|
902
|
+
builder,
|
|
903
|
+
startX === endX ? startX + 3 : labelX,
|
|
904
|
+
hasTargetApproach ? Math.max(0, bendY - metrics.height) : Math.max(0, startY),
|
|
905
|
+
transition.label,
|
|
906
|
+
)
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function addVerticalTransition(builder: StateTransitionRenderBuilder): void {
|
|
911
|
+
const { from, to, transition, targetIsChoice, targetIsHiddenMarker } = builder.route
|
|
912
|
+
const topToBottom = from.centerY <= to.centerY
|
|
913
|
+
const x = from.centerX
|
|
914
|
+
const startY = topToBottom ? from.top + from.height : from.top - 1
|
|
915
|
+
const endY = topToBottom ? to.top - 1 : to.top + to.height
|
|
916
|
+
const step = topToBottom ? 1 : -1
|
|
917
|
+
if (topToBottom) addBottomDeparture(builder, from, x)
|
|
918
|
+
else addTopDeparture(builder, from, x)
|
|
919
|
+
if (startY !== endY) addVerticalLine(builder, x, startY, endY - step, step)
|
|
920
|
+
addCell(builder, {
|
|
921
|
+
x,
|
|
922
|
+
y: endY,
|
|
923
|
+
...(targetIsHiddenMarker ? { char: "│" } : { arrowDirection: topToBottom ? "down" : "up" }),
|
|
924
|
+
})
|
|
925
|
+
if (targetIsChoice || targetIsHiddenMarker) addPathPoint(builder, to.left, to.top)
|
|
926
|
+
if (transition.label) addLabel(builder, x + 2, Math.min(startY, endY) + 1, transition.label)
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function createStateTransitionRenderPlan(route: StateTransitionRoutePlan): StateTransitionRenderPlan {
|
|
930
|
+
const builder: StateTransitionRenderBuilder = { route, cells: [], path: [] }
|
|
931
|
+
switch (route.kind) {
|
|
932
|
+
case "self":
|
|
933
|
+
addSelfTransition(builder)
|
|
934
|
+
break
|
|
935
|
+
case "horizontal-forward":
|
|
936
|
+
addHorizontalForward(builder)
|
|
937
|
+
break
|
|
938
|
+
case "bottom-feedback":
|
|
939
|
+
case "bottom-parallel":
|
|
940
|
+
addBottomLaneTransition(builder)
|
|
941
|
+
break
|
|
942
|
+
case "top-feedback":
|
|
943
|
+
addTopFeedbackTransition(builder)
|
|
944
|
+
break
|
|
945
|
+
case "vertical-elbow":
|
|
946
|
+
addVerticalElbowTransition(builder)
|
|
947
|
+
break
|
|
948
|
+
case "vertical":
|
|
949
|
+
addVerticalTransition(builder)
|
|
950
|
+
break
|
|
951
|
+
case "side-parallel":
|
|
952
|
+
addSideParallelTransition(builder)
|
|
953
|
+
break
|
|
954
|
+
}
|
|
955
|
+
return builder
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function pointIsInsideBounds(point: StateTransitionPathPoint, bounds: BoxBounds): boolean {
|
|
959
|
+
return (
|
|
960
|
+
point[0] >= bounds.left &&
|
|
961
|
+
point[0] < bounds.left + bounds.width &&
|
|
962
|
+
point[1] >= bounds.top &&
|
|
963
|
+
point[1] < bounds.top + bounds.height
|
|
964
|
+
)
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function routeIntersectsUnrelatedState(
|
|
968
|
+
plan: StateTransitionRenderPlan,
|
|
969
|
+
diagram: StateVisibleDiagram,
|
|
970
|
+
bounds: ReadonlyMap<string, BoxBounds>,
|
|
971
|
+
noteBounds: readonly StateDiagramNoteBounds[],
|
|
972
|
+
): boolean {
|
|
973
|
+
if (
|
|
974
|
+
diagram.states.some((state) => {
|
|
975
|
+
if (
|
|
976
|
+
state.id === plan.route.transition.from ||
|
|
977
|
+
state.id === plan.route.transition.to ||
|
|
978
|
+
isHiddenCompositeMarker(state)
|
|
979
|
+
)
|
|
980
|
+
return false
|
|
981
|
+
const bound = bounds.get(state.id)
|
|
982
|
+
return Boolean(bound && plan.path.some((point) => pointIsInsideBounds(point, bound)))
|
|
983
|
+
})
|
|
984
|
+
)
|
|
985
|
+
return true
|
|
986
|
+
|
|
987
|
+
return noteBounds.some((noteBound) => {
|
|
988
|
+
if (plan.path.some((point) => pointIsInsideBounds(point, noteBound))) return true
|
|
989
|
+
const target = bounds.get(noteBound.note.target)
|
|
990
|
+
if (!target) return false
|
|
991
|
+
const connector = spatialPathClaim(
|
|
992
|
+
`note-connector:${noteBound.id}`,
|
|
993
|
+
`note-connector:${noteBound.id}`,
|
|
994
|
+
"boundary",
|
|
995
|
+
stateDiagramNoteConnector(noteBound, target).points,
|
|
996
|
+
)
|
|
997
|
+
return plan.path.some(([x, y]) => connector.spans.some((span) => span.y === y && x >= span.fromX && x <= span.toX))
|
|
998
|
+
})
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function findBodySafePath(
|
|
1002
|
+
start: StateTransitionPathPoint,
|
|
1003
|
+
end: StateTransitionPathPoint,
|
|
1004
|
+
diagram: StateVisibleDiagram,
|
|
1005
|
+
bounds: ReadonlyMap<string, BoxBounds>,
|
|
1006
|
+
plan: StateTransitionRenderPlan,
|
|
1007
|
+
search: StateSearchSpace,
|
|
1008
|
+
budget: StateSearchBudget,
|
|
1009
|
+
): StateTransitionPathPoint[] | undefined {
|
|
1010
|
+
const margin = Math.max(8, bounds.size * 2)
|
|
1011
|
+
const compositeId = innermostCommonComposite(diagram, plan.route.transition)
|
|
1012
|
+
const composite = compositeId ? bounds.get(compositeId) : undefined
|
|
1013
|
+
const searchBounds = {
|
|
1014
|
+
minX: composite ? composite.left + 1 : search.minX - margin,
|
|
1015
|
+
minY: composite ? composite.top + 1 : Math.min(search.minY, ...plan.path.map((point) => point[1])) - margin,
|
|
1016
|
+
maxX: composite
|
|
1017
|
+
? composite.left + composite.width - 2
|
|
1018
|
+
: Math.max(search.maxX, ...plan.path.map((point) => point[0])) + margin,
|
|
1019
|
+
maxY: composite
|
|
1020
|
+
? composite.top + composite.height - 2
|
|
1021
|
+
: Math.max(search.maxY, ...plan.path.map((point) => point[1])) + margin,
|
|
1022
|
+
}
|
|
1023
|
+
const isFree = ([x, y]: StateTransitionPathPoint) =>
|
|
1024
|
+
x >= searchBounds.minX &&
|
|
1025
|
+
x <= searchBounds.maxX &&
|
|
1026
|
+
y >= searchBounds.minY &&
|
|
1027
|
+
y <= searchBounds.maxY &&
|
|
1028
|
+
!search.blocked.has(`${x}:${y}`)
|
|
1029
|
+
const pathMinX = Math.min(...plan.path.map((point) => point[0]))
|
|
1030
|
+
const pathMinY = Math.min(...plan.path.map((point) => point[1]))
|
|
1031
|
+
const pathMaxX = Math.max(...plan.path.map((point) => point[0]))
|
|
1032
|
+
const pathMaxY = Math.max(...plan.path.map((point) => point[1]))
|
|
1033
|
+
const directCandidates: StateTransitionPathPoint[][] = [
|
|
1034
|
+
[start, [start[0], end[1]] as const, end],
|
|
1035
|
+
[start, [end[0], start[1]] as const, end],
|
|
1036
|
+
...Array.from({ length: 4 }, (_, index) => index + 1).flatMap((offset): StateTransitionPathPoint[][] => [
|
|
1037
|
+
[start, [start[0], pathMinY - offset], [end[0], pathMinY - offset], end],
|
|
1038
|
+
[start, [start[0], pathMaxY + offset], [end[0], pathMaxY + offset], end],
|
|
1039
|
+
[start, [pathMinX - offset, start[1]], [pathMinX - offset, end[1]], end],
|
|
1040
|
+
[start, [pathMaxX + offset, start[1]], [pathMaxX + offset, end[1]], end],
|
|
1041
|
+
]),
|
|
1042
|
+
]
|
|
1043
|
+
const direct = directCandidates
|
|
1044
|
+
.map((points) => orthogonalPathPoints(points.map(([x, y]) => ({ x, y }))).map(({ x, y }) => [x, y] as const))
|
|
1045
|
+
.filter((points) => points.every(isFree))
|
|
1046
|
+
.sort((left, right) => left.length - right.length)[0]
|
|
1047
|
+
if (direct) return direct
|
|
1048
|
+
const path = findStateManhattanPath(
|
|
1049
|
+
[{ x: start[0], y: start[1] }],
|
|
1050
|
+
{ x: end[0], y: end[1] },
|
|
1051
|
+
search,
|
|
1052
|
+
searchBounds,
|
|
1053
|
+
budget,
|
|
1054
|
+
)
|
|
1055
|
+
return path?.map((point) => [point.x, point.y] as const)
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function bodySafeTransitionPlan(
|
|
1059
|
+
plan: StateTransitionRenderPlan,
|
|
1060
|
+
diagram: StateVisibleDiagram,
|
|
1061
|
+
bounds: ReadonlyMap<string, BoxBounds>,
|
|
1062
|
+
noteBounds: readonly StateDiagramNoteBounds[],
|
|
1063
|
+
search: StateSearchSpace,
|
|
1064
|
+
budget: StateSearchBudget,
|
|
1065
|
+
forceRepair = false,
|
|
1066
|
+
): StateTransitionRenderPlan {
|
|
1067
|
+
if (!forceRepair && !routeIntersectsUnrelatedState(plan, diagram, bounds, noteBounds)) return plan
|
|
1068
|
+
const sourceOutsideIndex = plan.path.findIndex((point) => !pointIsInsideBounds(point, plan.route.from))
|
|
1069
|
+
const targetOutsideIndex = plan.path.findLastIndex((point) => !pointIsInsideBounds(point, plan.route.to))
|
|
1070
|
+
if (sourceOutsideIndex < 0 || targetOutsideIndex < sourceOutsideIndex) return plan
|
|
1071
|
+
const safePath = findBodySafePath(
|
|
1072
|
+
plan.path[sourceOutsideIndex]!,
|
|
1073
|
+
plan.path[targetOutsideIndex]!,
|
|
1074
|
+
diagram,
|
|
1075
|
+
bounds,
|
|
1076
|
+
plan,
|
|
1077
|
+
search,
|
|
1078
|
+
budget,
|
|
1079
|
+
)
|
|
1080
|
+
if (!safePath) return alternateBodySafeTransitionPlan(plan, diagram, bounds, noteBounds, search, budget)
|
|
1081
|
+
|
|
1082
|
+
const prefix = plan.path.slice(0, sourceOutsideIndex)
|
|
1083
|
+
const suffix = plan.path.slice(targetOutsideIndex + 1)
|
|
1084
|
+
const repaired = renderBodySafeTransitionPlan(plan, safePath, prefix, suffix)
|
|
1085
|
+
if (safePath.length <= plan.path.length + 4) return repaired
|
|
1086
|
+
const alternate = alternateBodySafeTransitionPlan(plan, diagram, bounds, noteBounds, search, budget)
|
|
1087
|
+
return alternate !== plan && alternate.path.length < repaired.path.length ? alternate : repaired
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function alternateBodySafeTransitionPlan(
|
|
1091
|
+
plan: StateTransitionRenderPlan,
|
|
1092
|
+
diagram: StateVisibleDiagram,
|
|
1093
|
+
bounds: ReadonlyMap<string, BoxBounds>,
|
|
1094
|
+
noteBounds: readonly StateDiagramNoteBounds[],
|
|
1095
|
+
search: StateSearchSpace,
|
|
1096
|
+
budget: StateSearchBudget,
|
|
1097
|
+
): StateTransitionRenderPlan {
|
|
1098
|
+
const candidates: StateTransitionRenderPlan[] = []
|
|
1099
|
+
for (const source of stateRoutePorts(plan.route.from)) {
|
|
1100
|
+
for (const target of stateRoutePorts(plan.route.to)) {
|
|
1101
|
+
const safePath = findBodySafePath(source.outside, target.outside, diagram, bounds, plan, search, budget)
|
|
1102
|
+
if (!safePath) continue
|
|
1103
|
+
const prefix = plan.route.from.width > 1 && plan.route.from.height > 1 ? [source.border] : []
|
|
1104
|
+
const suffix =
|
|
1105
|
+
plan.route.targetIsChoice || plan.route.targetIsHiddenMarker
|
|
1106
|
+
? ([[plan.route.to.left, plan.route.to.top]] as const)
|
|
1107
|
+
: []
|
|
1108
|
+
const repaired = renderBodySafeTransitionPlan(plan, safePath, prefix, suffix, source.char)
|
|
1109
|
+
if (!routeIntersectsUnrelatedState(repaired, diagram, bounds, noteBounds)) candidates.push(repaired)
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
return candidates.sort((left, right) => left.path.length - right.path.length)[0] ?? plan
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function stateRoutePorts(bounds: BoxBounds): Array<{
|
|
1116
|
+
outside: StateTransitionPathPoint
|
|
1117
|
+
border: StateTransitionPathPoint
|
|
1118
|
+
char: string
|
|
1119
|
+
}> {
|
|
1120
|
+
return [
|
|
1121
|
+
{
|
|
1122
|
+
outside: [bounds.centerX, bounds.top - 1] as const,
|
|
1123
|
+
border: [bounds.centerX, bounds.top] as const,
|
|
1124
|
+
char: BorderChars.rounded.bottomT,
|
|
1125
|
+
},
|
|
1126
|
+
{
|
|
1127
|
+
outside: [bounds.centerX, bounds.top + bounds.height] as const,
|
|
1128
|
+
border: [bounds.centerX, bounds.top + bounds.height - 1] as const,
|
|
1129
|
+
char: BorderChars.rounded.topT,
|
|
1130
|
+
},
|
|
1131
|
+
{
|
|
1132
|
+
outside: [bounds.left - 1, bounds.centerY] as const,
|
|
1133
|
+
border: [bounds.left, bounds.centerY] as const,
|
|
1134
|
+
char: BorderChars.rounded.rightT,
|
|
1135
|
+
},
|
|
1136
|
+
{
|
|
1137
|
+
outside: [bounds.left + bounds.width, bounds.centerY] as const,
|
|
1138
|
+
border: [bounds.left + bounds.width - 1, bounds.centerY] as const,
|
|
1139
|
+
char: BorderChars.rounded.leftT,
|
|
1140
|
+
},
|
|
1141
|
+
].filter((port) => port.outside[0] >= 0)
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function renderBodySafeTransitionPlan(
|
|
1145
|
+
plan: StateTransitionRenderPlan,
|
|
1146
|
+
safePath: readonly StateTransitionPathPoint[],
|
|
1147
|
+
prefix: readonly StateTransitionPathPoint[],
|
|
1148
|
+
suffix: readonly StateTransitionPathPoint[],
|
|
1149
|
+
sourceChar?: string,
|
|
1150
|
+
): StateTransitionRenderPlan {
|
|
1151
|
+
const prefixKeys = new Set(prefix.map(([x, y]) => `${x}:${y}`))
|
|
1152
|
+
const cells: StateTransitionRenderCell[] = sourceChar
|
|
1153
|
+
? prefix.map(([x, y]) => ({ x, y, char: sourceChar }))
|
|
1154
|
+
: plan.cells.filter((cell) => prefixKeys.has(`${cell.x}:${cell.y}`))
|
|
1155
|
+
const fullPath = [...prefix, ...safePath, ...suffix]
|
|
1156
|
+
const previous = prefix.at(-1)
|
|
1157
|
+
for (const [index, point] of safePath.entries()) {
|
|
1158
|
+
if (index === safePath.length - 1) {
|
|
1159
|
+
const targetDirection = connectionDirection(point, [plan.route.to.centerX, plan.route.to.centerY])
|
|
1160
|
+
cells.push(
|
|
1161
|
+
plan.route.targetIsHiddenMarker
|
|
1162
|
+
? { x: point[0], y: point[1], char: targetDirection === "left" || targetDirection === "right" ? "─" : "│" }
|
|
1163
|
+
: { x: point[0], y: point[1], arrowDirection: targetDirection },
|
|
1164
|
+
)
|
|
1165
|
+
continue
|
|
1166
|
+
}
|
|
1167
|
+
const before = index === 0 ? previous : safePath[index - 1]
|
|
1168
|
+
const after = safePath[index + 1]!
|
|
1169
|
+
const connections = new Set<DiagramDirection>()
|
|
1170
|
+
if (before) connections.add(connectionDirection(point, before))
|
|
1171
|
+
connections.add(connectionDirection(point, after))
|
|
1172
|
+
cells.push({ x: point[0], y: point[1], char: diagramLineGlyph(connections, "rounded") })
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
return { ...plan, cells, path: fullPath, pathRepaired: true }
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function labelDistanceToPath(
|
|
1179
|
+
x: number,
|
|
1180
|
+
y: number,
|
|
1181
|
+
width: number,
|
|
1182
|
+
height: number,
|
|
1183
|
+
path: readonly StateTransitionPathPoint[],
|
|
1184
|
+
): number {
|
|
1185
|
+
return Math.min(
|
|
1186
|
+
...path.map(([pathX, pathY]) => {
|
|
1187
|
+
const dx = pathX < x ? x - pathX : pathX >= x + width ? pathX - (x + width - 1) : 0
|
|
1188
|
+
const dy = pathY < y ? y - pathY : pathY >= y + height ? pathY - (y + height - 1) : 0
|
|
1189
|
+
return dx + dy
|
|
1190
|
+
}),
|
|
1191
|
+
)
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
function stateTransitionLabelCandidates(
|
|
1195
|
+
plan: StateTransitionRenderPlan,
|
|
1196
|
+
width: number,
|
|
1197
|
+
height: number,
|
|
1198
|
+
): Array<{ x: number; y: number }> {
|
|
1199
|
+
const candidates = new Map<string, { x: number; y: number }>()
|
|
1200
|
+
const add = (x: number, y: number) => candidates.set(`${x}:${y}`, { x, y })
|
|
1201
|
+
if (
|
|
1202
|
+
plan.label &&
|
|
1203
|
+
(!plan.pathRepaired || labelDistanceToPath(plan.label.x, plan.label.y, width, height, plan.path) <= 4)
|
|
1204
|
+
) {
|
|
1205
|
+
add(plan.label.x, plan.label.y)
|
|
1206
|
+
}
|
|
1207
|
+
for (const [x, y] of plan.path) {
|
|
1208
|
+
add(x + 2, y - Math.floor(height / 2))
|
|
1209
|
+
add(x - width - 2, y - Math.floor(height / 2))
|
|
1210
|
+
add(x - Math.floor(width / 2), y - height - 1)
|
|
1211
|
+
add(x - Math.floor(width / 2), y + 2)
|
|
1212
|
+
}
|
|
1213
|
+
const preferred = plan.label ?? { x: plan.path[0]?.[0] ?? 0, y: plan.path[0]?.[1] ?? 0 }
|
|
1214
|
+
return [...candidates.values()].sort((left, right) => {
|
|
1215
|
+
const leftDistance = Math.abs(left.x - preferred.x) + Math.abs(left.y - preferred.y)
|
|
1216
|
+
const rightDistance = Math.abs(right.x - preferred.x) + Math.abs(right.y - preferred.y)
|
|
1217
|
+
return leftDistance - rightDistance
|
|
1218
|
+
})
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function placeStateTransitionLabels(
|
|
1222
|
+
plans: readonly StateTransitionRenderPlan[],
|
|
1223
|
+
diagram: StateVisibleDiagram,
|
|
1224
|
+
bounds: ReadonlyMap<string, BoxBounds>,
|
|
1225
|
+
noteBounds: readonly StateDiagramNoteBounds[],
|
|
1226
|
+
): StateTransitionRenderPlan[] {
|
|
1227
|
+
let space = SpatialIndex.empty().add(
|
|
1228
|
+
...diagram.states.flatMap((state) => {
|
|
1229
|
+
const bound = bounds.get(state.id)
|
|
1230
|
+
return bound && !isHiddenCompositeMarker(state)
|
|
1231
|
+
? [spatialRectClaim(`state:${state.id}`, `state:${state.id}`, "body", bound)]
|
|
1232
|
+
: []
|
|
1233
|
+
}),
|
|
1234
|
+
...plans.map((plan, index) =>
|
|
1235
|
+
spatialPathClaim(
|
|
1236
|
+
`route:${index}`,
|
|
1237
|
+
`route:${index}`,
|
|
1238
|
+
"route",
|
|
1239
|
+
plan.path.map(([x, y]) => ({ x, y })),
|
|
1240
|
+
),
|
|
1241
|
+
),
|
|
1242
|
+
...diagram.composites.flatMap((composite) => {
|
|
1243
|
+
const bound = bounds.get(composite.id)
|
|
1244
|
+
if (!bound) return []
|
|
1245
|
+
return [
|
|
1246
|
+
spatialPathClaim(`composite:${composite.id}`, `composite:${composite.id}`, "boundary", [
|
|
1247
|
+
{ x: bound.left, y: bound.top },
|
|
1248
|
+
{ x: bound.left + bound.width - 1, y: bound.top },
|
|
1249
|
+
{ x: bound.left + bound.width - 1, y: bound.top + bound.height - 1 },
|
|
1250
|
+
{ x: bound.left, y: bound.top + bound.height - 1 },
|
|
1251
|
+
{ x: bound.left, y: bound.top },
|
|
1252
|
+
]),
|
|
1253
|
+
]
|
|
1254
|
+
}),
|
|
1255
|
+
...noteBounds.flatMap((noteBound) => {
|
|
1256
|
+
const target = bounds.get(noteBound.note.target)
|
|
1257
|
+
return [
|
|
1258
|
+
spatialRectClaim(`note:${noteBound.id}`, `note:${noteBound.id}`, "body", noteBound),
|
|
1259
|
+
...(target
|
|
1260
|
+
? [
|
|
1261
|
+
spatialPathClaim(
|
|
1262
|
+
`note-connector:${noteBound.id}`,
|
|
1263
|
+
`note-connector:${noteBound.id}`,
|
|
1264
|
+
"boundary",
|
|
1265
|
+
stateDiagramNoteConnector(noteBound, target).points,
|
|
1266
|
+
),
|
|
1267
|
+
]
|
|
1268
|
+
: []),
|
|
1269
|
+
]
|
|
1270
|
+
}),
|
|
1271
|
+
)
|
|
1272
|
+
|
|
1273
|
+
const placed = new Map<number, StateTransitionRenderPlan>()
|
|
1274
|
+
const endpointCounts = new Map<string, number>()
|
|
1275
|
+
for (const plan of plans) {
|
|
1276
|
+
const key = `${plan.route.transition.from}\u0000${plan.route.transition.to}`
|
|
1277
|
+
endpointCounts.set(key, (endpointCounts.get(key) ?? 0) + 1)
|
|
1278
|
+
}
|
|
1279
|
+
const placementOrder = [...plans.keys()].sort(
|
|
1280
|
+
(left, right) => Number(Boolean(plans[left]!.pathRepaired)) - Number(Boolean(plans[right]!.pathRepaired)),
|
|
1281
|
+
)
|
|
1282
|
+
for (const planIndex of placementOrder) {
|
|
1283
|
+
const plan = plans[planIndex]!
|
|
1284
|
+
if (!plan.label) {
|
|
1285
|
+
placed.set(planIndex, plan)
|
|
1286
|
+
continue
|
|
1287
|
+
}
|
|
1288
|
+
const width = Math.max(...plan.label.lines.map(diagramTextWidth))
|
|
1289
|
+
const endpointKey = `${plan.route.transition.from}\u0000${plan.route.transition.to}`
|
|
1290
|
+
const needsLaneClearance = (endpointCounts.get(endpointKey) ?? 0) > 1
|
|
1291
|
+
const statePadding = needsLaneClearance || plan.label.lines.length > 1 ? 1 : 0
|
|
1292
|
+
const labelClaim = (x: number, y: number) =>
|
|
1293
|
+
spatialRectClaim(`label:${planIndex}`, `label:${planIndex}`, "label", {
|
|
1294
|
+
left: x,
|
|
1295
|
+
top: y,
|
|
1296
|
+
width,
|
|
1297
|
+
height: plan.label!.lines.length,
|
|
1298
|
+
})
|
|
1299
|
+
const sharesLoopCorridor = plans.some(
|
|
1300
|
+
(candidate) =>
|
|
1301
|
+
candidate.route.transition.from === plan.route.transition.from &&
|
|
1302
|
+
candidate.route.kind === (plan.route.kind === "self" ? "vertical" : "self"),
|
|
1303
|
+
)
|
|
1304
|
+
const corridorTop =
|
|
1305
|
+
plan.route.kind === "side-parallel" &&
|
|
1306
|
+
plans.some((candidate) => candidate !== plan && candidate.route.transition.to === plan.route.transition.to)
|
|
1307
|
+
? Math.min(...plan.path.map(([, y]) => y))
|
|
1308
|
+
: sharesLoopCorridor &&
|
|
1309
|
+
(plan.route.kind === "self" ||
|
|
1310
|
+
(plan.route.kind === "vertical" && plan.route.from.centerY < plan.route.to.centerY))
|
|
1311
|
+
? plan.route.from.top + plan.route.from.height
|
|
1312
|
+
: undefined
|
|
1313
|
+
const corridorBottom = corridorTop === undefined ? undefined : Math.max(...plan.path.map(([, y]) => y))
|
|
1314
|
+
const isClear = (x: number, y: number): boolean => {
|
|
1315
|
+
if (x < 0 || y < 0 || (corridorTop !== undefined && (y < corridorTop || y > corridorBottom!))) return false
|
|
1316
|
+
return space.isFree(labelClaim(x, y), {
|
|
1317
|
+
clearance: {
|
|
1318
|
+
body: statePadding,
|
|
1319
|
+
label: { x: 1, y: 1 },
|
|
1320
|
+
route:
|
|
1321
|
+
plan.pathRepaired || plan.route.kind === "side-parallel" || needsLaneClearance || corridorTop !== undefined
|
|
1322
|
+
? {
|
|
1323
|
+
x: 1,
|
|
1324
|
+
y: 0,
|
|
1325
|
+
}
|
|
1326
|
+
: 0,
|
|
1327
|
+
},
|
|
1328
|
+
})
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
const candidates = stateTransitionLabelCandidates(plan, width, plan.label.lines.length)
|
|
1332
|
+
const nearby = candidates.find(
|
|
1333
|
+
(candidate) =>
|
|
1334
|
+
(!(
|
|
1335
|
+
plan.pathRepaired ||
|
|
1336
|
+
plan.route.kind === "side-parallel" ||
|
|
1337
|
+
needsLaneClearance ||
|
|
1338
|
+
corridorTop !== undefined
|
|
1339
|
+
) ||
|
|
1340
|
+
labelDistanceToPath(candidate.x, candidate.y, width, plan.label!.lines.length, plan.path) >= 2) &&
|
|
1341
|
+
isClear(candidate.x, candidate.y),
|
|
1342
|
+
)
|
|
1343
|
+
let x = nearby?.x ?? candidates[0]?.x ?? plan.label.x
|
|
1344
|
+
let y = nearby?.y ?? candidates[0]?.y ?? plan.label.y
|
|
1345
|
+
if (!nearby) {
|
|
1346
|
+
search: for (let distance = 1; distance < 500; distance++) {
|
|
1347
|
+
for (let dx = -distance; dx <= distance; dx++) {
|
|
1348
|
+
const dy = distance - Math.abs(dx)
|
|
1349
|
+
for (const candidateY of dy === 0 ? [y] : [y - dy, y + dy]) {
|
|
1350
|
+
const candidateX = x + dx
|
|
1351
|
+
if (!isClear(candidateX, candidateY)) continue
|
|
1352
|
+
const pathDistance = labelDistanceToPath(candidateX, candidateY, width, plan.label!.lines.length, plan.path)
|
|
1353
|
+
if (pathDistance < 2 || pathDistance > 8) continue
|
|
1354
|
+
x = candidateX
|
|
1355
|
+
y = candidateY
|
|
1356
|
+
break search
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
if (!isClear(x, y)) {
|
|
1362
|
+
fallback: for (let distance = 1; distance < 500; distance++) {
|
|
1363
|
+
for (let dx = -distance; dx <= distance; dx++) {
|
|
1364
|
+
const dy = distance - Math.abs(dx)
|
|
1365
|
+
for (const candidateY of dy === 0 ? [y] : [y - dy, y + dy]) {
|
|
1366
|
+
const candidateX = x + dx
|
|
1367
|
+
if (!isClear(candidateX, candidateY)) continue
|
|
1368
|
+
x = candidateX
|
|
1369
|
+
y = candidateY
|
|
1370
|
+
break fallback
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
if (!isClear(x, y)) throw new Error(`Transition ${endpointKey} has no clear label position`)
|
|
1376
|
+
|
|
1377
|
+
space = space.add(labelClaim(x, y))
|
|
1378
|
+
placed.set(planIndex, { ...plan, label: { ...plan.label, x, y } })
|
|
1379
|
+
}
|
|
1380
|
+
return plans.map((plan, index) => placed.get(index) ?? plan)
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
export function createStateTransitionRenderPlans(
|
|
1384
|
+
diagram: StateVisibleDiagram,
|
|
1385
|
+
bounds: ReadonlyMap<string, BoxBounds>,
|
|
1386
|
+
feedbackLaneY: number,
|
|
1387
|
+
options: StateTransitionRenderOptions = {},
|
|
1388
|
+
): StateTransitionRenderPlan[] {
|
|
1389
|
+
const noteBounds = options.noteBounds ?? []
|
|
1390
|
+
const budget = options.searchBudget ?? createStateSearchBudget()
|
|
1391
|
+
const plans = createStateTransitionRoutePlans(diagram, bounds, feedbackLaneY, options.feedbackTopY).map(
|
|
1392
|
+
createStateTransitionRenderPlan,
|
|
1393
|
+
)
|
|
1394
|
+
if (options.repairRoutes === false) return placeStateTransitionLabels(plans, diagram, bounds, noteBounds)
|
|
1395
|
+
const baseObstacles = transitionObstacles(diagram, bounds, noteBounds)
|
|
1396
|
+
const repaired: StateTransitionRenderPlan[] = []
|
|
1397
|
+
for (const [index, plan] of plans.entries()) {
|
|
1398
|
+
const disjoint = repaired.filter((previous) => transitionsHaveDisjointEndpoints(previous, plan))
|
|
1399
|
+
const routeObstacles = repaired.filter(
|
|
1400
|
+
(previous) => disjoint.includes(previous) || transitionsAreReciprocal(previous, plan),
|
|
1401
|
+
)
|
|
1402
|
+
const routeSpace = createStateSearchSpace(
|
|
1403
|
+
baseObstacles.add(
|
|
1404
|
+
...routeObstacles.map((previous, previousIndex) =>
|
|
1405
|
+
spatialPathClaim(
|
|
1406
|
+
`transition:${index}:obstacle:${previousIndex}`,
|
|
1407
|
+
`transition:${index}:obstacle:${previousIndex}`,
|
|
1408
|
+
"route",
|
|
1409
|
+
previous.path.map(([x, y]) => ({ x, y })),
|
|
1410
|
+
),
|
|
1411
|
+
),
|
|
1412
|
+
),
|
|
1413
|
+
)
|
|
1414
|
+
repaired.push(
|
|
1415
|
+
bodySafeTransitionPlan(
|
|
1416
|
+
plan,
|
|
1417
|
+
diagram,
|
|
1418
|
+
bounds,
|
|
1419
|
+
noteBounds,
|
|
1420
|
+
routeSpace,
|
|
1421
|
+
budget,
|
|
1422
|
+
disjoint.some((previous) => pathsIntersect(previous.path, plan.path)),
|
|
1423
|
+
),
|
|
1424
|
+
)
|
|
1425
|
+
}
|
|
1426
|
+
return placeStateTransitionLabels(repaired, diagram, bounds, noteBounds)
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
function transitionsHaveDisjointEndpoints(left: StateTransitionRenderPlan, right: StateTransitionRenderPlan): boolean {
|
|
1430
|
+
const leftEndpoints = new Set([left.route.transition.from, left.route.transition.to])
|
|
1431
|
+
return !leftEndpoints.has(right.route.transition.from) && !leftEndpoints.has(right.route.transition.to)
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
function transitionsAreReciprocal(left: StateTransitionRenderPlan, right: StateTransitionRenderPlan): boolean {
|
|
1435
|
+
return (
|
|
1436
|
+
left.route.transition.from === right.route.transition.to && left.route.transition.to === right.route.transition.from
|
|
1437
|
+
)
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
function pathsIntersect(
|
|
1441
|
+
left: readonly StateTransitionPathPoint[],
|
|
1442
|
+
right: readonly StateTransitionPathPoint[],
|
|
1443
|
+
): boolean {
|
|
1444
|
+
const occupied = new Set(left.map(([x, y]) => `${x}:${y}`))
|
|
1445
|
+
return right.some(([x, y]) => occupied.has(`${x}:${y}`))
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
function transitionObstacles(
|
|
1449
|
+
diagram: StateVisibleDiagram,
|
|
1450
|
+
bounds: ReadonlyMap<string, BoxBounds>,
|
|
1451
|
+
noteBounds: readonly StateDiagramNoteBounds[],
|
|
1452
|
+
): SpatialIndex {
|
|
1453
|
+
return SpatialIndex.empty().add(
|
|
1454
|
+
...diagram.states.flatMap((state) => {
|
|
1455
|
+
const bound = bounds.get(state.id)
|
|
1456
|
+
return bound && !isHiddenCompositeMarker(state)
|
|
1457
|
+
? [spatialRectClaim(`state:${state.id}`, `state:${state.id}`, "body", bound)]
|
|
1458
|
+
: []
|
|
1459
|
+
}),
|
|
1460
|
+
...noteBounds.flatMap((noteBound) => {
|
|
1461
|
+
const target = bounds.get(noteBound.note.target)
|
|
1462
|
+
return [
|
|
1463
|
+
spatialRectClaim(`note:${noteBound.id}`, `note:${noteBound.id}`, "body", noteBound),
|
|
1464
|
+
...(target
|
|
1465
|
+
? [
|
|
1466
|
+
spatialPathClaim(
|
|
1467
|
+
`note-connector:${noteBound.id}`,
|
|
1468
|
+
`note-connector:${noteBound.id}`,
|
|
1469
|
+
"boundary",
|
|
1470
|
+
stateDiagramNoteConnector(noteBound, target).points,
|
|
1471
|
+
),
|
|
1472
|
+
]
|
|
1473
|
+
: []),
|
|
1474
|
+
]
|
|
1475
|
+
}),
|
|
1476
|
+
)
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
function connectionDirection(from: StateTransitionPathPoint, to: StateTransitionPathPoint): DiagramDirection {
|
|
1480
|
+
const deltaX = to[0] - from[0]
|
|
1481
|
+
const deltaY = to[1] - from[1]
|
|
1482
|
+
if (Math.abs(deltaX) >= Math.abs(deltaY) && deltaX !== 0) return deltaX > 0 ? "right" : "left"
|
|
1483
|
+
if (deltaY !== 0) return deltaY > 0 ? "down" : "up"
|
|
1484
|
+
return "right"
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
export function createStateTransitionJunctionPlans(
|
|
1488
|
+
diagram: StateVisibleDiagram,
|
|
1489
|
+
bounds: ReadonlyMap<string, BoxBounds>,
|
|
1490
|
+
renderPlans: readonly StateTransitionRenderPlan[],
|
|
1491
|
+
): StateTransitionJunctionPlan[] {
|
|
1492
|
+
const renderPlanByTransition = new Map(renderPlans.map((plan) => [plan.route.transition, plan]))
|
|
1493
|
+
return diagram.states.flatMap((state): StateTransitionJunctionPlan[] => {
|
|
1494
|
+
const kind =
|
|
1495
|
+
state.kind === "choice" || state.kind === "fork" || state.kind === "join"
|
|
1496
|
+
? state.kind : isHiddenCompositeMarker(state) ? "hidden-composite-marker" : undefined
|
|
1497
|
+
if (!kind) return []
|
|
1498
|
+
const stateBounds = bounds.get(state.id)
|
|
1499
|
+
if (!stateBounds) return []
|
|
1500
|
+
|
|
1501
|
+
const connections = new Set<DiagramDirection>()
|
|
1502
|
+
const transitions: StateVisibleTransition[] = []
|
|
1503
|
+
for (const transition of diagram.transitions) {
|
|
1504
|
+
const renderPlan = renderPlanByTransition.get(transition)
|
|
1505
|
+
let connected = false
|
|
1506
|
+
if (transition.to === state.id) {
|
|
1507
|
+
const junction = renderPlan?.path.at(-1)
|
|
1508
|
+
const neighbor = renderPlan?.path.at(-2)
|
|
1509
|
+
if (junction && neighbor) connections.add(connectionDirection(junction, neighbor))
|
|
1510
|
+
connected = true
|
|
1511
|
+
}
|
|
1512
|
+
if (transition.from === state.id) {
|
|
1513
|
+
const neighbor = renderPlan?.path[0]
|
|
1514
|
+
if (neighbor) connections.add(connectionDirection([stateBounds.left, stateBounds.top], neighbor))
|
|
1515
|
+
connected = true
|
|
1516
|
+
}
|
|
1517
|
+
if (connected) transitions.push(transition)
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
return [{ state, bounds: stateBounds, connections, transitions, kind }]
|
|
1521
|
+
})
|
|
1522
|
+
}
|