@typeonce/effect-machine-devtools 0.27.0 → 0.28.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/README.md +1 -1
- package/dist/client/assets/index-ZUqYOHbc.css +1 -0
- package/dist/client/assets/index-gxgK_rqB.js +37 -0
- package/dist/client/index.html +2 -2
- package/package.json +2 -2
- package/src/internal/browser/chart-layout-policy.ts +13 -8
- package/src/internal/browser/chart-layout.ts +1050 -429
- package/src/internal/browser/chart-model.ts +0 -57
- package/src/internal/browser/chart-renderer.ts +35 -25
- package/src/internal/browser/hierarchy-routing-example.ts +91 -0
- package/src/internal/browser/machine-index.ts +23 -0
- package/src/internal/browser/shared-terminal-routing-example.ts +141 -0
- package/src/internal/browser/styles.css +72 -41
- package/src/internal/browser/visualizer-app.ts +59 -4
- package/dist/client/assets/index-BFxKU97K.js +0 -37
- package/dist/client/assets/index-BsorPSDS.css +0 -1
|
@@ -12,8 +12,9 @@ import ELKBundle from "elkjs/lib/elk.bundled.js"
|
|
|
12
12
|
import { type ChartPortSide, makeChartLayoutPolicy } from "./chart-layout-policy.js"
|
|
13
13
|
import type { ChartEdge, ChartInitial, ChartModel, ChartNode, ChartRuntimeTarget } from "./chart-model.js"
|
|
14
14
|
|
|
15
|
-
export const maxVisibleFields = 4
|
|
16
15
|
export const maxVisibleActivities = 3
|
|
16
|
+
export const chartSelfLoopMinimumClearance = 24
|
|
17
|
+
export const chartEdgeLabelSpacing = 5
|
|
17
18
|
|
|
18
19
|
export interface ChartPoint {
|
|
19
20
|
readonly x: number
|
|
@@ -80,6 +81,36 @@ export interface LaidOutChart {
|
|
|
80
81
|
readonly edges: ReadonlyArray<LaidOutChartTransition | LaidOutChartInitialEdge>
|
|
81
82
|
}
|
|
82
83
|
|
|
84
|
+
export type ChartLayoutIssueCode =
|
|
85
|
+
| "detached-source"
|
|
86
|
+
| "detached-terminal"
|
|
87
|
+
| "missing-edge"
|
|
88
|
+
| "label-detached"
|
|
89
|
+
| "label-label-overlap"
|
|
90
|
+
| "label-node-overlap"
|
|
91
|
+
| "label-route-overlap"
|
|
92
|
+
| "node-crossing"
|
|
93
|
+
| "route-overlap"
|
|
94
|
+
| "self-loop-clearance"
|
|
95
|
+
| "self-loop-outside-parent"
|
|
96
|
+
| "short-source"
|
|
97
|
+
| "short-terminal"
|
|
98
|
+
| "wrong-source-direction"
|
|
99
|
+
| "wrong-terminal-direction"
|
|
100
|
+
|
|
101
|
+
export interface ChartLayoutIssue {
|
|
102
|
+
readonly code: ChartLayoutIssueCode
|
|
103
|
+
readonly edgeId: string
|
|
104
|
+
readonly relatedId: string | null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface ChartLayoutValidation {
|
|
108
|
+
readonly valid: boolean
|
|
109
|
+
readonly issues: ReadonlyArray<ChartLayoutIssue>
|
|
110
|
+
readonly crossings: number
|
|
111
|
+
readonly routeLength: number
|
|
112
|
+
}
|
|
113
|
+
|
|
83
114
|
export class ChartLayoutError extends Data.TaggedError("ChartLayoutError")<{
|
|
84
115
|
readonly cause: unknown
|
|
85
116
|
readonly message: string
|
|
@@ -91,23 +122,130 @@ interface NodeMetric {
|
|
|
91
122
|
readonly headerHeight: number
|
|
92
123
|
}
|
|
93
124
|
|
|
94
|
-
|
|
125
|
+
interface ChartRect {
|
|
126
|
+
readonly left: number
|
|
127
|
+
readonly right: number
|
|
128
|
+
readonly top: number
|
|
129
|
+
readonly bottom: number
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface UnconnectedRegion {
|
|
133
|
+
readonly id: string
|
|
134
|
+
readonly parent: string | null
|
|
135
|
+
readonly nodePaths: ReadonlyArray<string>
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
type PortConstraints = "fixed" | "relaxed"
|
|
139
|
+
|
|
140
|
+
interface LayoutProfile {
|
|
141
|
+
readonly id: string
|
|
142
|
+
readonly portConstraints: PortConstraints
|
|
143
|
+
readonly nodeSpacing: number
|
|
144
|
+
readonly layerSpacing: number
|
|
145
|
+
readonly edgeNodeSpacing: number
|
|
146
|
+
readonly edgeEdgeSpacing: number
|
|
147
|
+
readonly compoundNodeSpacing: number
|
|
148
|
+
readonly compoundLayerSpacing: number
|
|
149
|
+
readonly selfLoopSpacing: number
|
|
150
|
+
readonly padding: number
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const layoutProfiles: ReadonlyArray<LayoutProfile> = [
|
|
154
|
+
{
|
|
155
|
+
id: "compact-fixed",
|
|
156
|
+
portConstraints: "fixed",
|
|
157
|
+
nodeSpacing: 48,
|
|
158
|
+
layerSpacing: 72,
|
|
159
|
+
edgeNodeSpacing: 28,
|
|
160
|
+
edgeEdgeSpacing: 18,
|
|
161
|
+
compoundNodeSpacing: 32,
|
|
162
|
+
compoundLayerSpacing: 60,
|
|
163
|
+
selfLoopSpacing: 40,
|
|
164
|
+
padding: 36
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: "spacious-fixed",
|
|
168
|
+
portConstraints: "fixed",
|
|
169
|
+
nodeSpacing: 68,
|
|
170
|
+
layerSpacing: 96,
|
|
171
|
+
edgeNodeSpacing: 42,
|
|
172
|
+
edgeEdgeSpacing: 28,
|
|
173
|
+
compoundNodeSpacing: 48,
|
|
174
|
+
compoundLayerSpacing: 80,
|
|
175
|
+
selfLoopSpacing: 48,
|
|
176
|
+
padding: 48
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
id: "roomy-fixed",
|
|
180
|
+
portConstraints: "fixed",
|
|
181
|
+
nodeSpacing: 88,
|
|
182
|
+
layerSpacing: 124,
|
|
183
|
+
edgeNodeSpacing: 58,
|
|
184
|
+
edgeEdgeSpacing: 40,
|
|
185
|
+
compoundNodeSpacing: 64,
|
|
186
|
+
compoundLayerSpacing: 104,
|
|
187
|
+
selfLoopSpacing: 56,
|
|
188
|
+
padding: 60
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
id: "spacious-relaxed",
|
|
192
|
+
portConstraints: "relaxed",
|
|
193
|
+
nodeSpacing: 68,
|
|
194
|
+
layerSpacing: 96,
|
|
195
|
+
edgeNodeSpacing: 42,
|
|
196
|
+
edgeEdgeSpacing: 28,
|
|
197
|
+
compoundNodeSpacing: 48,
|
|
198
|
+
compoundLayerSpacing: 80,
|
|
199
|
+
selfLoopSpacing: 48,
|
|
200
|
+
padding: 48
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
id: "roomy-relaxed",
|
|
204
|
+
portConstraints: "relaxed",
|
|
205
|
+
nodeSpacing: 88,
|
|
206
|
+
layerSpacing: 124,
|
|
207
|
+
edgeNodeSpacing: 58,
|
|
208
|
+
edgeEdgeSpacing: 40,
|
|
209
|
+
compoundNodeSpacing: 64,
|
|
210
|
+
compoundLayerSpacing: 104,
|
|
211
|
+
selfLoopSpacing: 56,
|
|
212
|
+
padding: 60
|
|
213
|
+
}
|
|
214
|
+
]
|
|
215
|
+
|
|
216
|
+
const activitySectionHeight = (length: number): number => {
|
|
95
217
|
if (length === 0) return 0
|
|
96
|
-
const visible = Math.min(length,
|
|
97
|
-
return
|
|
218
|
+
const visible = Math.min(length, maxVisibleActivities)
|
|
219
|
+
return 16 + visible * 20 + (length > maxVisibleActivities ? 16 : 0)
|
|
98
220
|
}
|
|
99
221
|
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
222
|
+
const approximateTextWidth = (value: string, characterWidth: number): number => value.length * characterWidth
|
|
223
|
+
|
|
224
|
+
const nodeMetric = (node: ChartNode, selfLoops: number): NodeMetric => {
|
|
225
|
+
const headerHeight = 52 + activitySectionHeight(node.activities.length)
|
|
226
|
+
const nameWidth = approximateTextWidth(node.label, 7.2) + 55
|
|
227
|
+
const activityWidth = node.activities.reduce(
|
|
228
|
+
(width, activity) =>
|
|
229
|
+
Math.max(
|
|
230
|
+
width,
|
|
231
|
+
approximateTextWidth(activity.kind.toUpperCase(), 5.5) +
|
|
232
|
+
approximateTextWidth(activity.label, 6.1) + 48
|
|
233
|
+
),
|
|
234
|
+
0
|
|
106
235
|
)
|
|
107
|
-
const
|
|
236
|
+
const minimumWidth = node.type === "choice" || node.type === "history"
|
|
237
|
+
? 132
|
|
238
|
+
: node.children.length > 0
|
|
239
|
+
? 220
|
|
240
|
+
: 144
|
|
241
|
+
const selfLoopWidth = selfLoops <= 1 ? 0 : 144 + (selfLoops - 1) * 44
|
|
242
|
+
const selfLoopHeight = selfLoops <= 1 ? 0 : 52 + (selfLoops - 1) * 16
|
|
243
|
+
const width = Math.min(320, Math.max(minimumWidth, nameWidth, activityWidth, selfLoopWidth))
|
|
108
244
|
return {
|
|
109
245
|
width,
|
|
110
|
-
height: node.children.length === 0
|
|
246
|
+
height: node.children.length === 0
|
|
247
|
+
? Math.max(headerHeight, selfLoopHeight)
|
|
248
|
+
: Math.max(180, headerHeight + 88),
|
|
111
249
|
headerHeight
|
|
112
250
|
}
|
|
113
251
|
}
|
|
@@ -117,37 +255,18 @@ const elk = new ELK()
|
|
|
117
255
|
|
|
118
256
|
const sourcePortId = (edge: ChartEdge): string => `port:${edge.id}:source`
|
|
119
257
|
const targetPortId = (edge: ChartEdge): string => `port:${edge.id}:target`
|
|
120
|
-
const initialNodeId = (initial: ChartInitial): string => `node:${initial.id}`
|
|
121
|
-
const initialTargetPortId = (initial: ChartInitial): string => `port:${initial.id}:target`
|
|
122
258
|
const runtimeNodeId = (target: ChartRuntimeTarget): string => `node:${target.id}`
|
|
123
259
|
const runtimeTargetPortId = (target: ChartRuntimeTarget): string => `port:${target.edgeId}:target`
|
|
124
260
|
const unconnectedRegionId = (parent: string | null): string => `region:unconnected:${parent ?? "root"}`
|
|
125
261
|
const isSelfTransition = (edge: ChartEdge): boolean => edge.kind === "targetless" || edge.target === edge.source
|
|
126
|
-
type PortConstraints = "fixed" | "relaxed"
|
|
127
|
-
|
|
128
|
-
export const chartEdgeTerminalClearance = 24
|
|
129
|
-
export const chartSelfLoopLabelGap = 8
|
|
130
|
-
export const chartSelfLoopParentAllowance = 78
|
|
131
|
-
const chartSelfLoopRouteGap = 30
|
|
132
|
-
const chartSelfLoopLaneGap = 52
|
|
133
|
-
const chartTerminalLaneGap = 18
|
|
134
|
-
const chartLabelCollisionGap = 8
|
|
135
|
-
|
|
136
|
-
const selfLoopAllowance = (count: number): number =>
|
|
137
|
-
count === 0 ? 0 : chartSelfLoopParentAllowance + (count - 1) * chartSelfLoopLaneGap
|
|
138
|
-
|
|
139
262
|
const isDescendantPath = (path: string, ancestor: string): boolean => path.startsWith(`${ancestor}.`)
|
|
140
263
|
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
edge.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
interface UnconnectedRegion {
|
|
148
|
-
readonly id: string
|
|
149
|
-
readonly parent: string | null
|
|
150
|
-
readonly nodePaths: ReadonlyArray<string>
|
|
264
|
+
const selfLoopsBySource = (edges: ReadonlyArray<ChartEdge>): ReadonlyMap<string, number> => {
|
|
265
|
+
const counts = new Map<string, number>()
|
|
266
|
+
for (const edge of edges) {
|
|
267
|
+
if (isSelfTransition(edge)) counts.set(edge.source, (counts.get(edge.source) ?? 0) + 1)
|
|
268
|
+
}
|
|
269
|
+
return counts
|
|
151
270
|
}
|
|
152
271
|
|
|
153
272
|
const unconnectedRegions = (
|
|
@@ -166,6 +285,12 @@ const unconnectedRegions = (
|
|
|
166
285
|
})
|
|
167
286
|
}
|
|
168
287
|
|
|
288
|
+
const edgeTargetPath = (edge: ChartEdge): string | null => {
|
|
289
|
+
if (edge.kind === "target") return edge.target
|
|
290
|
+
if (edge.kind === "targetless") return edge.source
|
|
291
|
+
return null
|
|
292
|
+
}
|
|
293
|
+
|
|
169
294
|
const portsByState = (
|
|
170
295
|
edges: ReadonlyArray<ChartEdge>,
|
|
171
296
|
edgePolicy: ReturnType<typeof makeChartLayoutPolicy>["edge"],
|
|
@@ -188,9 +313,8 @@ const portsByState = (
|
|
|
188
313
|
for (const edge of edges) {
|
|
189
314
|
const policy = edgePolicy(edge)
|
|
190
315
|
add(edge.source, sourcePortId(edge), policy.sourceSide)
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
316
|
+
const target = edgeTargetPath(edge)
|
|
317
|
+
if (target !== null) add(target, targetPortId(edge), policy.targetSide)
|
|
194
318
|
}
|
|
195
319
|
return ports
|
|
196
320
|
}
|
|
@@ -204,64 +328,19 @@ const makeGraph = (
|
|
|
204
328
|
model: ChartModel,
|
|
205
329
|
policy: ReturnType<typeof makeChartLayoutPolicy>,
|
|
206
330
|
regions: ReadonlyArray<UnconnectedRegion>,
|
|
207
|
-
|
|
331
|
+
profile: LayoutProfile
|
|
208
332
|
): ElkNode => {
|
|
209
|
-
const nodesByPath = new Map(model.nodes.map((node) => [node.path, node]))
|
|
210
333
|
const regionsByParent = new Map(regions.map((region) => [region.parent, region]))
|
|
211
334
|
const sourceByEdgeId = new Map(model.edges.map((edge) => [edge.id, edge.source]))
|
|
212
|
-
const
|
|
213
|
-
const
|
|
214
|
-
for (const edge of model.edges) {
|
|
215
|
-
if (isSelfTransition(edge)) {
|
|
216
|
-
selfLoopCounts.set(edge.source, (selfLoopCounts.get(edge.source) ?? 0) + 1)
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
const selfLoopAllowanceByParent = new Map<string, number>()
|
|
220
|
-
for (const [source, count] of selfLoopCounts) {
|
|
221
|
-
const parent = nodesByPath.get(source)?.parent
|
|
222
|
-
if (parent === null || parent === undefined) continue
|
|
223
|
-
selfLoopAllowanceByParent.set(
|
|
224
|
-
parent,
|
|
225
|
-
Math.max(
|
|
226
|
-
selfLoopAllowanceByParent.get(parent) ?? 0,
|
|
227
|
-
selfLoopAllowance(count + externalIncomingCount(model, source))
|
|
228
|
-
)
|
|
229
|
-
)
|
|
230
|
-
}
|
|
231
|
-
const initialsByParent = new Map<string | null, Array<ChartInitial>>()
|
|
232
|
-
for (const initial of model.initials) {
|
|
233
|
-
const siblings = initialsByParent.get(initial.parent) ?? []
|
|
234
|
-
siblings.push(initial)
|
|
235
|
-
initialsByParent.set(initial.parent, siblings)
|
|
236
|
-
}
|
|
335
|
+
const runtimeByEdgeId = new Map(model.runtimeTargets.map((target) => [target.edgeId, target]))
|
|
336
|
+
const selfLoops = selfLoopsBySource(model.edges)
|
|
237
337
|
const runtimeTargetsByParent = new Map<string | null, Array<ChartRuntimeTarget>>()
|
|
238
338
|
for (const target of model.runtimeTargets) {
|
|
239
339
|
const siblings = runtimeTargetsByParent.get(target.parent) ?? []
|
|
240
340
|
siblings.push(target)
|
|
241
341
|
runtimeTargetsByParent.set(target.parent, siblings)
|
|
242
342
|
}
|
|
243
|
-
const ports = portsByState(
|
|
244
|
-
for (const initial of model.initials) {
|
|
245
|
-
const statePorts = ports.get(initial.target) ?? []
|
|
246
|
-
statePorts.push({
|
|
247
|
-
id: initialTargetPortId(initial),
|
|
248
|
-
width: 6,
|
|
249
|
-
height: 6,
|
|
250
|
-
...(portConstraints === "fixed"
|
|
251
|
-
? { layoutOptions: { "elk.port.side": "WEST" } }
|
|
252
|
-
: {})
|
|
253
|
-
})
|
|
254
|
-
ports.set(initial.target, statePorts)
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
const initialNode = (initial: ChartInitial): ElkNode => ({
|
|
258
|
-
id: initialNodeId(initial),
|
|
259
|
-
width: 14,
|
|
260
|
-
height: 14,
|
|
261
|
-
layoutOptions: {
|
|
262
|
-
"elk.layered.layering.layerConstraint": "FIRST"
|
|
263
|
-
}
|
|
264
|
-
})
|
|
343
|
+
const ports = portsByState(model.edges, policy.edge, profile.portConstraints)
|
|
265
344
|
const runtimeNode = (target: ChartRuntimeTarget): ElkNode => ({
|
|
266
345
|
id: runtimeNodeId(target),
|
|
267
346
|
width: 118,
|
|
@@ -270,37 +349,34 @@ const makeGraph = (
|
|
|
270
349
|
id: runtimeTargetPortId(target),
|
|
271
350
|
width: 6,
|
|
272
351
|
height: 6,
|
|
273
|
-
...(portConstraints === "fixed"
|
|
274
|
-
? { layoutOptions: { "elk.port.side": "
|
|
352
|
+
...(profile.portConstraints === "fixed"
|
|
353
|
+
? { layoutOptions: { "elk.port.side": "NORTH" } }
|
|
275
354
|
: {})
|
|
276
355
|
}],
|
|
277
|
-
...(portConstraints === "fixed"
|
|
356
|
+
...(profile.portConstraints === "fixed"
|
|
278
357
|
? { layoutOptions: { "elk.portConstraints": "FIXED_SIDE" } }
|
|
279
358
|
: {})
|
|
280
359
|
})
|
|
281
360
|
|
|
282
361
|
const stateNode = (node: ChartNode, suppressUnconnectedRegion: boolean): ElkNode => {
|
|
283
|
-
const metric = nodeMetric(node)
|
|
362
|
+
const metric = nodeMetric(node, selfLoops.get(node.path) ?? 0)
|
|
284
363
|
const nodePolicy = policy.node(node.path)
|
|
285
364
|
const descendants = children(node.path, suppressUnconnectedRegion || !nodePolicy.staticPath)
|
|
286
|
-
const bottomPadding = 28 + (selfLoopAllowanceByParent.get(node.path) ?? 0)
|
|
287
365
|
const common = {
|
|
288
366
|
id: node.path,
|
|
289
367
|
ports: [...ports.get(node.path) ?? []],
|
|
290
368
|
layoutOptions: {
|
|
291
|
-
...(portConstraints === "fixed"
|
|
292
|
-
|
|
369
|
+
...(profile.portConstraints === "fixed" && node.children.length === 0
|
|
370
|
+
? { "elk.portConstraints": "FIXED_SIDE" }
|
|
371
|
+
: {}),
|
|
372
|
+
"elk.spacing.portPort": "24",
|
|
293
373
|
...(nodePolicy.layerConstraint === null
|
|
294
374
|
? {}
|
|
295
375
|
: { "elk.layered.layering.layerConstraint": nodePolicy.layerConstraint })
|
|
296
376
|
}
|
|
297
377
|
}
|
|
298
378
|
if (descendants.length === 0) {
|
|
299
|
-
return {
|
|
300
|
-
...common,
|
|
301
|
-
width: metric.width,
|
|
302
|
-
height: metric.height
|
|
303
|
-
}
|
|
379
|
+
return { ...common, width: metric.width, height: metric.height }
|
|
304
380
|
}
|
|
305
381
|
return {
|
|
306
382
|
...common,
|
|
@@ -308,12 +384,18 @@ const makeGraph = (
|
|
|
308
384
|
layoutOptions: {
|
|
309
385
|
...common.layoutOptions,
|
|
310
386
|
"elk.algorithm": "layered",
|
|
311
|
-
"elk.direction": node.type === "parallel" ? "
|
|
312
|
-
"elk.padding": `[top=${metric.headerHeight +
|
|
387
|
+
"elk.direction": node.type === "parallel" ? "RIGHT" : "DOWN",
|
|
388
|
+
"elk.padding": `[top=${metric.headerHeight + 36},left=36,bottom=36,right=36]`,
|
|
313
389
|
"elk.nodeSize.constraints": "MINIMUM_SIZE",
|
|
314
390
|
"elk.nodeSize.minimum": `(${metric.width}, ${metric.height})`,
|
|
315
|
-
"elk.spacing.nodeNode":
|
|
316
|
-
"elk.layered.spacing.nodeNodeBetweenLayers":
|
|
391
|
+
"elk.spacing.nodeNode": String(profile.compoundNodeSpacing),
|
|
392
|
+
"elk.layered.spacing.nodeNodeBetweenLayers": String(profile.compoundLayerSpacing),
|
|
393
|
+
"elk.spacing.edgeNode": String(profile.edgeNodeSpacing),
|
|
394
|
+
"elk.spacing.edgeEdge": String(profile.edgeEdgeSpacing),
|
|
395
|
+
"elk.spacing.edgeLabel": String(chartEdgeLabelSpacing),
|
|
396
|
+
"elk.spacing.nodeSelfLoop": String(profile.selfLoopSpacing),
|
|
397
|
+
"elk.layered.spacing.edgeNodeBetweenLayers": String(profile.edgeNodeSpacing),
|
|
398
|
+
"elk.layered.spacing.edgeEdgeBetweenLayers": String(profile.edgeEdgeSpacing)
|
|
317
399
|
}
|
|
318
400
|
}
|
|
319
401
|
}
|
|
@@ -321,11 +403,9 @@ const makeGraph = (
|
|
|
321
403
|
function children(parent: string | null, suppressUnconnectedRegion = false): Array<ElkNode> {
|
|
322
404
|
const region = suppressUnconnectedRegion ? undefined : regionsByParent.get(parent)
|
|
323
405
|
const regionPaths = new Set(region?.nodePaths ?? [])
|
|
324
|
-
const initials = initialsByParent.get(parent) ?? []
|
|
325
406
|
const runtimeTargets = runtimeTargetsByParent.get(parent) ?? []
|
|
326
407
|
const states = policy.children(parent)
|
|
327
408
|
const regular: Array<ElkNode> = [
|
|
328
|
-
...initials.filter(({ target }) => !regionPaths.has(target)).map(initialNode),
|
|
329
409
|
...states.filter(({ path }) => !regionPaths.has(path)).map((node) => stateNode(node, suppressUnconnectedRegion)),
|
|
330
410
|
...runtimeTargets
|
|
331
411
|
.filter(({ edgeId }) => !regionPaths.has(sourceByEdgeId.get(edgeId) ?? ""))
|
|
@@ -334,7 +414,6 @@ const makeGraph = (
|
|
|
334
414
|
if (region === undefined) return regular
|
|
335
415
|
|
|
336
416
|
const regionChildren: Array<ElkNode> = [
|
|
337
|
-
...initials.filter(({ target }) => regionPaths.has(target)).map(initialNode),
|
|
338
417
|
...states.filter(({ path }) => regionPaths.has(path)).map((node) => stateNode(node, true)),
|
|
339
418
|
...runtimeTargets
|
|
340
419
|
.filter(({ edgeId }) => regionPaths.has(sourceByEdgeId.get(edgeId) ?? ""))
|
|
@@ -345,11 +424,15 @@ const makeGraph = (
|
|
|
345
424
|
children: regionChildren,
|
|
346
425
|
layoutOptions: {
|
|
347
426
|
"elk.algorithm": "layered",
|
|
348
|
-
"elk.direction": "
|
|
427
|
+
"elk.direction": "DOWN",
|
|
349
428
|
"elk.padding": "[top=54,left=24,bottom=24,right=24]",
|
|
350
429
|
"elk.layered.layering.layerConstraint": "LAST",
|
|
351
|
-
"elk.spacing.nodeNode":
|
|
352
|
-
"elk.layered.spacing.nodeNodeBetweenLayers":
|
|
430
|
+
"elk.spacing.nodeNode": String(profile.nodeSpacing),
|
|
431
|
+
"elk.layered.spacing.nodeNodeBetweenLayers": String(profile.layerSpacing),
|
|
432
|
+
"elk.spacing.edgeNode": String(profile.edgeNodeSpacing),
|
|
433
|
+
"elk.spacing.edgeEdge": String(profile.edgeEdgeSpacing),
|
|
434
|
+
"elk.spacing.edgeLabel": String(chartEdgeLabelSpacing),
|
|
435
|
+
"elk.spacing.nodeSelfLoop": String(profile.selfLoopSpacing)
|
|
353
436
|
}
|
|
354
437
|
})
|
|
355
438
|
return regular
|
|
@@ -359,13 +442,14 @@ const makeGraph = (
|
|
|
359
442
|
id: "chart-root",
|
|
360
443
|
children: children(null),
|
|
361
444
|
edges: [
|
|
362
|
-
...
|
|
445
|
+
...model.edges.map((edge): ElkExtendedEdge => {
|
|
363
446
|
const label = labelMetric(edge.label)
|
|
364
447
|
const edgeLayout = policy.edge(edge)
|
|
448
|
+
const runtimeTarget = edge.kind === "runtime" ? runtimeByEdgeId.get(edge.id) : undefined
|
|
365
449
|
return {
|
|
366
450
|
id: edge.id,
|
|
367
451
|
sources: [sourcePortId(edge)],
|
|
368
|
-
targets: [targetPortId(edge)],
|
|
452
|
+
targets: [runtimeTarget === undefined ? targetPortId(edge) : runtimeTargetPortId(runtimeTarget)],
|
|
369
453
|
labels: [{ text: edge.label, width: label.width, height: label.height }],
|
|
370
454
|
layoutOptions: {
|
|
371
455
|
"elk.layered.priority.direction": edgeLayout.direction === "forward" ? "10" : "1",
|
|
@@ -373,30 +457,23 @@ const makeGraph = (
|
|
|
373
457
|
"elk.layered.priority.straightness": "5"
|
|
374
458
|
}
|
|
375
459
|
}
|
|
376
|
-
})
|
|
377
|
-
...model.initials.map((initial): ElkExtendedEdge => ({
|
|
378
|
-
id: initial.id,
|
|
379
|
-
sources: [initialNodeId(initial)],
|
|
380
|
-
targets: [initialTargetPortId(initial)],
|
|
381
|
-
layoutOptions: {
|
|
382
|
-
"elk.layered.priority.direction": "100",
|
|
383
|
-
"elk.layered.priority.shortness": "100",
|
|
384
|
-
"elk.layered.priority.straightness": "100"
|
|
385
|
-
}
|
|
386
|
-
}))
|
|
460
|
+
})
|
|
387
461
|
],
|
|
388
462
|
layoutOptions: {
|
|
389
463
|
"elk.algorithm": "layered",
|
|
390
|
-
"elk.direction": "
|
|
464
|
+
"elk.direction": "DOWN",
|
|
391
465
|
"elk.hierarchyHandling": "INCLUDE_CHILDREN",
|
|
392
466
|
"elk.edgeRouting": "ORTHOGONAL",
|
|
393
|
-
"elk.padding":
|
|
394
|
-
|
|
395
|
-
"elk.
|
|
396
|
-
"elk.layered.spacing.
|
|
397
|
-
"elk.layered.spacing.
|
|
398
|
-
"elk.spacing.
|
|
399
|
-
"elk.spacing.
|
|
467
|
+
"elk.padding":
|
|
468
|
+
`[top=${profile.padding},left=${profile.padding},bottom=${profile.padding},right=${profile.padding}]`,
|
|
469
|
+
"elk.spacing.nodeNode": String(profile.nodeSpacing),
|
|
470
|
+
"elk.layered.spacing.nodeNodeBetweenLayers": String(profile.layerSpacing),
|
|
471
|
+
"elk.layered.spacing.edgeNodeBetweenLayers": String(profile.edgeNodeSpacing),
|
|
472
|
+
"elk.layered.spacing.edgeEdgeBetweenLayers": String(profile.edgeEdgeSpacing),
|
|
473
|
+
"elk.spacing.edgeNode": String(profile.edgeNodeSpacing),
|
|
474
|
+
"elk.spacing.edgeEdge": String(profile.edgeEdgeSpacing),
|
|
475
|
+
"elk.spacing.edgeLabel": String(chartEdgeLabelSpacing),
|
|
476
|
+
"elk.spacing.nodeSelfLoop": String(profile.selfLoopSpacing),
|
|
400
477
|
"elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES",
|
|
401
478
|
"elk.layered.considerModelOrder.portModelOrder": "false",
|
|
402
479
|
"elk.layered.considerModelOrder.crossingCounterNodeInfluence": "0.001",
|
|
@@ -480,243 +557,423 @@ const midpoint = (points: ReadonlyArray<ChartPoint>): ChartPoint => {
|
|
|
480
557
|
return points.at(-1)!
|
|
481
558
|
}
|
|
482
559
|
|
|
483
|
-
const
|
|
560
|
+
const horizontalBoundaryIntersection = (
|
|
561
|
+
start: ChartPoint,
|
|
562
|
+
end: ChartPoint,
|
|
563
|
+
y: number,
|
|
564
|
+
left: number,
|
|
565
|
+
right: number
|
|
566
|
+
): ChartPoint | null => {
|
|
567
|
+
if (start.x === end.x) {
|
|
568
|
+
if (start.x < left || start.x > right || (start.y - y) * (end.y - y) > 0) return null
|
|
569
|
+
return { x: start.x, y }
|
|
570
|
+
}
|
|
571
|
+
if (start.y !== y || end.y !== y) return null
|
|
572
|
+
const minimum = Math.max(left, Math.min(start.x, end.x))
|
|
573
|
+
const maximum = Math.min(right, Math.max(start.x, end.x))
|
|
574
|
+
if (minimum > maximum) return null
|
|
575
|
+
return { x: start.x <= end.x ? minimum : maximum, y }
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const trimRouteFromCompoundHeader = (
|
|
484
579
|
points: ReadonlyArray<ChartPoint>,
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
let result: readonly [ChartPoint, ChartPoint] | undefined
|
|
489
|
-
let resultLength = -1
|
|
580
|
+
node: LaidOutChartNode
|
|
581
|
+
): ReadonlyArray<ChartPoint> => {
|
|
582
|
+
const boundary = node.y + node.headerHeight
|
|
490
583
|
for (let index = 1; index < points.length; index++) {
|
|
491
|
-
const
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
584
|
+
const intersection = horizontalBoundaryIntersection(
|
|
585
|
+
points[index - 1]!,
|
|
586
|
+
points[index]!,
|
|
587
|
+
boundary,
|
|
588
|
+
node.x,
|
|
589
|
+
node.x + node.width
|
|
590
|
+
)
|
|
591
|
+
if (intersection !== null) return compactPoints([intersection, ...points.slice(index)])
|
|
499
592
|
}
|
|
500
|
-
return
|
|
593
|
+
return points
|
|
501
594
|
}
|
|
502
595
|
|
|
503
|
-
|
|
596
|
+
const normalizeHierarchyRoute = (
|
|
597
|
+
edge: ChartEdge,
|
|
504
598
|
points: ReadonlyArray<ChartPoint>,
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
points,
|
|
516
|
-
(left, right) => left.y === outerY && right.y === outerY,
|
|
517
|
-
(left, right) => Math.abs(right.x - left.x)
|
|
518
|
-
)
|
|
519
|
-
return {
|
|
520
|
-
x: segment === undefined ? (start.x + end.x) / 2 : (segment[0].x + segment[1].x) / 2,
|
|
521
|
-
y: outerY + chartSelfLoopLabelGap + labelHeight / 2
|
|
522
|
-
}
|
|
599
|
+
nodes: ReadonlyMap<string, LaidOutChartNode>
|
|
600
|
+
): ReadonlyArray<ChartPoint> => {
|
|
601
|
+
if (edge.target !== null && isDescendantPath(edge.target, edge.source)) {
|
|
602
|
+
const source = nodes.get(edge.source)
|
|
603
|
+
return source === undefined ? points : trimRouteFromCompoundHeader(points, source)
|
|
604
|
+
}
|
|
605
|
+
if (edge.target !== null && isDescendantPath(edge.source, edge.target)) {
|
|
606
|
+
const target = nodes.get(edge.target)
|
|
607
|
+
return target === undefined
|
|
608
|
+
? points
|
|
609
|
+
: [...trimRouteFromCompoundHeader([...points].reverse(), target)].reverse()
|
|
523
610
|
}
|
|
611
|
+
return points
|
|
612
|
+
}
|
|
524
613
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
614
|
+
type RouteSide = "NORTH" | "EAST" | "SOUTH" | "WEST"
|
|
615
|
+
|
|
616
|
+
const routeNodeRect = (node: LaidOutChartNode): ChartRect =>
|
|
617
|
+
node.node.children.length > 0 ? nodeHeaderRect(node) : nodeRect(node)
|
|
618
|
+
|
|
619
|
+
const expandRect = (rect: ChartRect, amount: number): ChartRect => ({
|
|
620
|
+
left: rect.left - amount,
|
|
621
|
+
right: rect.right + amount,
|
|
622
|
+
top: rect.top - amount,
|
|
623
|
+
bottom: rect.bottom + amount
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
const endpointSide = (point: ChartPoint, node: LaidOutChartNode): RouteSide => {
|
|
627
|
+
const rect = routeNodeRect(node)
|
|
628
|
+
const distances: Array<readonly [RouteSide, number]> = [
|
|
629
|
+
["NORTH", Math.abs(point.y - rect.top)],
|
|
630
|
+
["SOUTH", Math.abs(point.y - rect.bottom)],
|
|
631
|
+
["WEST", Math.abs(point.x - rect.left)],
|
|
632
|
+
["EAST", Math.abs(point.x - rect.right)]
|
|
633
|
+
]
|
|
634
|
+
return distances.sort((left, right) => left[1] - right[1])[0]![0]
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const outwardPoint = (point: ChartPoint, side: RouteSide, distance: number): ChartPoint => {
|
|
638
|
+
switch (side) {
|
|
639
|
+
case "NORTH":
|
|
640
|
+
return { x: point.x, y: point.y - distance }
|
|
641
|
+
case "SOUTH":
|
|
642
|
+
return { x: point.x, y: point.y + distance }
|
|
643
|
+
case "WEST":
|
|
644
|
+
return { x: point.x - distance, y: point.y }
|
|
645
|
+
case "EAST":
|
|
646
|
+
return { x: point.x + distance, y: point.y }
|
|
534
647
|
}
|
|
535
648
|
}
|
|
536
649
|
|
|
537
|
-
const
|
|
538
|
-
|
|
650
|
+
const isOutwardStep = (boundary: ChartPoint, adjacent: ChartPoint, side: RouteSide): boolean =>
|
|
651
|
+
side === "NORTH"
|
|
652
|
+
? adjacent.x === boundary.x && adjacent.y < boundary.y
|
|
653
|
+
: side === "SOUTH"
|
|
654
|
+
? adjacent.x === boundary.x && adjacent.y > boundary.y
|
|
655
|
+
: side === "WEST"
|
|
656
|
+
? adjacent.y === boundary.y && adjacent.x < boundary.x
|
|
657
|
+
: adjacent.y === boundary.y && adjacent.x > boundary.x
|
|
658
|
+
|
|
659
|
+
const pointOnNodeBoundary = (
|
|
660
|
+
point: ChartPoint,
|
|
539
661
|
node: LaidOutChartNode,
|
|
540
|
-
|
|
541
|
-
):
|
|
542
|
-
const
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
{ x: centerX + halfWidth, y: bottom }
|
|
553
|
-
])
|
|
554
|
-
return {
|
|
555
|
-
kind: "transition",
|
|
556
|
-
edge,
|
|
557
|
-
points,
|
|
558
|
-
label: selfLoopLabelPosition(points, metric.width, metric.height),
|
|
559
|
-
labelWidth: metric.width,
|
|
560
|
-
labelHeight: metric.height
|
|
662
|
+
side: RouteSide
|
|
663
|
+
): ChartPoint => {
|
|
664
|
+
const rect = routeNodeRect(node)
|
|
665
|
+
switch (side) {
|
|
666
|
+
case "NORTH":
|
|
667
|
+
return { x: Math.min(rect.right, Math.max(rect.left, point.x)), y: rect.top }
|
|
668
|
+
case "SOUTH":
|
|
669
|
+
return { x: Math.min(rect.right, Math.max(rect.left, point.x)), y: rect.bottom }
|
|
670
|
+
case "WEST":
|
|
671
|
+
return { x: rect.left, y: Math.min(rect.bottom, Math.max(rect.top, point.y)) }
|
|
672
|
+
case "EAST":
|
|
673
|
+
return { x: rect.right, y: Math.min(rect.bottom, Math.max(rect.top, point.y)) }
|
|
561
674
|
}
|
|
562
675
|
}
|
|
563
676
|
|
|
564
|
-
|
|
565
|
-
|
|
677
|
+
const nodeBoundaryDistance = (point: ChartPoint, node: LaidOutChartNode): number => {
|
|
678
|
+
const boundary = pointOnNodeBoundary(point, node, endpointSide(point, node))
|
|
679
|
+
return Math.abs(point.x - boundary.x) + Math.abs(point.y - boundary.y)
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const orthogonalConnections = (
|
|
683
|
+
start: ChartPoint,
|
|
684
|
+
end: ChartPoint
|
|
685
|
+
): ReadonlyArray<ReadonlyArray<ChartPoint>> => {
|
|
686
|
+
if (start.x === end.x || start.y === end.y) return [[start, end]]
|
|
687
|
+
return [
|
|
688
|
+
[start, { x: end.x, y: start.y }, end],
|
|
689
|
+
[start, { x: start.x, y: end.y }, end]
|
|
690
|
+
]
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const endpointConnections = (
|
|
694
|
+
start: ChartPoint,
|
|
695
|
+
end: ChartPoint,
|
|
696
|
+
target: ChartRect,
|
|
697
|
+
side: RouteSide
|
|
698
|
+
): ReadonlyArray<ReadonlyArray<ChartPoint>> => {
|
|
699
|
+
const clearance = 12
|
|
700
|
+
const detours = side === "EAST" || side === "WEST"
|
|
701
|
+
? [target.top - clearance, target.bottom + clearance].map((y) =>
|
|
702
|
+
compactPoints([start, { x: start.x, y }, { x: end.x, y }, end])
|
|
703
|
+
)
|
|
704
|
+
: [target.left - clearance, target.right + clearance].map((x) =>
|
|
705
|
+
compactPoints([start, { x, y: start.y }, { x, y: end.y }, end])
|
|
706
|
+
)
|
|
707
|
+
return [...orthogonalConnections(start, end), ...detours]
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const routeObstacles = (
|
|
711
|
+
edge: ChartEdge,
|
|
712
|
+
nodes: ReadonlyArray<LaidOutChartNode>
|
|
713
|
+
): ReadonlyArray<ChartRect> =>
|
|
714
|
+
nodes.flatMap((node) =>
|
|
715
|
+
node.node.path === edge.source || node.node.path === edge.target
|
|
716
|
+
? []
|
|
717
|
+
: [expandRect(routeNodeRect(node), 6)]
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
const routeIsClear = (
|
|
721
|
+
points: ReadonlyArray<ChartPoint>,
|
|
722
|
+
obstacles: ReadonlyArray<ChartRect>
|
|
723
|
+
): boolean =>
|
|
724
|
+
points.slice(1).every((point, index) => {
|
|
725
|
+
const previous = points[index]!
|
|
726
|
+
return (previous.x === point.x || previous.y === point.y) &&
|
|
727
|
+
obstacles.every((obstacle) => !segmentCrossesInterior(previous, point, obstacle))
|
|
728
|
+
})
|
|
729
|
+
|
|
730
|
+
const sameSideRoute = (
|
|
731
|
+
points: ReadonlyArray<ChartPoint>,
|
|
732
|
+
side: RouteSide,
|
|
733
|
+
lane: number
|
|
566
734
|
): ReadonlyArray<ChartPoint> => {
|
|
567
|
-
const
|
|
568
|
-
const
|
|
569
|
-
|
|
570
|
-
const
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
const
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
if (first === 0) return points
|
|
581
|
-
const x = end.x - direction * chartEdgeTerminalClearance
|
|
582
|
-
for (let index = first; index < points.length - 1; index++) result[index]!.x = x
|
|
583
|
-
} else {
|
|
584
|
-
const direction = Math.sign(end.y - bend.y)
|
|
585
|
-
if (direction === 0) return points
|
|
586
|
-
let first = points.length - 2
|
|
587
|
-
while (first > 0 && points[first - 1]!.y === bend.y) first--
|
|
588
|
-
if (first === 0) return points
|
|
589
|
-
const y = end.y - direction * chartEdgeTerminalClearance
|
|
590
|
-
for (let index = first; index < points.length - 1; index++) result[index]!.y = y
|
|
591
|
-
}
|
|
592
|
-
return compactPoints(result)
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
interface TerminalApproach {
|
|
596
|
-
readonly axis: "horizontal" | "vertical"
|
|
597
|
-
readonly direction: number
|
|
598
|
-
readonly end: ChartPoint
|
|
599
|
-
readonly bend: ChartPoint
|
|
600
|
-
readonly bendIndex: number
|
|
735
|
+
const start = points[0]!
|
|
736
|
+
const end = points.at(-1)!
|
|
737
|
+
const offset = 24 + lane * 8
|
|
738
|
+
const coordinate = side === "NORTH"
|
|
739
|
+
? { axis: "y" as const, value: Math.min(start.y, end.y) - offset }
|
|
740
|
+
: side === "SOUTH"
|
|
741
|
+
? { axis: "y" as const, value: Math.max(start.y, end.y) + offset }
|
|
742
|
+
: side === "WEST"
|
|
743
|
+
? { axis: "x" as const, value: Math.min(start.x, end.x) - offset }
|
|
744
|
+
: { axis: "x" as const, value: Math.max(start.x, end.x) + offset }
|
|
745
|
+
return coordinate.axis === "x"
|
|
746
|
+
? compactPoints([start, { x: coordinate.value, y: start.y }, { x: coordinate.value, y: end.y }, end])
|
|
747
|
+
: compactPoints([start, { x: start.x, y: coordinate.value }, { x: end.x, y: coordinate.value }, end])
|
|
601
748
|
}
|
|
602
749
|
|
|
603
|
-
const
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
}
|
|
620
|
-
|
|
750
|
+
const shortenTransitionRoute = (
|
|
751
|
+
edge: ChartEdge,
|
|
752
|
+
points: ReadonlyArray<ChartPoint>,
|
|
753
|
+
nodes: ReadonlyMap<string, LaidOutChartNode>,
|
|
754
|
+
allNodes: ReadonlyArray<LaidOutChartNode>,
|
|
755
|
+
lanes: Map<string, number>
|
|
756
|
+
): ReadonlyArray<ChartPoint> => {
|
|
757
|
+
if (edge.target === null || isSelfTransition(edge)) return points
|
|
758
|
+
const source = nodes.get(edge.source)
|
|
759
|
+
const target = nodes.get(edge.target)
|
|
760
|
+
if (source === undefined || target === undefined) return points
|
|
761
|
+
const sourceSide = endpointSide(points[0]!, source)
|
|
762
|
+
const targetSide = endpointSide(points.at(-1)!, target)
|
|
763
|
+
if (sourceSide !== targetSide) return points
|
|
764
|
+
const obstacles = routeObstacles(edge, allNodes)
|
|
765
|
+
const currentLength = chartRouteLength(points)
|
|
766
|
+
const key = `${edge.target}:${targetSide}`
|
|
767
|
+
const lane = lanes.get(key) ?? 0
|
|
768
|
+
const candidate = sameSideRoute(points, sourceSide, lane)
|
|
769
|
+
if (!routeIsClear(candidate, obstacles) || chartRouteLength(candidate) + 16 >= currentLength) return points
|
|
770
|
+
lanes.set(key, lane + 1)
|
|
771
|
+
return candidate
|
|
621
772
|
}
|
|
622
773
|
|
|
623
|
-
const
|
|
774
|
+
const normalizeTerminalDirection = (
|
|
775
|
+
edge: ChartEdge,
|
|
624
776
|
points: ReadonlyArray<ChartPoint>,
|
|
625
|
-
|
|
626
|
-
|
|
777
|
+
nodes: ReadonlyMap<string, LaidOutChartNode>,
|
|
778
|
+
allNodes: ReadonlyArray<LaidOutChartNode>
|
|
627
779
|
): ReadonlyArray<ChartPoint> => {
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
const start = points[0]!
|
|
649
|
-
return compactPoints([
|
|
650
|
-
start,
|
|
651
|
-
{ x: start.x, y },
|
|
652
|
-
{ x: approach.end.x, y },
|
|
653
|
-
approach.end
|
|
654
|
-
])
|
|
655
|
-
}
|
|
656
|
-
for (let index = first; index <= approach.bendIndex; index++) result[index]!.y = y
|
|
657
|
-
}
|
|
658
|
-
return compactPoints(result)
|
|
780
|
+
if (edge.target === null || isSelfTransition(edge) || points.length < 2) return points
|
|
781
|
+
const target = nodes.get(edge.target)
|
|
782
|
+
if (target === undefined) return points
|
|
783
|
+
const rawEnd = points.at(-1)!
|
|
784
|
+
const side = endpointSide(rawEnd, target)
|
|
785
|
+
const end = pointOnNodeBoundary(rawEnd, target, side)
|
|
786
|
+
const prefix = points.slice(0, -1)
|
|
787
|
+
const previous = prefix.at(-1)!
|
|
788
|
+
const attached = compactPoints([...prefix, end])
|
|
789
|
+
if (isOutwardStep(end, previous, side)) return attached
|
|
790
|
+
|
|
791
|
+
const targetStub = outwardPoint(end, side, 18)
|
|
792
|
+
const obstacles = [...routeObstacles(edge, allNodes), routeNodeRect(target)]
|
|
793
|
+
return endpointConnections(previous, targetStub, routeNodeRect(target), side)
|
|
794
|
+
.filter((connection) =>
|
|
795
|
+
!connection.slice(0, -1).some((point) => point.x === end.x && point.y === end.y) &&
|
|
796
|
+
routeIsClear([...connection, end], obstacles)
|
|
797
|
+
)
|
|
798
|
+
.map((connection) => compactPoints([...prefix, ...connection.slice(1), end]))
|
|
799
|
+
.sort((left, right) => chartRouteLength(left) - chartRouteLength(right))[0] ?? attached
|
|
659
800
|
}
|
|
660
801
|
|
|
661
|
-
const
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
const
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
802
|
+
const normalizeSourceDirection = (
|
|
803
|
+
edge: ChartEdge,
|
|
804
|
+
points: ReadonlyArray<ChartPoint>,
|
|
805
|
+
nodes: ReadonlyMap<string, LaidOutChartNode>,
|
|
806
|
+
allNodes: ReadonlyArray<LaidOutChartNode>
|
|
807
|
+
): ReadonlyArray<ChartPoint> => {
|
|
808
|
+
if (isSelfTransition(edge) || points.length < 2) return points
|
|
809
|
+
const source = nodes.get(edge.source)
|
|
810
|
+
if (source === undefined || source.node.children.length > 0) return points
|
|
811
|
+
const rawStart = points[0]!
|
|
812
|
+
const side = endpointSide(rawStart, source)
|
|
813
|
+
const start = pointOnNodeBoundary(rawStart, source, side)
|
|
814
|
+
const tail = points.slice(1)
|
|
815
|
+
const next = tail[0]!
|
|
816
|
+
const attached = compactPoints([start, ...tail])
|
|
817
|
+
if (isOutwardStep(start, next, side)) return attached
|
|
818
|
+
|
|
819
|
+
const sourceStub = outwardPoint(start, side, 18)
|
|
820
|
+
const obstacles = [...routeObstacles(edge, allNodes), routeNodeRect(source)]
|
|
821
|
+
return endpointConnections(sourceStub, next, routeNodeRect(source), side)
|
|
822
|
+
.filter((connection) => routeIsClear([start, ...connection, ...tail.slice(1)], obstacles))
|
|
823
|
+
.map((connection) => compactPoints([start, ...connection, ...tail.slice(1)]))
|
|
824
|
+
.sort((left, right) => chartRouteLength(left) - chartRouteLength(right))[0] ?? attached
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const headerDetour = (
|
|
828
|
+
points: ReadonlyArray<ChartPoint>,
|
|
829
|
+
node: LaidOutChartNode,
|
|
830
|
+
nodes: ReadonlyArray<LaidOutChartNode>,
|
|
831
|
+
lanes: Map<string, number>
|
|
832
|
+
): ReadonlyArray<ChartPoint> => {
|
|
833
|
+
const header = nodeHeaderRect(node)
|
|
834
|
+
for (let index = 1; index < points.length; index++) {
|
|
835
|
+
const start = points[index - 1]!
|
|
836
|
+
const end = points[index]!
|
|
837
|
+
if (start.x !== end.x || !segmentCrossesInterior(start, end, header)) continue
|
|
838
|
+
const candidates = (["left", "right"] as const).map((side) => {
|
|
839
|
+
const key = `${node.node.path}:${side}`
|
|
840
|
+
const used = lanes.get(key) ?? 0
|
|
841
|
+
const laneX = side === "left"
|
|
842
|
+
? header.left - 12 - used * 8
|
|
843
|
+
: header.right + 12 + used * 8
|
|
844
|
+
const route = compactPoints([
|
|
845
|
+
...points.slice(0, index),
|
|
846
|
+
{ x: laneX, y: start.y },
|
|
847
|
+
{ x: laneX, y: end.y },
|
|
848
|
+
end,
|
|
849
|
+
...points.slice(index + 1)
|
|
850
|
+
])
|
|
851
|
+
const crossings = nodes.reduce((count, obstacleNode) => {
|
|
852
|
+
if (obstacleNode.node.path === node.node.path) return count
|
|
853
|
+
const obstacle = obstacleNode.node.children.length > 0
|
|
854
|
+
? nodeHeaderRect(obstacleNode)
|
|
855
|
+
: nodeRect(obstacleNode)
|
|
856
|
+
return count +
|
|
857
|
+
route.slice(1).filter((point, segmentIndex) => segmentCrossesInterior(route[segmentIndex]!, point, obstacle))
|
|
858
|
+
.length
|
|
859
|
+
}, 0)
|
|
860
|
+
return {
|
|
861
|
+
key,
|
|
862
|
+
route,
|
|
863
|
+
score: crossings * 1_000_000 + used * 10_000 + chartRouteLength(route)
|
|
864
|
+
}
|
|
691
865
|
})
|
|
866
|
+
const selected = candidates.sort((left, right) => left.score - right.score)[0]!
|
|
867
|
+
lanes.set(selected.key, (lanes.get(selected.key) ?? 0) + 1)
|
|
868
|
+
return selected.route
|
|
692
869
|
}
|
|
693
|
-
return
|
|
694
|
-
const points = pointsByEdgeId.get(edge.edge.id)
|
|
695
|
-
return points === undefined ? edge : { ...edge, points }
|
|
696
|
-
})
|
|
870
|
+
return points
|
|
697
871
|
}
|
|
698
872
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
873
|
+
const avoidCompoundHeaders = (
|
|
874
|
+
edge: ChartEdge,
|
|
875
|
+
points: ReadonlyArray<ChartPoint>,
|
|
876
|
+
nodes: ReadonlyArray<LaidOutChartNode>,
|
|
877
|
+
lanes: Map<string, number>
|
|
878
|
+
): ReadonlyArray<ChartPoint> => {
|
|
879
|
+
let routed = points
|
|
880
|
+
for (const node of nodes) {
|
|
881
|
+
if (node.node.children.length === 0 || !transitionTouchesNode(edge, node.node.path)) continue
|
|
882
|
+
if (!routed.slice(1).some((point, index) => segmentCrossesInterior(routed[index]!, point, nodeHeaderRect(node)))) {
|
|
883
|
+
continue
|
|
884
|
+
}
|
|
885
|
+
routed = headerDetour(routed, node, nodes, lanes)
|
|
886
|
+
}
|
|
887
|
+
return routed
|
|
704
888
|
}
|
|
705
889
|
|
|
706
|
-
const
|
|
707
|
-
|
|
890
|
+
const routeLabelCandidates = (
|
|
891
|
+
edge: ChartEdge,
|
|
892
|
+
points: ReadonlyArray<ChartPoint>,
|
|
893
|
+
fallback: ChartPoint,
|
|
708
894
|
width: number,
|
|
709
895
|
height: number
|
|
710
|
-
):
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
896
|
+
): ReadonlyArray<ChartPoint> => {
|
|
897
|
+
if (isSelfTransition(edge)) return [fallback]
|
|
898
|
+
const candidates = points.slice(1).flatMap((end, index) => {
|
|
899
|
+
const start = points[index]!
|
|
900
|
+
const horizontal = start.y === end.y
|
|
901
|
+
const length = Math.abs(end.x - start.x) + Math.abs(end.y - start.y)
|
|
902
|
+
const required = (horizontal ? width : height) + 24
|
|
903
|
+
return length < required ? [] : [{ start, end, horizontal, length }]
|
|
904
|
+
})
|
|
905
|
+
const ordered = [
|
|
906
|
+
...candidates.filter(({ horizontal }) => !horizontal).sort((left, right) => right.length - left.length),
|
|
907
|
+
...candidates.filter(({ horizontal }) => horizontal).sort((left, right) => right.length - left.length)
|
|
908
|
+
].flatMap(({ start, end, horizontal, length }) => {
|
|
909
|
+
const clearance = (horizontal ? width : height) / 2 + 12
|
|
910
|
+
return [0.5, 2 / 3, 1 / 3].flatMap((ratio) => {
|
|
911
|
+
const distance = length * ratio
|
|
912
|
+
if (distance < clearance || length - distance < clearance) return []
|
|
913
|
+
return [{
|
|
914
|
+
x: start.x + (end.x - start.x) * ratio,
|
|
915
|
+
y: start.y + (end.y - start.y) * ratio
|
|
916
|
+
}]
|
|
917
|
+
})
|
|
918
|
+
})
|
|
919
|
+
return [...ordered, fallback].filter((candidate, index, all) =>
|
|
920
|
+
all.findIndex((other) => other.x === candidate.x && other.y === candidate.y) === index
|
|
921
|
+
)
|
|
922
|
+
}
|
|
716
923
|
|
|
717
|
-
const
|
|
718
|
-
|
|
719
|
-
|
|
924
|
+
const placeTransitionLabels = (
|
|
925
|
+
transitions: ReadonlyArray<LaidOutChartTransition>,
|
|
926
|
+
nodes: ReadonlyArray<LaidOutChartNode>
|
|
927
|
+
): ReadonlyArray<LaidOutChartTransition> => {
|
|
928
|
+
const placed: Array<LaidOutChartTransition> = []
|
|
929
|
+
for (const transition of transitions) {
|
|
930
|
+
const candidates = routeLabelCandidates(
|
|
931
|
+
transition.edge,
|
|
932
|
+
transition.points,
|
|
933
|
+
transition.label,
|
|
934
|
+
transition.labelWidth,
|
|
935
|
+
transition.labelHeight
|
|
936
|
+
)
|
|
937
|
+
const position = candidates.find((candidate) => {
|
|
938
|
+
const candidateRect = labelRect(candidate, transition.labelWidth, transition.labelHeight)
|
|
939
|
+
if (
|
|
940
|
+
nodes.some((node) => {
|
|
941
|
+
const touchesCompound = node.node.children.length > 0 && transitionTouchesNode(
|
|
942
|
+
transition.edge,
|
|
943
|
+
node.node.path
|
|
944
|
+
)
|
|
945
|
+
return overlaps(candidateRect, touchesCompound ? nodeHeaderRect(node) : nodeRect(node), 2)
|
|
946
|
+
})
|
|
947
|
+
) return false
|
|
948
|
+
if (
|
|
949
|
+
placed.some((other) =>
|
|
950
|
+
overlaps(
|
|
951
|
+
candidateRect,
|
|
952
|
+
labelRect(other.label, other.labelWidth, other.labelHeight),
|
|
953
|
+
2
|
|
954
|
+
)
|
|
955
|
+
)
|
|
956
|
+
) return false
|
|
957
|
+
if (
|
|
958
|
+
transitions.some((other) =>
|
|
959
|
+
other.edge.id !== transition.edge.id && overlaps(
|
|
960
|
+
candidateRect,
|
|
961
|
+
labelRect(other.label, other.labelWidth, other.labelHeight),
|
|
962
|
+
2
|
|
963
|
+
)
|
|
964
|
+
)
|
|
965
|
+
) return false
|
|
966
|
+
return transitions.every((other) =>
|
|
967
|
+
other.edge.id === transition.edge.id ||
|
|
968
|
+
!other.points.slice(1).some((point, index) =>
|
|
969
|
+
segmentCrossesInterior(other.points[index]!, point, candidateRect)
|
|
970
|
+
)
|
|
971
|
+
)
|
|
972
|
+
}) ?? transition.label
|
|
973
|
+
placed.push({ ...transition, label: position })
|
|
974
|
+
}
|
|
975
|
+
return placed
|
|
976
|
+
}
|
|
720
977
|
|
|
721
978
|
const collectLayout = (
|
|
722
979
|
model: ChartModel,
|
|
@@ -724,7 +981,6 @@ const collectLayout = (
|
|
|
724
981
|
unconnected: ReadonlyArray<UnconnectedRegion>
|
|
725
982
|
): LaidOutChart => {
|
|
726
983
|
const chartNodes = new Map(model.nodes.map((node) => [node.path, node]))
|
|
727
|
-
const chartInitials = new Map(model.initials.map((initial) => [initialNodeId(initial), initial]))
|
|
728
984
|
const chartRuntimeTargets = new Map(model.runtimeTargets.map((target) => [runtimeNodeId(target), target]))
|
|
729
985
|
const chartRegions = new Map(unconnected.map((region) => [region.id, region]))
|
|
730
986
|
const offsets = new Map<string, ChartPoint>([[graph.id, { x: 0, y: 0 }]])
|
|
@@ -732,6 +988,7 @@ const collectLayout = (
|
|
|
732
988
|
const nodes: Array<LaidOutChartNode> = []
|
|
733
989
|
const initials: Array<LaidOutChartInitial> = []
|
|
734
990
|
const runtimeTargets: Array<LaidOutChartRuntimeTarget> = []
|
|
991
|
+
const selfLoops = selfLoopsBySource(model.edges)
|
|
735
992
|
|
|
736
993
|
const visit = (node: ElkNode, parentOffset: ChartPoint): void => {
|
|
737
994
|
const absolute = add(parentOffset, { x: node.x ?? 0, y: node.y ?? 0 })
|
|
@@ -750,7 +1007,7 @@ const collectLayout = (
|
|
|
750
1007
|
}
|
|
751
1008
|
const chartNode = chartNodes.get(node.id)
|
|
752
1009
|
if (chartNode !== undefined) {
|
|
753
|
-
const metric = nodeMetric(chartNode)
|
|
1010
|
+
const metric = nodeMetric(chartNode, selfLoops.get(chartNode.path) ?? 0)
|
|
754
1011
|
nodes.push({
|
|
755
1012
|
node: chartNode,
|
|
756
1013
|
x: absolute.x,
|
|
@@ -760,16 +1017,6 @@ const collectLayout = (
|
|
|
760
1017
|
headerHeight: metric.headerHeight
|
|
761
1018
|
})
|
|
762
1019
|
}
|
|
763
|
-
const initial = chartInitials.get(node.id)
|
|
764
|
-
if (initial !== undefined) {
|
|
765
|
-
initials.push({
|
|
766
|
-
initial,
|
|
767
|
-
x: absolute.x,
|
|
768
|
-
y: absolute.y,
|
|
769
|
-
width: node.width ?? 14,
|
|
770
|
-
height: node.height ?? 14
|
|
771
|
-
})
|
|
772
|
-
}
|
|
773
1020
|
const runtimeTarget = chartRuntimeTargets.get(node.id)
|
|
774
1021
|
if (runtimeTarget !== undefined) {
|
|
775
1022
|
runtimeTargets.push({
|
|
@@ -784,73 +1031,90 @@ const collectLayout = (
|
|
|
784
1031
|
}
|
|
785
1032
|
for (const child of graph.children ?? []) visit(child, { x: 0, y: 0 })
|
|
786
1033
|
|
|
1034
|
+
const nodesByPath = new Map(nodes.map((node) => [node.node.path, node]))
|
|
1035
|
+
for (const initial of model.initials) {
|
|
1036
|
+
const target = nodesByPath.get(initial.target)
|
|
1037
|
+
if (target === undefined) continue
|
|
1038
|
+
initials.push({
|
|
1039
|
+
initial,
|
|
1040
|
+
x: target.x + 9,
|
|
1041
|
+
y: target.y - 17,
|
|
1042
|
+
width: 7,
|
|
1043
|
+
height: 7
|
|
1044
|
+
})
|
|
1045
|
+
}
|
|
1046
|
+
|
|
787
1047
|
const chartEdges = new Map(model.edges.map((edge) => [edge.id, edge]))
|
|
788
|
-
const
|
|
789
|
-
const
|
|
790
|
-
|
|
1048
|
+
const hierarchyLanes = new Map<string, number>()
|
|
1049
|
+
const directLanes = new Map<string, number>()
|
|
1050
|
+
const rawTransitionEdges = (graph.edges ?? []).flatMap(
|
|
1051
|
+
(edge): ReadonlyArray<LaidOutChartTransition> => {
|
|
791
1052
|
const offset = offsets.get(edge.container ?? graph.id) ?? { x: 0, y: 0 }
|
|
792
|
-
const
|
|
793
|
-
if (
|
|
1053
|
+
const elkPoints = edgePoints(edge, offset)
|
|
1054
|
+
if (elkPoints === undefined) return []
|
|
794
1055
|
const chartEdge = chartEdges.get(edge.id)
|
|
795
|
-
if (chartEdge
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
}
|
|
818
|
-
)
|
|
819
|
-
const nodesByPath = new Map(nodes.map((node) => [node.node.path, node]))
|
|
820
|
-
const elkTransitions = separateTerminalApproaches(
|
|
821
|
-
elkEdges.filter((edge): edge is LaidOutChartTransition => edge.kind === "transition")
|
|
822
|
-
)
|
|
823
|
-
const occupiedLabels = elkTransitions.map((edge) => labelRect(edge.label, edge.labelWidth, edge.labelHeight))
|
|
824
|
-
const selfLoopLanes = new Map<string, number>()
|
|
825
|
-
const selfEdges = model.edges.flatMap((edge): ReadonlyArray<LaidOutChartTransition> => {
|
|
826
|
-
if (!isSelfTransition(edge)) return []
|
|
827
|
-
const node = nodesByPath.get(edge.source)
|
|
828
|
-
if (node === undefined) return []
|
|
829
|
-
let lane = selfLoopLanes.get(edge.source) ?? 0
|
|
830
|
-
let selfTransition = laidOutSelfTransition(edge, node, lane)
|
|
831
|
-
while (
|
|
832
|
-
occupiedLabels.some((occupied) =>
|
|
833
|
-
overlaps(
|
|
834
|
-
labelRect(selfTransition.label, selfTransition.labelWidth, selfTransition.labelHeight),
|
|
835
|
-
occupied,
|
|
836
|
-
chartLabelCollisionGap
|
|
837
|
-
)
|
|
1056
|
+
if (chartEdge === undefined) return []
|
|
1057
|
+
const points = normalizeTerminalDirection(
|
|
1058
|
+
chartEdge,
|
|
1059
|
+
normalizeSourceDirection(
|
|
1060
|
+
chartEdge,
|
|
1061
|
+
avoidCompoundHeaders(
|
|
1062
|
+
chartEdge,
|
|
1063
|
+
shortenTransitionRoute(
|
|
1064
|
+
chartEdge,
|
|
1065
|
+
normalizeHierarchyRoute(chartEdge, elkPoints, nodesByPath),
|
|
1066
|
+
nodesByPath,
|
|
1067
|
+
nodes,
|
|
1068
|
+
directLanes
|
|
1069
|
+
),
|
|
1070
|
+
nodes,
|
|
1071
|
+
hierarchyLanes
|
|
1072
|
+
),
|
|
1073
|
+
nodesByPath,
|
|
1074
|
+
nodes
|
|
1075
|
+
),
|
|
1076
|
+
nodesByPath,
|
|
1077
|
+
nodes
|
|
838
1078
|
)
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
1079
|
+
const metric = labelMetric(chartEdge.label)
|
|
1080
|
+
const label = edge.labels?.[0]
|
|
1081
|
+
const labelWidth = label?.width ?? metric.width
|
|
1082
|
+
const labelHeight = label?.height ?? metric.height
|
|
1083
|
+
const elkLabel = label?.x === undefined || label.y === undefined
|
|
1084
|
+
? midpoint(points)
|
|
1085
|
+
: add(offset, {
|
|
1086
|
+
x: label.x + labelWidth / 2,
|
|
1087
|
+
y: label.y + labelHeight / 2
|
|
1088
|
+
})
|
|
1089
|
+
const routeChanged = points.length !== elkPoints.length ||
|
|
1090
|
+
points.slice(1, -1).some((point, index) =>
|
|
1091
|
+
point.x !== elkPoints[index + 1]?.x || point.y !== elkPoints[index + 1]?.y
|
|
1092
|
+
)
|
|
1093
|
+
return [{
|
|
1094
|
+
kind: "transition",
|
|
1095
|
+
edge: chartEdge,
|
|
1096
|
+
points,
|
|
1097
|
+
label: !isSelfTransition(chartEdge) && routeChanged ? midpoint(points) : elkLabel,
|
|
1098
|
+
labelWidth,
|
|
1099
|
+
labelHeight
|
|
1100
|
+
}]
|
|
842
1101
|
}
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
1102
|
+
)
|
|
1103
|
+
const initialEdges = initials.flatMap(({ initial, x, y, width, height }): ReadonlyArray<LaidOutChartInitialEdge> => {
|
|
1104
|
+
const target = nodesByPath.get(initial.target)
|
|
1105
|
+
if (target === undefined) return []
|
|
1106
|
+
const start = { x: x + width / 2, y: y + height / 2 }
|
|
1107
|
+
return [{
|
|
1108
|
+
kind: "initial",
|
|
1109
|
+
initial,
|
|
1110
|
+
points: compactPoints([start, { x: start.x, y: target.y }])
|
|
1111
|
+
}]
|
|
846
1112
|
})
|
|
1113
|
+
const transitionEdges = placeTransitionLabels(rawTransitionEdges, nodes)
|
|
847
1114
|
const edges: ReadonlyArray<LaidOutChartTransition | LaidOutChartInitialEdge> = [
|
|
848
|
-
...
|
|
849
|
-
...
|
|
850
|
-
...selfEdges
|
|
1115
|
+
...transitionEdges,
|
|
1116
|
+
...initialEdges
|
|
851
1117
|
]
|
|
852
|
-
|
|
853
|
-
const transitionEdges = edges.filter((edge) => edge.kind === "transition")
|
|
854
1118
|
const contentWidth = Math.max(
|
|
855
1119
|
0,
|
|
856
1120
|
...nodes.map(({ width, x }) => x + width),
|
|
@@ -879,39 +1143,396 @@ const collectLayout = (
|
|
|
879
1143
|
}
|
|
880
1144
|
}
|
|
881
1145
|
|
|
1146
|
+
const labelRect = (point: ChartPoint, width: number, height: number): ChartRect => ({
|
|
1147
|
+
left: point.x - width / 2,
|
|
1148
|
+
right: point.x + width / 2,
|
|
1149
|
+
top: point.y - height / 2,
|
|
1150
|
+
bottom: point.y + height / 2
|
|
1151
|
+
})
|
|
1152
|
+
|
|
1153
|
+
const nodeRect = (node: LaidOutChartNode): ChartRect => ({
|
|
1154
|
+
left: node.x,
|
|
1155
|
+
right: node.x + node.width,
|
|
1156
|
+
top: node.y,
|
|
1157
|
+
bottom: node.y + node.height
|
|
1158
|
+
})
|
|
1159
|
+
|
|
1160
|
+
const nodeHeaderRect = (node: LaidOutChartNode): ChartRect => ({
|
|
1161
|
+
left: node.x,
|
|
1162
|
+
right: node.x + node.width,
|
|
1163
|
+
top: node.y,
|
|
1164
|
+
bottom: node.y + node.headerHeight
|
|
1165
|
+
})
|
|
1166
|
+
|
|
1167
|
+
const pointRectDistance = (point: ChartPoint, rect: ChartRect): number =>
|
|
1168
|
+
Math.hypot(
|
|
1169
|
+
Math.max(rect.left - point.x, 0, point.x - rect.right),
|
|
1170
|
+
Math.max(rect.top - point.y, 0, point.y - rect.bottom)
|
|
1171
|
+
)
|
|
1172
|
+
|
|
1173
|
+
const overlaps = (left: ChartRect, right: ChartRect, gap = 0): boolean =>
|
|
1174
|
+
left.left < right.right + gap && left.right > right.left - gap &&
|
|
1175
|
+
left.top < right.bottom + gap && left.bottom > right.top - gap
|
|
1176
|
+
|
|
1177
|
+
const segmentCrossesInterior = (start: ChartPoint, end: ChartPoint, rect: ChartRect): boolean => {
|
|
1178
|
+
const epsilon = 0.001
|
|
1179
|
+
const left = rect.left + epsilon
|
|
1180
|
+
const right = rect.right - epsilon
|
|
1181
|
+
const top = rect.top + epsilon
|
|
1182
|
+
const bottom = rect.bottom - epsilon
|
|
1183
|
+
const deltaX = end.x - start.x
|
|
1184
|
+
const deltaY = end.y - start.y
|
|
1185
|
+
let minimum = 0
|
|
1186
|
+
let maximum = 1
|
|
1187
|
+
const clip = (direction: number, origin: number, low: number, high: number): boolean => {
|
|
1188
|
+
if (direction === 0) return origin >= low && origin <= high
|
|
1189
|
+
const first = (low - origin) / direction
|
|
1190
|
+
const second = (high - origin) / direction
|
|
1191
|
+
minimum = Math.max(minimum, Math.min(first, second))
|
|
1192
|
+
maximum = Math.min(maximum, Math.max(first, second))
|
|
1193
|
+
return minimum <= maximum
|
|
1194
|
+
}
|
|
1195
|
+
return clip(deltaX, start.x, left, right) && clip(deltaY, start.y, top, bottom) &&
|
|
1196
|
+
maximum > 0 && minimum < 1
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
const pointSegmentDistance = (point: ChartPoint, start: ChartPoint, end: ChartPoint): number => {
|
|
1200
|
+
const deltaX = end.x - start.x
|
|
1201
|
+
const deltaY = end.y - start.y
|
|
1202
|
+
const squaredLength = deltaX * deltaX + deltaY * deltaY
|
|
1203
|
+
if (squaredLength === 0) return Math.hypot(point.x - start.x, point.y - start.y)
|
|
1204
|
+
const ratio = Math.min(
|
|
1205
|
+
1,
|
|
1206
|
+
Math.max(0, ((point.x - start.x) * deltaX + (point.y - start.y) * deltaY) / squaredLength)
|
|
1207
|
+
)
|
|
1208
|
+
return Math.hypot(point.x - (start.x + ratio * deltaX), point.y - (start.y + ratio * deltaY))
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
const labelDistance = (transition: LaidOutChartTransition): number => {
|
|
1212
|
+
let distance = Number.POSITIVE_INFINITY
|
|
1213
|
+
for (let index = 1; index < transition.points.length; index++) {
|
|
1214
|
+
distance = Math.min(
|
|
1215
|
+
distance,
|
|
1216
|
+
pointSegmentDistance(transition.label, transition.points[index - 1]!, transition.points[index]!)
|
|
1217
|
+
)
|
|
1218
|
+
}
|
|
1219
|
+
return distance
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
export const chartRouteLength = (points: ReadonlyArray<ChartPoint>): number =>
|
|
1223
|
+
points.slice(1).reduce((total, point, index) => {
|
|
1224
|
+
const previous = points[index]!
|
|
1225
|
+
return total + Math.abs(point.x - previous.x) + Math.abs(point.y - previous.y)
|
|
1226
|
+
}, 0)
|
|
1227
|
+
|
|
1228
|
+
interface OrthogonalSegment {
|
|
1229
|
+
readonly edgeId: string
|
|
1230
|
+
readonly start: ChartPoint
|
|
1231
|
+
readonly end: ChartPoint
|
|
1232
|
+
readonly horizontal: boolean
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
const segments = (layout: LaidOutChart): ReadonlyArray<OrthogonalSegment> =>
|
|
1236
|
+
layout.edges.flatMap((edge): ReadonlyArray<OrthogonalSegment> => {
|
|
1237
|
+
const edgeId = edge.kind === "transition" ? edge.edge.id : edge.initial.id
|
|
1238
|
+
return edge.points.slice(1).map((end, index) => ({
|
|
1239
|
+
edgeId,
|
|
1240
|
+
start: edge.points[index]!,
|
|
1241
|
+
end,
|
|
1242
|
+
horizontal: edge.points[index]!.y === end.y
|
|
1243
|
+
}))
|
|
1244
|
+
})
|
|
1245
|
+
|
|
1246
|
+
const crossingCount = (allSegments: ReadonlyArray<OrthogonalSegment>): number => {
|
|
1247
|
+
let crossings = 0
|
|
1248
|
+
for (let leftIndex = 0; leftIndex < allSegments.length; leftIndex++) {
|
|
1249
|
+
const left = allSegments[leftIndex]!
|
|
1250
|
+
for (let rightIndex = leftIndex + 1; rightIndex < allSegments.length; rightIndex++) {
|
|
1251
|
+
const right = allSegments[rightIndex]!
|
|
1252
|
+
if (left.edgeId === right.edgeId || left.horizontal === right.horizontal) continue
|
|
1253
|
+
const horizontal = left.horizontal ? left : right
|
|
1254
|
+
const vertical = left.horizontal ? right : left
|
|
1255
|
+
const horizontalLeft = Math.min(horizontal.start.x, horizontal.end.x)
|
|
1256
|
+
const horizontalRight = Math.max(horizontal.start.x, horizontal.end.x)
|
|
1257
|
+
const verticalTop = Math.min(vertical.start.y, vertical.end.y)
|
|
1258
|
+
const verticalBottom = Math.max(vertical.start.y, vertical.end.y)
|
|
1259
|
+
if (
|
|
1260
|
+
vertical.start.x > horizontalLeft && vertical.start.x < horizontalRight &&
|
|
1261
|
+
horizontal.start.y > verticalTop && horizontal.start.y < verticalBottom
|
|
1262
|
+
) crossings++
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
return crossings
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
const collinearOverlap = (left: OrthogonalSegment, right: OrthogonalSegment): number => {
|
|
1269
|
+
if (left.horizontal !== right.horizontal) return 0
|
|
1270
|
+
if (left.horizontal) {
|
|
1271
|
+
if (left.start.y !== right.start.y) return 0
|
|
1272
|
+
return Math.max(
|
|
1273
|
+
0,
|
|
1274
|
+
Math.min(Math.max(left.start.x, left.end.x), Math.max(right.start.x, right.end.x)) -
|
|
1275
|
+
Math.max(Math.min(left.start.x, left.end.x), Math.min(right.start.x, right.end.x))
|
|
1276
|
+
)
|
|
1277
|
+
}
|
|
1278
|
+
if (left.start.x !== right.start.x) return 0
|
|
1279
|
+
return Math.max(
|
|
1280
|
+
0,
|
|
1281
|
+
Math.min(Math.max(left.start.y, left.end.y), Math.max(right.start.y, right.end.y)) -
|
|
1282
|
+
Math.max(Math.min(left.start.y, left.end.y), Math.min(right.start.y, right.end.y))
|
|
1283
|
+
)
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
const transitionTouchesNode = (edge: ChartEdge, path: string): boolean => {
|
|
1287
|
+
const target = edgeTargetPath(edge)
|
|
1288
|
+
return edge.source === path || isDescendantPath(edge.source, path) ||
|
|
1289
|
+
target === path || target !== null && isDescendantPath(target, path)
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
const laidOutEdgeId = (edge: LaidOutChartTransition | LaidOutChartInitialEdge): string =>
|
|
1293
|
+
edge.kind === "transition" ? edge.edge.id : edge.initial.id
|
|
1294
|
+
|
|
1295
|
+
const validationScore = (validation: ChartLayoutValidation): number =>
|
|
1296
|
+
validation.issues.length * 1_000_000 + validation.crossings * 10_000 + validation.routeLength
|
|
1297
|
+
|
|
1298
|
+
export const validateChartLayout = (
|
|
1299
|
+
model: ChartModel,
|
|
1300
|
+
layout: LaidOutChart
|
|
1301
|
+
): ChartLayoutValidation => {
|
|
1302
|
+
const issues: Array<ChartLayoutIssue> = []
|
|
1303
|
+
const report = (code: ChartLayoutIssueCode, edgeId: string, relatedId: string | null = null): void => {
|
|
1304
|
+
if (issues.some((issue) => issue.code === code && issue.edgeId === edgeId && issue.relatedId === relatedId)) return
|
|
1305
|
+
issues.push({ code, edgeId, relatedId })
|
|
1306
|
+
}
|
|
1307
|
+
const transitions = layout.edges.filter(
|
|
1308
|
+
(edge): edge is LaidOutChartTransition => edge.kind === "transition"
|
|
1309
|
+
)
|
|
1310
|
+
const transitionById = new Map(transitions.map((edge) => [edge.edge.id, edge]))
|
|
1311
|
+
for (const edge of model.edges) {
|
|
1312
|
+
if (!transitionById.has(edge.id)) report("missing-edge", edge.id)
|
|
1313
|
+
}
|
|
1314
|
+
const initialEdges = layout.edges.filter(
|
|
1315
|
+
(edge): edge is LaidOutChartInitialEdge => edge.kind === "initial"
|
|
1316
|
+
)
|
|
1317
|
+
const initialById = new Map(initialEdges.map((edge) => [edge.initial.id, edge]))
|
|
1318
|
+
for (const initial of model.initials) {
|
|
1319
|
+
if (!initialById.has(initial.id)) report("missing-edge", initial.id)
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
for (const transition of transitions) {
|
|
1323
|
+
const transitionLabelDistance = labelDistance(transition)
|
|
1324
|
+
if (transitionLabelDistance > Math.max(transition.labelWidth, transition.labelHeight) / 2 + 12) {
|
|
1325
|
+
report("label-detached", transition.edge.id, `${Math.round(transitionLabelDistance)}px`)
|
|
1326
|
+
}
|
|
1327
|
+
const start = transition.points[0]
|
|
1328
|
+
const next = transition.points[1]
|
|
1329
|
+
if (start !== undefined && next !== undefined && !isSelfTransition(transition.edge)) {
|
|
1330
|
+
const source = layout.nodes.find(({ node }) => node.path === transition.edge.source)
|
|
1331
|
+
if (source !== undefined && source.node.children.length === 0) {
|
|
1332
|
+
if (Math.abs(start.x - next.x) + Math.abs(start.y - next.y) < 9) {
|
|
1333
|
+
report("short-source", transition.edge.id)
|
|
1334
|
+
}
|
|
1335
|
+
if (nodeBoundaryDistance(start, source) > 0.5) {
|
|
1336
|
+
report("detached-source", transition.edge.id, transition.edge.source)
|
|
1337
|
+
} else if (!isOutwardStep(start, next, endpointSide(start, source))) {
|
|
1338
|
+
report("wrong-source-direction", transition.edge.id, transition.edge.source)
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
const end = transition.points.at(-1)
|
|
1344
|
+
const bend = transition.points.at(-2)
|
|
1345
|
+
if (
|
|
1346
|
+
end === undefined || bend === undefined ||
|
|
1347
|
+
Math.abs(end.x - bend.x) + Math.abs(end.y - bend.y) < 9
|
|
1348
|
+
) report("short-terminal", transition.edge.id)
|
|
1349
|
+
if (end !== undefined && transition.edge.target !== null && !isSelfTransition(transition.edge)) {
|
|
1350
|
+
const target = layout.nodes.find(({ node }) => node.path === transition.edge.target)
|
|
1351
|
+
if (target !== undefined && nodeBoundaryDistance(end, target) > 0.5) {
|
|
1352
|
+
report("detached-terminal", transition.edge.id, transition.edge.target)
|
|
1353
|
+
} else if (target !== undefined && bend !== undefined && !isOutwardStep(end, bend, endpointSide(end, target))) {
|
|
1354
|
+
report("wrong-terminal-direction", transition.edge.id, transition.edge.target)
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
const label = labelRect(transition.label, transition.labelWidth, transition.labelHeight)
|
|
1359
|
+
for (const node of layout.nodes) {
|
|
1360
|
+
const touchesCompound = node.node.children.length > 0 && transitionTouchesNode(
|
|
1361
|
+
transition.edge,
|
|
1362
|
+
node.node.path
|
|
1363
|
+
)
|
|
1364
|
+
const obstacle = touchesCompound
|
|
1365
|
+
? nodeHeaderRect(node)
|
|
1366
|
+
: nodeRect(node)
|
|
1367
|
+
if (overlaps(label, obstacle, 2)) report("label-node-overlap", transition.edge.id, node.node.path)
|
|
1368
|
+
if (
|
|
1369
|
+
transition.points.slice(1).some((point, index) =>
|
|
1370
|
+
segmentCrossesInterior(transition.points[index]!, point, obstacle)
|
|
1371
|
+
)
|
|
1372
|
+
) report("node-crossing", transition.edge.id, node.node.path)
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
if (isSelfTransition(transition.edge)) {
|
|
1376
|
+
const source = model.nodes.find((node) => node.path === transition.edge.source)
|
|
1377
|
+
const sourceLayout = layout.nodes.find((node) => node.node.path === transition.edge.source)
|
|
1378
|
+
if (
|
|
1379
|
+
sourceLayout !== undefined &&
|
|
1380
|
+
Math.max(...transition.points.map((point) => pointRectDistance(point, nodeRect(sourceLayout)))) <
|
|
1381
|
+
chartSelfLoopMinimumClearance
|
|
1382
|
+
) report("self-loop-clearance", transition.edge.id, sourceLayout.node.path)
|
|
1383
|
+
const parent = source?.parent === null
|
|
1384
|
+
? undefined
|
|
1385
|
+
: layout.nodes.find((node) => node.node.path === source?.parent)
|
|
1386
|
+
if (parent !== undefined) {
|
|
1387
|
+
const content = {
|
|
1388
|
+
left: parent.x,
|
|
1389
|
+
right: parent.x + parent.width,
|
|
1390
|
+
top: parent.y + parent.headerHeight,
|
|
1391
|
+
bottom: parent.y + parent.height
|
|
1392
|
+
}
|
|
1393
|
+
const outside = transition.points.some((point) =>
|
|
1394
|
+
point.x < content.left || point.x > content.right ||
|
|
1395
|
+
point.y < content.top || point.y > content.bottom
|
|
1396
|
+
) || label.left < content.left || label.right > content.right ||
|
|
1397
|
+
label.top < content.top || label.bottom > content.bottom
|
|
1398
|
+
if (outside) report("self-loop-outside-parent", transition.edge.id, parent.node.path)
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
for (const initial of initialEdges) {
|
|
1404
|
+
for (const node of layout.nodes) {
|
|
1405
|
+
const containsTarget = node.node.path === initial.initial.target ||
|
|
1406
|
+
isDescendantPath(initial.initial.target, node.node.path)
|
|
1407
|
+
const obstacle = node.node.children.length > 0 && containsTarget
|
|
1408
|
+
? nodeHeaderRect(node)
|
|
1409
|
+
: nodeRect(node)
|
|
1410
|
+
if (
|
|
1411
|
+
initial.points.slice(1).some((point, index) => segmentCrossesInterior(initial.points[index]!, point, obstacle))
|
|
1412
|
+
) report("node-crossing", initial.initial.id, node.node.path)
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
for (let left = 0; left < transitions.length; left++) {
|
|
1417
|
+
const leftEdge = transitions[left]!
|
|
1418
|
+
const leftRect = labelRect(leftEdge.label, leftEdge.labelWidth, leftEdge.labelHeight)
|
|
1419
|
+
for (let right = left + 1; right < transitions.length; right++) {
|
|
1420
|
+
const rightEdge = transitions[right]!
|
|
1421
|
+
const rightRect = labelRect(rightEdge.label, rightEdge.labelWidth, rightEdge.labelHeight)
|
|
1422
|
+
if (overlaps(leftRect, rightRect, 2)) {
|
|
1423
|
+
report("label-label-overlap", leftEdge.edge.id, rightEdge.edge.id)
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
for (const other of layout.edges) {
|
|
1427
|
+
if (laidOutEdgeId(other) === leftEdge.edge.id) continue
|
|
1428
|
+
if (
|
|
1429
|
+
other.points.slice(1).some((point, index) => segmentCrossesInterior(other.points[index]!, point, leftRect))
|
|
1430
|
+
) report("label-route-overlap", leftEdge.edge.id, laidOutEdgeId(other))
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
const allSegments = segments(layout)
|
|
1435
|
+
for (let left = 0; left < allSegments.length; left++) {
|
|
1436
|
+
for (let right = left + 1; right < allSegments.length; right++) {
|
|
1437
|
+
const first = allSegments[left]!
|
|
1438
|
+
const second = allSegments[right]!
|
|
1439
|
+
if (first.edgeId === second.edgeId) continue
|
|
1440
|
+
if (collinearOverlap(first, second) > 4) report("route-overlap", first.edgeId, second.edgeId)
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
return {
|
|
1445
|
+
valid: issues.length === 0,
|
|
1446
|
+
issues,
|
|
1447
|
+
crossings: crossingCount(allSegments),
|
|
1448
|
+
routeLength: layout.edges.reduce((sum, edge) => sum + chartRouteLength(edge.points), 0)
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
|
|
882
1452
|
type ChartLayoutEngine = (graph: ElkNode, portConstraints: PortConstraints) => Promise<ElkNode>
|
|
1453
|
+
type ChartLayoutValidator = (model: ChartModel, layout: LaidOutChart) => ChartLayoutValidation
|
|
1454
|
+
|
|
1455
|
+
interface LayoutAttemptFailure {
|
|
1456
|
+
readonly profile: string
|
|
1457
|
+
readonly cause: unknown
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
interface InvalidLayoutCandidate {
|
|
1461
|
+
readonly profile: string
|
|
1462
|
+
readonly layout: LaidOutChart
|
|
1463
|
+
readonly validation: ChartLayoutValidation
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
const isWarningOnlyLayout = ({ issues }: ChartLayoutValidation): boolean =>
|
|
1467
|
+
issues.length > 0 && issues.every(({ code }) => code === "label-route-overlap")
|
|
1468
|
+
|
|
1469
|
+
const bestLayoutCandidate = (
|
|
1470
|
+
candidates: ReadonlyArray<InvalidLayoutCandidate>
|
|
1471
|
+
): InvalidLayoutCandidate | undefined =>
|
|
1472
|
+
[...candidates].sort((left, right) => validationScore(left.validation) - validationScore(right.validation))[0]
|
|
883
1473
|
|
|
884
1474
|
const causeMessage = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause)
|
|
885
1475
|
|
|
1476
|
+
const issueSummary = (validation: ChartLayoutValidation): string => {
|
|
1477
|
+
const counts = new Map<ChartLayoutIssueCode, number>()
|
|
1478
|
+
for (const issue of validation.issues) counts.set(issue.code, (counts.get(issue.code) ?? 0) + 1)
|
|
1479
|
+
const summary = [...counts].map(([code, count]) => `${code} (${count})`).join(", ")
|
|
1480
|
+
const examples = validation.issues.slice(0, 3).map(({ code, edgeId, relatedId }) =>
|
|
1481
|
+
`${code}:${edgeId}${relatedId === null ? "" : `:${relatedId}`}`
|
|
1482
|
+
).join(", ")
|
|
1483
|
+
return examples.length === 0 ? summary : `${summary}; ${examples}`
|
|
1484
|
+
}
|
|
1485
|
+
|
|
886
1486
|
export const layoutChartWith = (
|
|
887
1487
|
model: ChartModel,
|
|
888
|
-
layout: ChartLayoutEngine
|
|
1488
|
+
layout: ChartLayoutEngine,
|
|
1489
|
+
validate: ChartLayoutValidator = validateChartLayout
|
|
889
1490
|
): Effect.Effect<LaidOutChart, ChartLayoutError> =>
|
|
890
1491
|
Effect.suspend(() => {
|
|
891
1492
|
const policy = makeChartLayoutPolicy(model)
|
|
892
1493
|
const regions = unconnectedRegions(model, policy)
|
|
893
|
-
const
|
|
894
|
-
|
|
895
|
-
try: () => layout(makeGraph(model, policy, regions, portConstraints), portConstraints),
|
|
896
|
-
catch: (cause) => cause
|
|
897
|
-
})
|
|
1494
|
+
const failures: Array<LayoutAttemptFailure> = []
|
|
1495
|
+
const invalid: Array<InvalidLayoutCandidate> = []
|
|
898
1496
|
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
1497
|
+
const attempt = (index: number): Effect.Effect<LaidOutChart, ChartLayoutError> => {
|
|
1498
|
+
const profile = layoutProfiles[index]
|
|
1499
|
+
if (profile === undefined) {
|
|
1500
|
+
const fallback = bestLayoutCandidate(invalid.filter(({ validation }) => isWarningOnlyLayout(validation)))
|
|
1501
|
+
if (fallback !== undefined) return Effect.succeed(fallback.layout)
|
|
1502
|
+
const best = bestLayoutCandidate(invalid)
|
|
1503
|
+
const detail = best === undefined
|
|
1504
|
+
? failures.map(({ cause, profile }) => `${profile}: ${causeMessage(cause)}`).join("; ")
|
|
1505
|
+
: `${best.profile}: ${issueSummary(best.validation)}`
|
|
1506
|
+
return Effect.fail(
|
|
1507
|
+
new ChartLayoutError({
|
|
1508
|
+
cause: { failures, invalid },
|
|
1509
|
+
message:
|
|
1510
|
+
`ELK did not produce a safe layout for ${model.machineId} after ${layoutProfiles.length} deterministic attempts: ${detail}`
|
|
1511
|
+
})
|
|
1512
|
+
)
|
|
1513
|
+
}
|
|
1514
|
+
return Effect.matchEffect(
|
|
1515
|
+
Effect.tryPromise({
|
|
1516
|
+
try: () => layout(makeGraph(model, policy, regions, profile), profile.portConstraints),
|
|
1517
|
+
catch: (cause) => cause
|
|
1518
|
+
}),
|
|
1519
|
+
{
|
|
1520
|
+
onFailure: (cause) => {
|
|
1521
|
+
failures.push({ profile: profile.id, cause })
|
|
1522
|
+
return attempt(index + 1)
|
|
1523
|
+
},
|
|
1524
|
+
onSuccess: (graph) => {
|
|
1525
|
+
const candidate = collectLayout(model, graph, regions)
|
|
1526
|
+
const validation = validate(model, candidate)
|
|
1527
|
+
if (validation.valid) return Effect.succeed(candidate)
|
|
1528
|
+
invalid.push({ profile: profile.id, layout: candidate, validation })
|
|
1529
|
+
return attempt(index + 1)
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
)
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
return attempt(0)
|
|
915
1536
|
})
|
|
916
1537
|
|
|
917
1538
|
export const layoutChart = (model: ChartModel): Effect.Effect<LaidOutChart, ChartLayoutError> =>
|