@typeonce/effect-machine-devtools 0.27.1 → 0.29.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 +640 -149
- package/src/internal/browser/chart-model.ts +0 -57
- package/src/internal/browser/chart-renderer.ts +19 -23
- 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 +34 -36
- package/src/internal/browser/visualizer-app.ts +59 -4
- package/dist/client/assets/index-BnBnpx0G.css +0 -1
- package/dist/client/assets/index-DM9jlCJV.js +0 -37
|
@@ -12,9 +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
|
|
17
16
|
export const chartSelfLoopMinimumClearance = 24
|
|
17
|
+
export const chartEdgeLabelSpacing = 5
|
|
18
18
|
|
|
19
19
|
export interface ChartPoint {
|
|
20
20
|
readonly x: number
|
|
@@ -82,6 +82,8 @@ export interface LaidOutChart {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
export type ChartLayoutIssueCode =
|
|
85
|
+
| "detached-source"
|
|
86
|
+
| "detached-terminal"
|
|
85
87
|
| "missing-edge"
|
|
86
88
|
| "label-detached"
|
|
87
89
|
| "label-label-overlap"
|
|
@@ -91,7 +93,10 @@ export type ChartLayoutIssueCode =
|
|
|
91
93
|
| "route-overlap"
|
|
92
94
|
| "self-loop-clearance"
|
|
93
95
|
| "self-loop-outside-parent"
|
|
96
|
+
| "short-source"
|
|
94
97
|
| "short-terminal"
|
|
98
|
+
| "wrong-source-direction"
|
|
99
|
+
| "wrong-terminal-direction"
|
|
95
100
|
|
|
96
101
|
export interface ChartLayoutIssue {
|
|
97
102
|
readonly code: ChartLayoutIssueCode
|
|
@@ -149,82 +154,98 @@ const layoutProfiles: ReadonlyArray<LayoutProfile> = [
|
|
|
149
154
|
{
|
|
150
155
|
id: "compact-fixed",
|
|
151
156
|
portConstraints: "fixed",
|
|
152
|
-
nodeSpacing:
|
|
153
|
-
layerSpacing:
|
|
154
|
-
edgeNodeSpacing:
|
|
155
|
-
edgeEdgeSpacing:
|
|
156
|
-
compoundNodeSpacing:
|
|
157
|
-
compoundLayerSpacing:
|
|
158
|
-
selfLoopSpacing:
|
|
159
|
-
padding:
|
|
157
|
+
nodeSpacing: 48,
|
|
158
|
+
layerSpacing: 72,
|
|
159
|
+
edgeNodeSpacing: 28,
|
|
160
|
+
edgeEdgeSpacing: 18,
|
|
161
|
+
compoundNodeSpacing: 32,
|
|
162
|
+
compoundLayerSpacing: 60,
|
|
163
|
+
selfLoopSpacing: 40,
|
|
164
|
+
padding: 36
|
|
160
165
|
},
|
|
161
166
|
{
|
|
162
167
|
id: "spacious-fixed",
|
|
163
168
|
portConstraints: "fixed",
|
|
164
|
-
nodeSpacing:
|
|
165
|
-
layerSpacing:
|
|
166
|
-
edgeNodeSpacing:
|
|
167
|
-
edgeEdgeSpacing:
|
|
168
|
-
compoundNodeSpacing:
|
|
169
|
-
compoundLayerSpacing:
|
|
170
|
-
selfLoopSpacing:
|
|
171
|
-
padding:
|
|
169
|
+
nodeSpacing: 68,
|
|
170
|
+
layerSpacing: 96,
|
|
171
|
+
edgeNodeSpacing: 42,
|
|
172
|
+
edgeEdgeSpacing: 28,
|
|
173
|
+
compoundNodeSpacing: 48,
|
|
174
|
+
compoundLayerSpacing: 80,
|
|
175
|
+
selfLoopSpacing: 48,
|
|
176
|
+
padding: 48
|
|
172
177
|
},
|
|
173
178
|
{
|
|
174
179
|
id: "roomy-fixed",
|
|
175
180
|
portConstraints: "fixed",
|
|
176
|
-
nodeSpacing:
|
|
177
|
-
layerSpacing:
|
|
178
|
-
edgeNodeSpacing:
|
|
179
|
-
edgeEdgeSpacing:
|
|
180
|
-
compoundNodeSpacing:
|
|
181
|
-
compoundLayerSpacing:
|
|
182
|
-
selfLoopSpacing:
|
|
183
|
-
padding:
|
|
181
|
+
nodeSpacing: 88,
|
|
182
|
+
layerSpacing: 124,
|
|
183
|
+
edgeNodeSpacing: 58,
|
|
184
|
+
edgeEdgeSpacing: 40,
|
|
185
|
+
compoundNodeSpacing: 64,
|
|
186
|
+
compoundLayerSpacing: 104,
|
|
187
|
+
selfLoopSpacing: 56,
|
|
188
|
+
padding: 60
|
|
184
189
|
},
|
|
185
190
|
{
|
|
186
191
|
id: "spacious-relaxed",
|
|
187
192
|
portConstraints: "relaxed",
|
|
188
|
-
nodeSpacing:
|
|
189
|
-
layerSpacing:
|
|
190
|
-
edgeNodeSpacing:
|
|
191
|
-
edgeEdgeSpacing:
|
|
192
|
-
compoundNodeSpacing:
|
|
193
|
-
compoundLayerSpacing:
|
|
194
|
-
selfLoopSpacing:
|
|
195
|
-
padding:
|
|
193
|
+
nodeSpacing: 68,
|
|
194
|
+
layerSpacing: 96,
|
|
195
|
+
edgeNodeSpacing: 42,
|
|
196
|
+
edgeEdgeSpacing: 28,
|
|
197
|
+
compoundNodeSpacing: 48,
|
|
198
|
+
compoundLayerSpacing: 80,
|
|
199
|
+
selfLoopSpacing: 48,
|
|
200
|
+
padding: 48
|
|
196
201
|
},
|
|
197
202
|
{
|
|
198
203
|
id: "roomy-relaxed",
|
|
199
204
|
portConstraints: "relaxed",
|
|
200
|
-
nodeSpacing:
|
|
201
|
-
layerSpacing:
|
|
202
|
-
edgeNodeSpacing:
|
|
203
|
-
edgeEdgeSpacing:
|
|
204
|
-
compoundNodeSpacing:
|
|
205
|
-
compoundLayerSpacing:
|
|
206
|
-
selfLoopSpacing:
|
|
207
|
-
padding:
|
|
205
|
+
nodeSpacing: 88,
|
|
206
|
+
layerSpacing: 124,
|
|
207
|
+
edgeNodeSpacing: 58,
|
|
208
|
+
edgeEdgeSpacing: 40,
|
|
209
|
+
compoundNodeSpacing: 64,
|
|
210
|
+
compoundLayerSpacing: 104,
|
|
211
|
+
selfLoopSpacing: 56,
|
|
212
|
+
padding: 60
|
|
208
213
|
}
|
|
209
214
|
]
|
|
210
215
|
|
|
211
|
-
const
|
|
216
|
+
const activitySectionHeight = (length: number): number => {
|
|
212
217
|
if (length === 0) return 0
|
|
213
|
-
const visible = Math.min(length,
|
|
214
|
-
return
|
|
218
|
+
const visible = Math.min(length, maxVisibleActivities)
|
|
219
|
+
return 16 + visible * 20 + (length > maxVisibleActivities ? 16 : 0)
|
|
215
220
|
}
|
|
216
221
|
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
223
235
|
)
|
|
224
|
-
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))
|
|
225
244
|
return {
|
|
226
245
|
width,
|
|
227
|
-
height: node.children.length === 0
|
|
246
|
+
height: node.children.length === 0
|
|
247
|
+
? Math.max(headerHeight, selfLoopHeight)
|
|
248
|
+
: Math.max(180, headerHeight + 88),
|
|
228
249
|
headerHeight
|
|
229
250
|
}
|
|
230
251
|
}
|
|
@@ -234,14 +255,20 @@ const elk = new ELK()
|
|
|
234
255
|
|
|
235
256
|
const sourcePortId = (edge: ChartEdge): string => `port:${edge.id}:source`
|
|
236
257
|
const targetPortId = (edge: ChartEdge): string => `port:${edge.id}:target`
|
|
237
|
-
const initialNodeId = (initial: ChartInitial): string => `node:${initial.id}`
|
|
238
|
-
const initialTargetPortId = (initial: ChartInitial): string => `port:${initial.id}:target`
|
|
239
258
|
const runtimeNodeId = (target: ChartRuntimeTarget): string => `node:${target.id}`
|
|
240
259
|
const runtimeTargetPortId = (target: ChartRuntimeTarget): string => `port:${target.edgeId}:target`
|
|
241
260
|
const unconnectedRegionId = (parent: string | null): string => `region:unconnected:${parent ?? "root"}`
|
|
242
261
|
const isSelfTransition = (edge: ChartEdge): boolean => edge.kind === "targetless" || edge.target === edge.source
|
|
243
262
|
const isDescendantPath = (path: string, ancestor: string): boolean => path.startsWith(`${ancestor}.`)
|
|
244
263
|
|
|
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
|
|
270
|
+
}
|
|
271
|
+
|
|
245
272
|
const unconnectedRegions = (
|
|
246
273
|
model: ChartModel,
|
|
247
274
|
policy: ReturnType<typeof makeChartLayoutPolicy>
|
|
@@ -306,12 +333,7 @@ const makeGraph = (
|
|
|
306
333
|
const regionsByParent = new Map(regions.map((region) => [region.parent, region]))
|
|
307
334
|
const sourceByEdgeId = new Map(model.edges.map((edge) => [edge.id, edge.source]))
|
|
308
335
|
const runtimeByEdgeId = new Map(model.runtimeTargets.map((target) => [target.edgeId, target]))
|
|
309
|
-
const
|
|
310
|
-
for (const initial of model.initials) {
|
|
311
|
-
const siblings = initialsByParent.get(initial.parent) ?? []
|
|
312
|
-
siblings.push(initial)
|
|
313
|
-
initialsByParent.set(initial.parent, siblings)
|
|
314
|
-
}
|
|
336
|
+
const selfLoops = selfLoopsBySource(model.edges)
|
|
315
337
|
const runtimeTargetsByParent = new Map<string | null, Array<ChartRuntimeTarget>>()
|
|
316
338
|
for (const target of model.runtimeTargets) {
|
|
317
339
|
const siblings = runtimeTargetsByParent.get(target.parent) ?? []
|
|
@@ -319,25 +341,6 @@ const makeGraph = (
|
|
|
319
341
|
runtimeTargetsByParent.set(target.parent, siblings)
|
|
320
342
|
}
|
|
321
343
|
const ports = portsByState(model.edges, policy.edge, profile.portConstraints)
|
|
322
|
-
for (const initial of model.initials) {
|
|
323
|
-
const statePorts = ports.get(initial.target) ?? []
|
|
324
|
-
statePorts.push({
|
|
325
|
-
id: initialTargetPortId(initial),
|
|
326
|
-
width: 6,
|
|
327
|
-
height: 6,
|
|
328
|
-
...(profile.portConstraints === "fixed"
|
|
329
|
-
? { layoutOptions: { "elk.port.side": "WEST" } }
|
|
330
|
-
: {})
|
|
331
|
-
})
|
|
332
|
-
ports.set(initial.target, statePorts)
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
const initialNode = (initial: ChartInitial): ElkNode => ({
|
|
336
|
-
id: initialNodeId(initial),
|
|
337
|
-
width: 14,
|
|
338
|
-
height: 14,
|
|
339
|
-
layoutOptions: { "elk.layered.layering.layerConstraint": "FIRST" }
|
|
340
|
-
})
|
|
341
344
|
const runtimeNode = (target: ChartRuntimeTarget): ElkNode => ({
|
|
342
345
|
id: runtimeNodeId(target),
|
|
343
346
|
width: 118,
|
|
@@ -347,7 +350,7 @@ const makeGraph = (
|
|
|
347
350
|
width: 6,
|
|
348
351
|
height: 6,
|
|
349
352
|
...(profile.portConstraints === "fixed"
|
|
350
|
-
? { layoutOptions: { "elk.port.side": "
|
|
353
|
+
? { layoutOptions: { "elk.port.side": "NORTH" } }
|
|
351
354
|
: {})
|
|
352
355
|
}],
|
|
353
356
|
...(profile.portConstraints === "fixed"
|
|
@@ -356,14 +359,16 @@ const makeGraph = (
|
|
|
356
359
|
})
|
|
357
360
|
|
|
358
361
|
const stateNode = (node: ChartNode, suppressUnconnectedRegion: boolean): ElkNode => {
|
|
359
|
-
const metric = nodeMetric(node)
|
|
362
|
+
const metric = nodeMetric(node, selfLoops.get(node.path) ?? 0)
|
|
360
363
|
const nodePolicy = policy.node(node.path)
|
|
361
364
|
const descendants = children(node.path, suppressUnconnectedRegion || !nodePolicy.staticPath)
|
|
362
365
|
const common = {
|
|
363
366
|
id: node.path,
|
|
364
367
|
ports: [...ports.get(node.path) ?? []],
|
|
365
368
|
layoutOptions: {
|
|
366
|
-
...(profile.portConstraints === "fixed"
|
|
369
|
+
...(profile.portConstraints === "fixed" && node.children.length === 0
|
|
370
|
+
? { "elk.portConstraints": "FIXED_SIDE" }
|
|
371
|
+
: {}),
|
|
367
372
|
"elk.spacing.portPort": "24",
|
|
368
373
|
...(nodePolicy.layerConstraint === null
|
|
369
374
|
? {}
|
|
@@ -379,16 +384,15 @@ const makeGraph = (
|
|
|
379
384
|
layoutOptions: {
|
|
380
385
|
...common.layoutOptions,
|
|
381
386
|
"elk.algorithm": "layered",
|
|
382
|
-
"elk.direction": node.type === "parallel" ? "
|
|
387
|
+
"elk.direction": node.type === "parallel" ? "RIGHT" : "DOWN",
|
|
383
388
|
"elk.padding": `[top=${metric.headerHeight + 36},left=36,bottom=36,right=36]`,
|
|
384
389
|
"elk.nodeSize.constraints": "MINIMUM_SIZE",
|
|
385
390
|
"elk.nodeSize.minimum": `(${metric.width}, ${metric.height})`,
|
|
386
391
|
"elk.spacing.nodeNode": String(profile.compoundNodeSpacing),
|
|
387
|
-
"elk.layered.spacing.nodeNodeBetweenLayers": String(
|
|
388
|
-
node.type === "parallel" ? profile.compoundNodeSpacing + 24 : profile.compoundLayerSpacing
|
|
389
|
-
),
|
|
392
|
+
"elk.layered.spacing.nodeNodeBetweenLayers": String(profile.compoundLayerSpacing),
|
|
390
393
|
"elk.spacing.edgeNode": String(profile.edgeNodeSpacing),
|
|
391
394
|
"elk.spacing.edgeEdge": String(profile.edgeEdgeSpacing),
|
|
395
|
+
"elk.spacing.edgeLabel": String(chartEdgeLabelSpacing),
|
|
392
396
|
"elk.spacing.nodeSelfLoop": String(profile.selfLoopSpacing),
|
|
393
397
|
"elk.layered.spacing.edgeNodeBetweenLayers": String(profile.edgeNodeSpacing),
|
|
394
398
|
"elk.layered.spacing.edgeEdgeBetweenLayers": String(profile.edgeEdgeSpacing)
|
|
@@ -399,11 +403,9 @@ const makeGraph = (
|
|
|
399
403
|
function children(parent: string | null, suppressUnconnectedRegion = false): Array<ElkNode> {
|
|
400
404
|
const region = suppressUnconnectedRegion ? undefined : regionsByParent.get(parent)
|
|
401
405
|
const regionPaths = new Set(region?.nodePaths ?? [])
|
|
402
|
-
const initials = initialsByParent.get(parent) ?? []
|
|
403
406
|
const runtimeTargets = runtimeTargetsByParent.get(parent) ?? []
|
|
404
407
|
const states = policy.children(parent)
|
|
405
408
|
const regular: Array<ElkNode> = [
|
|
406
|
-
...initials.filter(({ target }) => !regionPaths.has(target)).map(initialNode),
|
|
407
409
|
...states.filter(({ path }) => !regionPaths.has(path)).map((node) => stateNode(node, suppressUnconnectedRegion)),
|
|
408
410
|
...runtimeTargets
|
|
409
411
|
.filter(({ edgeId }) => !regionPaths.has(sourceByEdgeId.get(edgeId) ?? ""))
|
|
@@ -412,7 +414,6 @@ const makeGraph = (
|
|
|
412
414
|
if (region === undefined) return regular
|
|
413
415
|
|
|
414
416
|
const regionChildren: Array<ElkNode> = [
|
|
415
|
-
...initials.filter(({ target }) => regionPaths.has(target)).map(initialNode),
|
|
416
417
|
...states.filter(({ path }) => regionPaths.has(path)).map((node) => stateNode(node, true)),
|
|
417
418
|
...runtimeTargets
|
|
418
419
|
.filter(({ edgeId }) => regionPaths.has(sourceByEdgeId.get(edgeId) ?? ""))
|
|
@@ -423,13 +424,14 @@ const makeGraph = (
|
|
|
423
424
|
children: regionChildren,
|
|
424
425
|
layoutOptions: {
|
|
425
426
|
"elk.algorithm": "layered",
|
|
426
|
-
"elk.direction": "
|
|
427
|
+
"elk.direction": "DOWN",
|
|
427
428
|
"elk.padding": "[top=54,left=24,bottom=24,right=24]",
|
|
428
429
|
"elk.layered.layering.layerConstraint": "LAST",
|
|
429
430
|
"elk.spacing.nodeNode": String(profile.nodeSpacing),
|
|
430
431
|
"elk.layered.spacing.nodeNodeBetweenLayers": String(profile.layerSpacing),
|
|
431
432
|
"elk.spacing.edgeNode": String(profile.edgeNodeSpacing),
|
|
432
433
|
"elk.spacing.edgeEdge": String(profile.edgeEdgeSpacing),
|
|
434
|
+
"elk.spacing.edgeLabel": String(chartEdgeLabelSpacing),
|
|
433
435
|
"elk.spacing.nodeSelfLoop": String(profile.selfLoopSpacing)
|
|
434
436
|
}
|
|
435
437
|
})
|
|
@@ -455,21 +457,11 @@ const makeGraph = (
|
|
|
455
457
|
"elk.layered.priority.straightness": "5"
|
|
456
458
|
}
|
|
457
459
|
}
|
|
458
|
-
})
|
|
459
|
-
...model.initials.map((initial): ElkExtendedEdge => ({
|
|
460
|
-
id: initial.id,
|
|
461
|
-
sources: [initialNodeId(initial)],
|
|
462
|
-
targets: [initialTargetPortId(initial)],
|
|
463
|
-
layoutOptions: {
|
|
464
|
-
"elk.layered.priority.direction": "100",
|
|
465
|
-
"elk.layered.priority.shortness": "100",
|
|
466
|
-
"elk.layered.priority.straightness": "100"
|
|
467
|
-
}
|
|
468
|
-
}))
|
|
460
|
+
})
|
|
469
461
|
],
|
|
470
462
|
layoutOptions: {
|
|
471
463
|
"elk.algorithm": "layered",
|
|
472
|
-
"elk.direction": "
|
|
464
|
+
"elk.direction": "DOWN",
|
|
473
465
|
"elk.hierarchyHandling": "INCLUDE_CHILDREN",
|
|
474
466
|
"elk.edgeRouting": "ORTHOGONAL",
|
|
475
467
|
"elk.padding":
|
|
@@ -480,6 +472,7 @@ const makeGraph = (
|
|
|
480
472
|
"elk.layered.spacing.edgeEdgeBetweenLayers": String(profile.edgeEdgeSpacing),
|
|
481
473
|
"elk.spacing.edgeNode": String(profile.edgeNodeSpacing),
|
|
482
474
|
"elk.spacing.edgeEdge": String(profile.edgeEdgeSpacing),
|
|
475
|
+
"elk.spacing.edgeLabel": String(chartEdgeLabelSpacing),
|
|
483
476
|
"elk.spacing.nodeSelfLoop": String(profile.selfLoopSpacing),
|
|
484
477
|
"elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES",
|
|
485
478
|
"elk.layered.considerModelOrder.portModelOrder": "false",
|
|
@@ -564,13 +557,430 @@ const midpoint = (points: ReadonlyArray<ChartPoint>): ChartPoint => {
|
|
|
564
557
|
return points.at(-1)!
|
|
565
558
|
}
|
|
566
559
|
|
|
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 = (
|
|
579
|
+
points: ReadonlyArray<ChartPoint>,
|
|
580
|
+
node: LaidOutChartNode
|
|
581
|
+
): ReadonlyArray<ChartPoint> => {
|
|
582
|
+
const boundary = node.y + node.headerHeight
|
|
583
|
+
for (let index = 1; index < points.length; index++) {
|
|
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)])
|
|
592
|
+
}
|
|
593
|
+
return points
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const normalizeHierarchyRoute = (
|
|
597
|
+
edge: ChartEdge,
|
|
598
|
+
points: ReadonlyArray<ChartPoint>,
|
|
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()
|
|
610
|
+
}
|
|
611
|
+
return points
|
|
612
|
+
}
|
|
613
|
+
|
|
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 }
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
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,
|
|
661
|
+
node: LaidOutChartNode,
|
|
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)) }
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
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
|
|
734
|
+
): ReadonlyArray<ChartPoint> => {
|
|
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])
|
|
748
|
+
}
|
|
749
|
+
|
|
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
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const normalizeTerminalDirection = (
|
|
775
|
+
edge: ChartEdge,
|
|
776
|
+
points: ReadonlyArray<ChartPoint>,
|
|
777
|
+
nodes: ReadonlyMap<string, LaidOutChartNode>,
|
|
778
|
+
allNodes: ReadonlyArray<LaidOutChartNode>
|
|
779
|
+
): ReadonlyArray<ChartPoint> => {
|
|
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
|
|
800
|
+
}
|
|
801
|
+
|
|
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
|
+
}
|
|
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
|
|
869
|
+
}
|
|
870
|
+
return points
|
|
871
|
+
}
|
|
872
|
+
|
|
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
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const routeLabelCandidates = (
|
|
891
|
+
edge: ChartEdge,
|
|
892
|
+
points: ReadonlyArray<ChartPoint>,
|
|
893
|
+
fallback: ChartPoint,
|
|
894
|
+
width: number,
|
|
895
|
+
height: number
|
|
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
|
+
}
|
|
923
|
+
|
|
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
|
+
}
|
|
977
|
+
|
|
567
978
|
const collectLayout = (
|
|
568
979
|
model: ChartModel,
|
|
569
980
|
graph: ElkNode,
|
|
570
981
|
unconnected: ReadonlyArray<UnconnectedRegion>
|
|
571
982
|
): LaidOutChart => {
|
|
572
983
|
const chartNodes = new Map(model.nodes.map((node) => [node.path, node]))
|
|
573
|
-
const chartInitials = new Map(model.initials.map((initial) => [initialNodeId(initial), initial]))
|
|
574
984
|
const chartRuntimeTargets = new Map(model.runtimeTargets.map((target) => [runtimeNodeId(target), target]))
|
|
575
985
|
const chartRegions = new Map(unconnected.map((region) => [region.id, region]))
|
|
576
986
|
const offsets = new Map<string, ChartPoint>([[graph.id, { x: 0, y: 0 }]])
|
|
@@ -578,6 +988,7 @@ const collectLayout = (
|
|
|
578
988
|
const nodes: Array<LaidOutChartNode> = []
|
|
579
989
|
const initials: Array<LaidOutChartInitial> = []
|
|
580
990
|
const runtimeTargets: Array<LaidOutChartRuntimeTarget> = []
|
|
991
|
+
const selfLoops = selfLoopsBySource(model.edges)
|
|
581
992
|
|
|
582
993
|
const visit = (node: ElkNode, parentOffset: ChartPoint): void => {
|
|
583
994
|
const absolute = add(parentOffset, { x: node.x ?? 0, y: node.y ?? 0 })
|
|
@@ -596,7 +1007,7 @@ const collectLayout = (
|
|
|
596
1007
|
}
|
|
597
1008
|
const chartNode = chartNodes.get(node.id)
|
|
598
1009
|
if (chartNode !== undefined) {
|
|
599
|
-
const metric = nodeMetric(chartNode)
|
|
1010
|
+
const metric = nodeMetric(chartNode, selfLoops.get(chartNode.path) ?? 0)
|
|
600
1011
|
nodes.push({
|
|
601
1012
|
node: chartNode,
|
|
602
1013
|
x: absolute.x,
|
|
@@ -606,16 +1017,6 @@ const collectLayout = (
|
|
|
606
1017
|
headerHeight: metric.headerHeight
|
|
607
1018
|
})
|
|
608
1019
|
}
|
|
609
|
-
const initial = chartInitials.get(node.id)
|
|
610
|
-
if (initial !== undefined) {
|
|
611
|
-
initials.push({
|
|
612
|
-
initial,
|
|
613
|
-
x: absolute.x,
|
|
614
|
-
y: absolute.y,
|
|
615
|
-
width: node.width ?? 14,
|
|
616
|
-
height: node.height ?? 14
|
|
617
|
-
})
|
|
618
|
-
}
|
|
619
1020
|
const runtimeTarget = chartRuntimeTargets.get(node.id)
|
|
620
1021
|
if (runtimeTarget !== undefined) {
|
|
621
1022
|
runtimeTargets.push({
|
|
@@ -630,39 +1031,90 @@ const collectLayout = (
|
|
|
630
1031
|
}
|
|
631
1032
|
for (const child of graph.children ?? []) visit(child, { x: 0, y: 0 })
|
|
632
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
|
+
|
|
633
1047
|
const chartEdges = new Map(model.edges.map((edge) => [edge.id, edge]))
|
|
634
|
-
const
|
|
635
|
-
const
|
|
636
|
-
|
|
1048
|
+
const hierarchyLanes = new Map<string, number>()
|
|
1049
|
+
const directLanes = new Map<string, number>()
|
|
1050
|
+
const rawTransitionEdges = (graph.edges ?? []).flatMap(
|
|
1051
|
+
(edge): ReadonlyArray<LaidOutChartTransition> => {
|
|
637
1052
|
const offset = offsets.get(edge.container ?? graph.id) ?? { x: 0, y: 0 }
|
|
638
|
-
const
|
|
639
|
-
if (
|
|
1053
|
+
const elkPoints = edgePoints(edge, offset)
|
|
1054
|
+
if (elkPoints === undefined) return []
|
|
640
1055
|
const chartEdge = chartEdges.get(edge.id)
|
|
641
|
-
if (chartEdge
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
|
1078
|
+
)
|
|
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
|
+
}]
|
|
662
1101
|
}
|
|
663
1102
|
)
|
|
664
|
-
|
|
665
|
-
|
|
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
|
+
}]
|
|
1112
|
+
})
|
|
1113
|
+
const transitionEdges = placeTransitionLabels(rawTransitionEdges, nodes)
|
|
1114
|
+
const edges: ReadonlyArray<LaidOutChartTransition | LaidOutChartInitialEdge> = [
|
|
1115
|
+
...transitionEdges,
|
|
1116
|
+
...initialEdges
|
|
1117
|
+
]
|
|
666
1118
|
const contentWidth = Math.max(
|
|
667
1119
|
0,
|
|
668
1120
|
...nodes.map(({ width, x }) => x + width),
|
|
@@ -872,16 +1324,44 @@ export const validateChartLayout = (
|
|
|
872
1324
|
if (transitionLabelDistance > Math.max(transition.labelWidth, transition.labelHeight) / 2 + 12) {
|
|
873
1325
|
report("label-detached", transition.edge.id, `${Math.round(transitionLabelDistance)}px`)
|
|
874
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
|
+
|
|
875
1343
|
const end = transition.points.at(-1)
|
|
876
1344
|
const bend = transition.points.at(-2)
|
|
877
1345
|
if (
|
|
878
1346
|
end === undefined || bend === undefined ||
|
|
879
1347
|
Math.abs(end.x - bend.x) + Math.abs(end.y - bend.y) < 9
|
|
880
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
|
+
}
|
|
881
1357
|
|
|
882
1358
|
const label = labelRect(transition.label, transition.labelWidth, transition.labelHeight)
|
|
883
1359
|
for (const node of layout.nodes) {
|
|
884
|
-
const
|
|
1360
|
+
const touchesCompound = node.node.children.length > 0 && transitionTouchesNode(
|
|
1361
|
+
transition.edge,
|
|
1362
|
+
node.node.path
|
|
1363
|
+
)
|
|
1364
|
+
const obstacle = touchesCompound
|
|
885
1365
|
? nodeHeaderRect(node)
|
|
886
1366
|
: nodeRect(node)
|
|
887
1367
|
if (overlaps(label, obstacle, 2)) report("label-node-overlap", transition.edge.id, node.node.path)
|
|
@@ -970,6 +1450,7 @@ export const validateChartLayout = (
|
|
|
970
1450
|
}
|
|
971
1451
|
|
|
972
1452
|
type ChartLayoutEngine = (graph: ElkNode, portConstraints: PortConstraints) => Promise<ElkNode>
|
|
1453
|
+
type ChartLayoutValidator = (model: ChartModel, layout: LaidOutChart) => ChartLayoutValidation
|
|
973
1454
|
|
|
974
1455
|
interface LayoutAttemptFailure {
|
|
975
1456
|
readonly profile: string
|
|
@@ -978,9 +1459,18 @@ interface LayoutAttemptFailure {
|
|
|
978
1459
|
|
|
979
1460
|
interface InvalidLayoutCandidate {
|
|
980
1461
|
readonly profile: string
|
|
1462
|
+
readonly layout: LaidOutChart
|
|
981
1463
|
readonly validation: ChartLayoutValidation
|
|
982
1464
|
}
|
|
983
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]
|
|
1473
|
+
|
|
984
1474
|
const causeMessage = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause)
|
|
985
1475
|
|
|
986
1476
|
const issueSummary = (validation: ChartLayoutValidation): string => {
|
|
@@ -995,7 +1485,8 @@ const issueSummary = (validation: ChartLayoutValidation): string => {
|
|
|
995
1485
|
|
|
996
1486
|
export const layoutChartWith = (
|
|
997
1487
|
model: ChartModel,
|
|
998
|
-
layout: ChartLayoutEngine
|
|
1488
|
+
layout: ChartLayoutEngine,
|
|
1489
|
+
validate: ChartLayoutValidator = validateChartLayout
|
|
999
1490
|
): Effect.Effect<LaidOutChart, ChartLayoutError> =>
|
|
1000
1491
|
Effect.suspend(() => {
|
|
1001
1492
|
const policy = makeChartLayoutPolicy(model)
|
|
@@ -1006,9 +1497,9 @@ export const layoutChartWith = (
|
|
|
1006
1497
|
const attempt = (index: number): Effect.Effect<LaidOutChart, ChartLayoutError> => {
|
|
1007
1498
|
const profile = layoutProfiles[index]
|
|
1008
1499
|
if (profile === undefined) {
|
|
1009
|
-
const
|
|
1010
|
-
|
|
1011
|
-
)
|
|
1500
|
+
const fallback = bestLayoutCandidate(invalid.filter(({ validation }) => isWarningOnlyLayout(validation)))
|
|
1501
|
+
if (fallback !== undefined) return Effect.succeed(fallback.layout)
|
|
1502
|
+
const best = bestLayoutCandidate(invalid)
|
|
1012
1503
|
const detail = best === undefined
|
|
1013
1504
|
? failures.map(({ cause, profile }) => `${profile}: ${causeMessage(cause)}`).join("; ")
|
|
1014
1505
|
: `${best.profile}: ${issueSummary(best.validation)}`
|
|
@@ -1032,9 +1523,9 @@ export const layoutChartWith = (
|
|
|
1032
1523
|
},
|
|
1033
1524
|
onSuccess: (graph) => {
|
|
1034
1525
|
const candidate = collectLayout(model, graph, regions)
|
|
1035
|
-
const validation =
|
|
1526
|
+
const validation = validate(model, candidate)
|
|
1036
1527
|
if (validation.valid) return Effect.succeed(candidate)
|
|
1037
|
-
invalid.push({ profile: profile.id, validation })
|
|
1528
|
+
invalid.push({ profile: profile.id, layout: candidate, validation })
|
|
1038
1529
|
return attempt(index + 1)
|
|
1039
1530
|
}
|
|
1040
1531
|
}
|