@typeonce/effect-machine-devtools 0.27.0 → 0.27.1
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/dist/client/assets/{index-BsorPSDS.css → index-BnBnpx0G.css} +1 -1
- package/dist/client/assets/index-DM9jlCJV.js +37 -0
- package/dist/client/index.html +2 -2
- package/package.json +2 -2
- package/src/internal/browser/chart-layout.ts +504 -374
- package/src/internal/browser/chart-renderer.ts +16 -2
- package/src/internal/browser/hierarchy-routing-example.ts +91 -0
- package/src/internal/browser/styles.css +38 -5
- package/dist/client/assets/index-BFxKU97K.js +0 -37
|
@@ -14,6 +14,7 @@ import type { ChartEdge, ChartInitial, ChartModel, ChartNode, ChartRuntimeTarget
|
|
|
14
14
|
|
|
15
15
|
export const maxVisibleFields = 4
|
|
16
16
|
export const maxVisibleActivities = 3
|
|
17
|
+
export const chartSelfLoopMinimumClearance = 24
|
|
17
18
|
|
|
18
19
|
export interface ChartPoint {
|
|
19
20
|
readonly x: number
|
|
@@ -80,6 +81,31 @@ export interface LaidOutChart {
|
|
|
80
81
|
readonly edges: ReadonlyArray<LaidOutChartTransition | LaidOutChartInitialEdge>
|
|
81
82
|
}
|
|
82
83
|
|
|
84
|
+
export type ChartLayoutIssueCode =
|
|
85
|
+
| "missing-edge"
|
|
86
|
+
| "label-detached"
|
|
87
|
+
| "label-label-overlap"
|
|
88
|
+
| "label-node-overlap"
|
|
89
|
+
| "label-route-overlap"
|
|
90
|
+
| "node-crossing"
|
|
91
|
+
| "route-overlap"
|
|
92
|
+
| "self-loop-clearance"
|
|
93
|
+
| "self-loop-outside-parent"
|
|
94
|
+
| "short-terminal"
|
|
95
|
+
|
|
96
|
+
export interface ChartLayoutIssue {
|
|
97
|
+
readonly code: ChartLayoutIssueCode
|
|
98
|
+
readonly edgeId: string
|
|
99
|
+
readonly relatedId: string | null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface ChartLayoutValidation {
|
|
103
|
+
readonly valid: boolean
|
|
104
|
+
readonly issues: ReadonlyArray<ChartLayoutIssue>
|
|
105
|
+
readonly crossings: number
|
|
106
|
+
readonly routeLength: number
|
|
107
|
+
}
|
|
108
|
+
|
|
83
109
|
export class ChartLayoutError extends Data.TaggedError("ChartLayoutError")<{
|
|
84
110
|
readonly cause: unknown
|
|
85
111
|
readonly message: string
|
|
@@ -91,6 +117,97 @@ interface NodeMetric {
|
|
|
91
117
|
readonly headerHeight: number
|
|
92
118
|
}
|
|
93
119
|
|
|
120
|
+
interface ChartRect {
|
|
121
|
+
readonly left: number
|
|
122
|
+
readonly right: number
|
|
123
|
+
readonly top: number
|
|
124
|
+
readonly bottom: number
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface UnconnectedRegion {
|
|
128
|
+
readonly id: string
|
|
129
|
+
readonly parent: string | null
|
|
130
|
+
readonly nodePaths: ReadonlyArray<string>
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
type PortConstraints = "fixed" | "relaxed"
|
|
134
|
+
|
|
135
|
+
interface LayoutProfile {
|
|
136
|
+
readonly id: string
|
|
137
|
+
readonly portConstraints: PortConstraints
|
|
138
|
+
readonly nodeSpacing: number
|
|
139
|
+
readonly layerSpacing: number
|
|
140
|
+
readonly edgeNodeSpacing: number
|
|
141
|
+
readonly edgeEdgeSpacing: number
|
|
142
|
+
readonly compoundNodeSpacing: number
|
|
143
|
+
readonly compoundLayerSpacing: number
|
|
144
|
+
readonly selfLoopSpacing: number
|
|
145
|
+
readonly padding: number
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const layoutProfiles: ReadonlyArray<LayoutProfile> = [
|
|
149
|
+
{
|
|
150
|
+
id: "compact-fixed",
|
|
151
|
+
portConstraints: "fixed",
|
|
152
|
+
nodeSpacing: 64,
|
|
153
|
+
layerSpacing: 148,
|
|
154
|
+
edgeNodeSpacing: 42,
|
|
155
|
+
edgeEdgeSpacing: 26,
|
|
156
|
+
compoundNodeSpacing: 44,
|
|
157
|
+
compoundLayerSpacing: 108,
|
|
158
|
+
selfLoopSpacing: 32,
|
|
159
|
+
padding: 44
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
id: "spacious-fixed",
|
|
163
|
+
portConstraints: "fixed",
|
|
164
|
+
nodeSpacing: 88,
|
|
165
|
+
layerSpacing: 188,
|
|
166
|
+
edgeNodeSpacing: 58,
|
|
167
|
+
edgeEdgeSpacing: 38,
|
|
168
|
+
compoundNodeSpacing: 64,
|
|
169
|
+
compoundLayerSpacing: 144,
|
|
170
|
+
selfLoopSpacing: 40,
|
|
171
|
+
padding: 56
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
id: "roomy-fixed",
|
|
175
|
+
portConstraints: "fixed",
|
|
176
|
+
nodeSpacing: 112,
|
|
177
|
+
layerSpacing: 232,
|
|
178
|
+
edgeNodeSpacing: 76,
|
|
179
|
+
edgeEdgeSpacing: 52,
|
|
180
|
+
compoundNodeSpacing: 82,
|
|
181
|
+
compoundLayerSpacing: 180,
|
|
182
|
+
selfLoopSpacing: 48,
|
|
183
|
+
padding: 68
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: "spacious-relaxed",
|
|
187
|
+
portConstraints: "relaxed",
|
|
188
|
+
nodeSpacing: 88,
|
|
189
|
+
layerSpacing: 188,
|
|
190
|
+
edgeNodeSpacing: 58,
|
|
191
|
+
edgeEdgeSpacing: 38,
|
|
192
|
+
compoundNodeSpacing: 64,
|
|
193
|
+
compoundLayerSpacing: 144,
|
|
194
|
+
selfLoopSpacing: 40,
|
|
195
|
+
padding: 56
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
id: "roomy-relaxed",
|
|
199
|
+
portConstraints: "relaxed",
|
|
200
|
+
nodeSpacing: 112,
|
|
201
|
+
layerSpacing: 232,
|
|
202
|
+
edgeNodeSpacing: 76,
|
|
203
|
+
edgeEdgeSpacing: 52,
|
|
204
|
+
compoundNodeSpacing: 82,
|
|
205
|
+
compoundLayerSpacing: 180,
|
|
206
|
+
selfLoopSpacing: 48,
|
|
207
|
+
padding: 68
|
|
208
|
+
}
|
|
209
|
+
]
|
|
210
|
+
|
|
94
211
|
const sectionHeight = (length: number, limit: number): number => {
|
|
95
212
|
if (length === 0) return 0
|
|
96
213
|
const visible = Math.min(length, limit)
|
|
@@ -123,33 +240,8 @@ const runtimeNodeId = (target: ChartRuntimeTarget): string => `node:${target.id}
|
|
|
123
240
|
const runtimeTargetPortId = (target: ChartRuntimeTarget): string => `port:${target.edgeId}:target`
|
|
124
241
|
const unconnectedRegionId = (parent: string | null): string => `region:unconnected:${parent ?? "root"}`
|
|
125
242
|
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
243
|
const isDescendantPath = (path: string, ancestor: string): boolean => path.startsWith(`${ancestor}.`)
|
|
140
244
|
|
|
141
|
-
const externalIncomingCount = (model: ChartModel, source: string): number =>
|
|
142
|
-
model.edges.filter((edge) =>
|
|
143
|
-
edge.target !== null && isDescendantPath(edge.target, source) &&
|
|
144
|
-
edge.source !== source && !isDescendantPath(edge.source, source)
|
|
145
|
-
).length
|
|
146
|
-
|
|
147
|
-
interface UnconnectedRegion {
|
|
148
|
-
readonly id: string
|
|
149
|
-
readonly parent: string | null
|
|
150
|
-
readonly nodePaths: ReadonlyArray<string>
|
|
151
|
-
}
|
|
152
|
-
|
|
153
245
|
const unconnectedRegions = (
|
|
154
246
|
model: ChartModel,
|
|
155
247
|
policy: ReturnType<typeof makeChartLayoutPolicy>
|
|
@@ -166,6 +258,12 @@ const unconnectedRegions = (
|
|
|
166
258
|
})
|
|
167
259
|
}
|
|
168
260
|
|
|
261
|
+
const edgeTargetPath = (edge: ChartEdge): string | null => {
|
|
262
|
+
if (edge.kind === "target") return edge.target
|
|
263
|
+
if (edge.kind === "targetless") return edge.source
|
|
264
|
+
return null
|
|
265
|
+
}
|
|
266
|
+
|
|
169
267
|
const portsByState = (
|
|
170
268
|
edges: ReadonlyArray<ChartEdge>,
|
|
171
269
|
edgePolicy: ReturnType<typeof makeChartLayoutPolicy>["edge"],
|
|
@@ -188,9 +286,8 @@ const portsByState = (
|
|
|
188
286
|
for (const edge of edges) {
|
|
189
287
|
const policy = edgePolicy(edge)
|
|
190
288
|
add(edge.source, sourcePortId(edge), policy.sourceSide)
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
289
|
+
const target = edgeTargetPath(edge)
|
|
290
|
+
if (target !== null) add(target, targetPortId(edge), policy.targetSide)
|
|
194
291
|
}
|
|
195
292
|
return ports
|
|
196
293
|
}
|
|
@@ -204,30 +301,11 @@ const makeGraph = (
|
|
|
204
301
|
model: ChartModel,
|
|
205
302
|
policy: ReturnType<typeof makeChartLayoutPolicy>,
|
|
206
303
|
regions: ReadonlyArray<UnconnectedRegion>,
|
|
207
|
-
|
|
304
|
+
profile: LayoutProfile
|
|
208
305
|
): ElkNode => {
|
|
209
|
-
const nodesByPath = new Map(model.nodes.map((node) => [node.path, node]))
|
|
210
306
|
const regionsByParent = new Map(regions.map((region) => [region.parent, region]))
|
|
211
307
|
const sourceByEdgeId = new Map(model.edges.map((edge) => [edge.id, edge.source]))
|
|
212
|
-
const
|
|
213
|
-
const selfLoopCounts = new Map<string, number>()
|
|
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
|
-
}
|
|
308
|
+
const runtimeByEdgeId = new Map(model.runtimeTargets.map((target) => [target.edgeId, target]))
|
|
231
309
|
const initialsByParent = new Map<string | null, Array<ChartInitial>>()
|
|
232
310
|
for (const initial of model.initials) {
|
|
233
311
|
const siblings = initialsByParent.get(initial.parent) ?? []
|
|
@@ -240,14 +318,14 @@ const makeGraph = (
|
|
|
240
318
|
siblings.push(target)
|
|
241
319
|
runtimeTargetsByParent.set(target.parent, siblings)
|
|
242
320
|
}
|
|
243
|
-
const ports = portsByState(
|
|
321
|
+
const ports = portsByState(model.edges, policy.edge, profile.portConstraints)
|
|
244
322
|
for (const initial of model.initials) {
|
|
245
323
|
const statePorts = ports.get(initial.target) ?? []
|
|
246
324
|
statePorts.push({
|
|
247
325
|
id: initialTargetPortId(initial),
|
|
248
326
|
width: 6,
|
|
249
327
|
height: 6,
|
|
250
|
-
...(portConstraints === "fixed"
|
|
328
|
+
...(profile.portConstraints === "fixed"
|
|
251
329
|
? { layoutOptions: { "elk.port.side": "WEST" } }
|
|
252
330
|
: {})
|
|
253
331
|
})
|
|
@@ -258,9 +336,7 @@ const makeGraph = (
|
|
|
258
336
|
id: initialNodeId(initial),
|
|
259
337
|
width: 14,
|
|
260
338
|
height: 14,
|
|
261
|
-
layoutOptions: {
|
|
262
|
-
"elk.layered.layering.layerConstraint": "FIRST"
|
|
263
|
-
}
|
|
339
|
+
layoutOptions: { "elk.layered.layering.layerConstraint": "FIRST" }
|
|
264
340
|
})
|
|
265
341
|
const runtimeNode = (target: ChartRuntimeTarget): ElkNode => ({
|
|
266
342
|
id: runtimeNodeId(target),
|
|
@@ -270,11 +346,11 @@ const makeGraph = (
|
|
|
270
346
|
id: runtimeTargetPortId(target),
|
|
271
347
|
width: 6,
|
|
272
348
|
height: 6,
|
|
273
|
-
...(portConstraints === "fixed"
|
|
349
|
+
...(profile.portConstraints === "fixed"
|
|
274
350
|
? { layoutOptions: { "elk.port.side": "WEST" } }
|
|
275
351
|
: {})
|
|
276
352
|
}],
|
|
277
|
-
...(portConstraints === "fixed"
|
|
353
|
+
...(profile.portConstraints === "fixed"
|
|
278
354
|
? { layoutOptions: { "elk.portConstraints": "FIXED_SIDE" } }
|
|
279
355
|
: {})
|
|
280
356
|
})
|
|
@@ -283,24 +359,19 @@ const makeGraph = (
|
|
|
283
359
|
const metric = nodeMetric(node)
|
|
284
360
|
const nodePolicy = policy.node(node.path)
|
|
285
361
|
const descendants = children(node.path, suppressUnconnectedRegion || !nodePolicy.staticPath)
|
|
286
|
-
const bottomPadding = 28 + (selfLoopAllowanceByParent.get(node.path) ?? 0)
|
|
287
362
|
const common = {
|
|
288
363
|
id: node.path,
|
|
289
364
|
ports: [...ports.get(node.path) ?? []],
|
|
290
365
|
layoutOptions: {
|
|
291
|
-
...(portConstraints === "fixed" ? { "elk.portConstraints": "FIXED_SIDE" } : {}),
|
|
292
|
-
"elk.spacing.portPort": "
|
|
366
|
+
...(profile.portConstraints === "fixed" ? { "elk.portConstraints": "FIXED_SIDE" } : {}),
|
|
367
|
+
"elk.spacing.portPort": "24",
|
|
293
368
|
...(nodePolicy.layerConstraint === null
|
|
294
369
|
? {}
|
|
295
370
|
: { "elk.layered.layering.layerConstraint": nodePolicy.layerConstraint })
|
|
296
371
|
}
|
|
297
372
|
}
|
|
298
373
|
if (descendants.length === 0) {
|
|
299
|
-
return {
|
|
300
|
-
...common,
|
|
301
|
-
width: metric.width,
|
|
302
|
-
height: metric.height
|
|
303
|
-
}
|
|
374
|
+
return { ...common, width: metric.width, height: metric.height }
|
|
304
375
|
}
|
|
305
376
|
return {
|
|
306
377
|
...common,
|
|
@@ -309,11 +380,18 @@ const makeGraph = (
|
|
|
309
380
|
...common.layoutOptions,
|
|
310
381
|
"elk.algorithm": "layered",
|
|
311
382
|
"elk.direction": node.type === "parallel" ? "DOWN" : "RIGHT",
|
|
312
|
-
"elk.padding": `[top=${metric.headerHeight +
|
|
383
|
+
"elk.padding": `[top=${metric.headerHeight + 36},left=36,bottom=36,right=36]`,
|
|
313
384
|
"elk.nodeSize.constraints": "MINIMUM_SIZE",
|
|
314
385
|
"elk.nodeSize.minimum": `(${metric.width}, ${metric.height})`,
|
|
315
|
-
"elk.spacing.nodeNode":
|
|
316
|
-
"elk.layered.spacing.nodeNodeBetweenLayers":
|
|
386
|
+
"elk.spacing.nodeNode": String(profile.compoundNodeSpacing),
|
|
387
|
+
"elk.layered.spacing.nodeNodeBetweenLayers": String(
|
|
388
|
+
node.type === "parallel" ? profile.compoundNodeSpacing + 24 : profile.compoundLayerSpacing
|
|
389
|
+
),
|
|
390
|
+
"elk.spacing.edgeNode": String(profile.edgeNodeSpacing),
|
|
391
|
+
"elk.spacing.edgeEdge": String(profile.edgeEdgeSpacing),
|
|
392
|
+
"elk.spacing.nodeSelfLoop": String(profile.selfLoopSpacing),
|
|
393
|
+
"elk.layered.spacing.edgeNodeBetweenLayers": String(profile.edgeNodeSpacing),
|
|
394
|
+
"elk.layered.spacing.edgeEdgeBetweenLayers": String(profile.edgeEdgeSpacing)
|
|
317
395
|
}
|
|
318
396
|
}
|
|
319
397
|
}
|
|
@@ -348,8 +426,11 @@ const makeGraph = (
|
|
|
348
426
|
"elk.direction": "RIGHT",
|
|
349
427
|
"elk.padding": "[top=54,left=24,bottom=24,right=24]",
|
|
350
428
|
"elk.layered.layering.layerConstraint": "LAST",
|
|
351
|
-
"elk.spacing.nodeNode":
|
|
352
|
-
"elk.layered.spacing.nodeNodeBetweenLayers":
|
|
429
|
+
"elk.spacing.nodeNode": String(profile.nodeSpacing),
|
|
430
|
+
"elk.layered.spacing.nodeNodeBetweenLayers": String(profile.layerSpacing),
|
|
431
|
+
"elk.spacing.edgeNode": String(profile.edgeNodeSpacing),
|
|
432
|
+
"elk.spacing.edgeEdge": String(profile.edgeEdgeSpacing),
|
|
433
|
+
"elk.spacing.nodeSelfLoop": String(profile.selfLoopSpacing)
|
|
353
434
|
}
|
|
354
435
|
})
|
|
355
436
|
return regular
|
|
@@ -359,13 +440,14 @@ const makeGraph = (
|
|
|
359
440
|
id: "chart-root",
|
|
360
441
|
children: children(null),
|
|
361
442
|
edges: [
|
|
362
|
-
...
|
|
443
|
+
...model.edges.map((edge): ElkExtendedEdge => {
|
|
363
444
|
const label = labelMetric(edge.label)
|
|
364
445
|
const edgeLayout = policy.edge(edge)
|
|
446
|
+
const runtimeTarget = edge.kind === "runtime" ? runtimeByEdgeId.get(edge.id) : undefined
|
|
365
447
|
return {
|
|
366
448
|
id: edge.id,
|
|
367
449
|
sources: [sourcePortId(edge)],
|
|
368
|
-
targets: [targetPortId(edge)],
|
|
450
|
+
targets: [runtimeTarget === undefined ? targetPortId(edge) : runtimeTargetPortId(runtimeTarget)],
|
|
369
451
|
labels: [{ text: edge.label, width: label.width, height: label.height }],
|
|
370
452
|
layoutOptions: {
|
|
371
453
|
"elk.layered.priority.direction": edgeLayout.direction === "forward" ? "10" : "1",
|
|
@@ -390,13 +472,15 @@ const makeGraph = (
|
|
|
390
472
|
"elk.direction": "RIGHT",
|
|
391
473
|
"elk.hierarchyHandling": "INCLUDE_CHILDREN",
|
|
392
474
|
"elk.edgeRouting": "ORTHOGONAL",
|
|
393
|
-
"elk.padding":
|
|
394
|
-
|
|
395
|
-
"elk.
|
|
396
|
-
"elk.layered.spacing.
|
|
397
|
-
"elk.layered.spacing.
|
|
398
|
-
"elk.spacing.
|
|
399
|
-
"elk.spacing.
|
|
475
|
+
"elk.padding":
|
|
476
|
+
`[top=${profile.padding},left=${profile.padding},bottom=${profile.padding},right=${profile.padding}]`,
|
|
477
|
+
"elk.spacing.nodeNode": String(profile.nodeSpacing),
|
|
478
|
+
"elk.layered.spacing.nodeNodeBetweenLayers": String(profile.layerSpacing),
|
|
479
|
+
"elk.layered.spacing.edgeNodeBetweenLayers": String(profile.edgeNodeSpacing),
|
|
480
|
+
"elk.layered.spacing.edgeEdgeBetweenLayers": String(profile.edgeEdgeSpacing),
|
|
481
|
+
"elk.spacing.edgeNode": String(profile.edgeNodeSpacing),
|
|
482
|
+
"elk.spacing.edgeEdge": String(profile.edgeEdgeSpacing),
|
|
483
|
+
"elk.spacing.nodeSelfLoop": String(profile.selfLoopSpacing),
|
|
400
484
|
"elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES",
|
|
401
485
|
"elk.layered.considerModelOrder.portModelOrder": "false",
|
|
402
486
|
"elk.layered.considerModelOrder.crossingCounterNodeInfluence": "0.001",
|
|
@@ -480,244 +564,6 @@ const midpoint = (points: ReadonlyArray<ChartPoint>): ChartPoint => {
|
|
|
480
564
|
return points.at(-1)!
|
|
481
565
|
}
|
|
482
566
|
|
|
483
|
-
const longestSegment = (
|
|
484
|
-
points: ReadonlyArray<ChartPoint>,
|
|
485
|
-
matches: (start: ChartPoint, end: ChartPoint) => boolean,
|
|
486
|
-
length: (start: ChartPoint, end: ChartPoint) => number
|
|
487
|
-
): readonly [ChartPoint, ChartPoint] | undefined => {
|
|
488
|
-
let result: readonly [ChartPoint, ChartPoint] | undefined
|
|
489
|
-
let resultLength = -1
|
|
490
|
-
for (let index = 1; index < points.length; index++) {
|
|
491
|
-
const start = points[index - 1]!
|
|
492
|
-
const end = points[index]!
|
|
493
|
-
if (!matches(start, end)) continue
|
|
494
|
-
const candidateLength = length(start, end)
|
|
495
|
-
if (candidateLength > resultLength) {
|
|
496
|
-
result = [start, end]
|
|
497
|
-
resultLength = candidateLength
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
return result
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
export const selfLoopLabelPosition = (
|
|
504
|
-
points: ReadonlyArray<ChartPoint>,
|
|
505
|
-
labelWidth: number,
|
|
506
|
-
labelHeight: number
|
|
507
|
-
): ChartPoint => {
|
|
508
|
-
const start = points[0]
|
|
509
|
-
const end = points.at(-1)
|
|
510
|
-
if (start === undefined || end === undefined) return midpoint(points)
|
|
511
|
-
|
|
512
|
-
if (Math.abs(start.y - end.y) <= Math.abs(start.x - end.x)) {
|
|
513
|
-
const outerY = Math.max(...points.map(({ y }) => y))
|
|
514
|
-
const segment = longestSegment(
|
|
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
|
-
}
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
const outerX = Math.max(...points.map(({ x }) => x))
|
|
526
|
-
const segment = longestSegment(
|
|
527
|
-
points,
|
|
528
|
-
(top, bottom) => top.x === outerX && bottom.x === outerX,
|
|
529
|
-
(top, bottom) => Math.abs(bottom.y - top.y)
|
|
530
|
-
)
|
|
531
|
-
return {
|
|
532
|
-
x: outerX + chartSelfLoopLabelGap + labelWidth / 2,
|
|
533
|
-
y: segment === undefined ? (start.y + end.y) / 2 : (segment[0].y + segment[1].y) / 2
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
const laidOutSelfTransition = (
|
|
538
|
-
edge: ChartEdge,
|
|
539
|
-
node: LaidOutChartNode,
|
|
540
|
-
lane: number
|
|
541
|
-
): LaidOutChartTransition => {
|
|
542
|
-
const metric = labelMetric(edge.label)
|
|
543
|
-
const centerX = node.x + node.width / 2
|
|
544
|
-
const maximumHalfWidth = Math.max(24, node.width / 2 - 24)
|
|
545
|
-
const halfWidth = Math.min(maximumHalfWidth, Math.max(52, metric.width / 2 + 12))
|
|
546
|
-
const bottom = node.y + node.height
|
|
547
|
-
const outerY = bottom + chartSelfLoopRouteGap + lane * chartSelfLoopLaneGap
|
|
548
|
-
const points = ensureChartEdgeTerminalClearance([
|
|
549
|
-
{ x: centerX - halfWidth, y: bottom },
|
|
550
|
-
{ x: centerX - halfWidth, y: outerY },
|
|
551
|
-
{ x: centerX + halfWidth, y: outerY },
|
|
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
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
export const ensureChartEdgeTerminalClearance = (
|
|
565
|
-
points: ReadonlyArray<ChartPoint>
|
|
566
|
-
): ReadonlyArray<ChartPoint> => {
|
|
567
|
-
const end = points.at(-1)
|
|
568
|
-
const bend = points.at(-2)
|
|
569
|
-
if (end === undefined || bend === undefined || points.length < 3) return points
|
|
570
|
-
const horizontal = end.y === bend.y
|
|
571
|
-
const length = horizontal ? Math.abs(end.x - bend.x) : Math.abs(end.y - bend.y)
|
|
572
|
-
if (length >= chartEdgeTerminalClearance) return points
|
|
573
|
-
|
|
574
|
-
const result = points.map((point) => ({ ...point }))
|
|
575
|
-
if (horizontal) {
|
|
576
|
-
const direction = Math.sign(end.x - bend.x)
|
|
577
|
-
if (direction === 0) return points
|
|
578
|
-
let first = points.length - 2
|
|
579
|
-
while (first > 0 && points[first - 1]!.x === bend.x) first--
|
|
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
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
const terminalApproach = (points: ReadonlyArray<ChartPoint>): TerminalApproach | undefined => {
|
|
604
|
-
const end = points.at(-1)
|
|
605
|
-
const bend = points.at(-2)
|
|
606
|
-
const beforeBend = points.at(-3)
|
|
607
|
-
if (end === undefined || bend === undefined || beforeBend === undefined) return undefined
|
|
608
|
-
if (end.y === bend.y && beforeBend.x === bend.x) {
|
|
609
|
-
const direction = Math.sign(end.x - bend.x)
|
|
610
|
-
return direction === 0
|
|
611
|
-
? undefined
|
|
612
|
-
: { axis: "vertical", direction, end, bend, bendIndex: points.length - 2 }
|
|
613
|
-
}
|
|
614
|
-
if (end.x === bend.x && beforeBend.y === bend.y) {
|
|
615
|
-
const direction = Math.sign(end.y - bend.y)
|
|
616
|
-
return direction === 0
|
|
617
|
-
? undefined
|
|
618
|
-
: { axis: "horizontal", direction, end, bend, bendIndex: points.length - 2 }
|
|
619
|
-
}
|
|
620
|
-
return undefined
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
const moveTerminalApproach = (
|
|
624
|
-
points: ReadonlyArray<ChartPoint>,
|
|
625
|
-
approach: TerminalApproach,
|
|
626
|
-
distance: number
|
|
627
|
-
): ReadonlyArray<ChartPoint> => {
|
|
628
|
-
const result = points.map((point) => ({ ...point }))
|
|
629
|
-
if (approach.axis === "vertical") {
|
|
630
|
-
const x = approach.end.x - approach.direction * distance
|
|
631
|
-
let first = approach.bendIndex
|
|
632
|
-
while (first > 0 && points[first - 1]!.x === approach.bend.x) first--
|
|
633
|
-
if (first === 0) {
|
|
634
|
-
const start = points[0]!
|
|
635
|
-
return compactPoints([
|
|
636
|
-
start,
|
|
637
|
-
{ x, y: start.y },
|
|
638
|
-
{ x, y: approach.end.y },
|
|
639
|
-
approach.end
|
|
640
|
-
])
|
|
641
|
-
}
|
|
642
|
-
for (let index = first; index <= approach.bendIndex; index++) result[index]!.x = x
|
|
643
|
-
} else {
|
|
644
|
-
const y = approach.end.y - approach.direction * distance
|
|
645
|
-
let first = approach.bendIndex
|
|
646
|
-
while (first > 0 && points[first - 1]!.y === approach.bend.y) first--
|
|
647
|
-
if (first === 0) {
|
|
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)
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
const separateTerminalApproaches = (
|
|
662
|
-
transitions: ReadonlyArray<LaidOutChartTransition>
|
|
663
|
-
): ReadonlyArray<LaidOutChartTransition> => {
|
|
664
|
-
const groups = new Map<
|
|
665
|
-
string,
|
|
666
|
-
Array<{ readonly edge: LaidOutChartTransition; readonly approach: TerminalApproach }>
|
|
667
|
-
>()
|
|
668
|
-
for (const edge of transitions) {
|
|
669
|
-
if (edge.edge.target === null || isSelfTransition(edge.edge)) continue
|
|
670
|
-
const approach = terminalApproach(edge.points)
|
|
671
|
-
if (approach === undefined) continue
|
|
672
|
-
const key = `${edge.edge.target}:${approach.axis}:${approach.direction}`
|
|
673
|
-
const group = groups.get(key) ?? []
|
|
674
|
-
group.push({ edge, approach })
|
|
675
|
-
groups.set(key, group)
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
const pointsByEdgeId = new Map<string, ReadonlyArray<ChartPoint>>()
|
|
679
|
-
for (const group of groups.values()) {
|
|
680
|
-
if (group.length < 2) continue
|
|
681
|
-
group.sort((left, right) => {
|
|
682
|
-
const leftPosition = left.approach.axis === "vertical" ? left.approach.end.y : left.approach.end.x
|
|
683
|
-
const rightPosition = right.approach.axis === "vertical" ? right.approach.end.y : right.approach.end.x
|
|
684
|
-
return leftPosition - rightPosition || left.edge.edge.id.localeCompare(right.edge.edge.id)
|
|
685
|
-
})
|
|
686
|
-
group.forEach(({ approach, edge }, lane) => {
|
|
687
|
-
pointsByEdgeId.set(
|
|
688
|
-
edge.edge.id,
|
|
689
|
-
moveTerminalApproach(edge.points, approach, chartEdgeTerminalClearance + lane * chartTerminalLaneGap)
|
|
690
|
-
)
|
|
691
|
-
})
|
|
692
|
-
}
|
|
693
|
-
return transitions.map((edge) => {
|
|
694
|
-
const points = pointsByEdgeId.get(edge.edge.id)
|
|
695
|
-
return points === undefined ? edge : { ...edge, points }
|
|
696
|
-
})
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
interface ChartRect {
|
|
700
|
-
readonly left: number
|
|
701
|
-
readonly right: number
|
|
702
|
-
readonly top: number
|
|
703
|
-
readonly bottom: number
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
const labelRect = (
|
|
707
|
-
point: ChartPoint,
|
|
708
|
-
width: number,
|
|
709
|
-
height: number
|
|
710
|
-
): ChartRect => ({
|
|
711
|
-
left: point.x - width / 2,
|
|
712
|
-
right: point.x + width / 2,
|
|
713
|
-
top: point.y - height / 2,
|
|
714
|
-
bottom: point.y + height / 2
|
|
715
|
-
})
|
|
716
|
-
|
|
717
|
-
const overlaps = (left: ChartRect, right: ChartRect, gap: number): boolean =>
|
|
718
|
-
left.left < right.right + gap && left.right > right.left - gap &&
|
|
719
|
-
left.top < right.bottom + gap && left.bottom > right.top - gap
|
|
720
|
-
|
|
721
567
|
const collectLayout = (
|
|
722
568
|
model: ChartModel,
|
|
723
569
|
graph: ElkNode,
|
|
@@ -786,7 +632,7 @@ const collectLayout = (
|
|
|
786
632
|
|
|
787
633
|
const chartEdges = new Map(model.edges.map((edge) => [edge.id, edge]))
|
|
788
634
|
const initialEdges = new Map(model.initials.map((initial) => [initial.id, initial]))
|
|
789
|
-
const
|
|
635
|
+
const edges = (graph.edges ?? []).flatMap(
|
|
790
636
|
(edge): ReadonlyArray<LaidOutChartTransition | LaidOutChartInitialEdge> => {
|
|
791
637
|
const offset = offsets.get(edge.container ?? graph.id) ?? { x: 0, y: 0 }
|
|
792
638
|
const points = edgePoints(edge, offset)
|
|
@@ -795,15 +641,14 @@ const collectLayout = (
|
|
|
795
641
|
if (chartEdge !== undefined) {
|
|
796
642
|
const metric = labelMetric(chartEdge.label)
|
|
797
643
|
const label = edge.labels?.[0]
|
|
798
|
-
const transitionPoints = ensureChartEdgeTerminalClearance(points)
|
|
799
644
|
const labelWidth = label?.width ?? metric.width
|
|
800
645
|
const labelHeight = label?.height ?? metric.height
|
|
801
646
|
return [{
|
|
802
647
|
kind: "transition",
|
|
803
648
|
edge: chartEdge,
|
|
804
|
-
points
|
|
649
|
+
points,
|
|
805
650
|
label: label?.x === undefined || label.y === undefined
|
|
806
|
-
? midpoint(
|
|
651
|
+
? midpoint(points)
|
|
807
652
|
: add(offset, {
|
|
808
653
|
x: label.x + labelWidth / 2,
|
|
809
654
|
y: label.y + labelHeight / 2
|
|
@@ -816,39 +661,6 @@ const collectLayout = (
|
|
|
816
661
|
return initial === undefined ? [] : [{ kind: "initial", initial, points }]
|
|
817
662
|
}
|
|
818
663
|
)
|
|
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
|
-
)
|
|
838
|
-
)
|
|
839
|
-
) {
|
|
840
|
-
lane++
|
|
841
|
-
selfTransition = laidOutSelfTransition(edge, node, lane)
|
|
842
|
-
}
|
|
843
|
-
selfLoopLanes.set(edge.source, lane + 1)
|
|
844
|
-
occupiedLabels.push(labelRect(selfTransition.label, selfTransition.labelWidth, selfTransition.labelHeight))
|
|
845
|
-
return [selfTransition]
|
|
846
|
-
})
|
|
847
|
-
const edges: ReadonlyArray<LaidOutChartTransition | LaidOutChartInitialEdge> = [
|
|
848
|
-
...elkTransitions,
|
|
849
|
-
...elkEdges.filter((edge): edge is LaidOutChartInitialEdge => edge.kind === "initial"),
|
|
850
|
-
...selfEdges
|
|
851
|
-
]
|
|
852
664
|
|
|
853
665
|
const transitionEdges = edges.filter((edge) => edge.kind === "transition")
|
|
854
666
|
const contentWidth = Math.max(
|
|
@@ -879,10 +691,308 @@ const collectLayout = (
|
|
|
879
691
|
}
|
|
880
692
|
}
|
|
881
693
|
|
|
694
|
+
const labelRect = (point: ChartPoint, width: number, height: number): ChartRect => ({
|
|
695
|
+
left: point.x - width / 2,
|
|
696
|
+
right: point.x + width / 2,
|
|
697
|
+
top: point.y - height / 2,
|
|
698
|
+
bottom: point.y + height / 2
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
const nodeRect = (node: LaidOutChartNode): ChartRect => ({
|
|
702
|
+
left: node.x,
|
|
703
|
+
right: node.x + node.width,
|
|
704
|
+
top: node.y,
|
|
705
|
+
bottom: node.y + node.height
|
|
706
|
+
})
|
|
707
|
+
|
|
708
|
+
const nodeHeaderRect = (node: LaidOutChartNode): ChartRect => ({
|
|
709
|
+
left: node.x,
|
|
710
|
+
right: node.x + node.width,
|
|
711
|
+
top: node.y,
|
|
712
|
+
bottom: node.y + node.headerHeight
|
|
713
|
+
})
|
|
714
|
+
|
|
715
|
+
const pointRectDistance = (point: ChartPoint, rect: ChartRect): number =>
|
|
716
|
+
Math.hypot(
|
|
717
|
+
Math.max(rect.left - point.x, 0, point.x - rect.right),
|
|
718
|
+
Math.max(rect.top - point.y, 0, point.y - rect.bottom)
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
const overlaps = (left: ChartRect, right: ChartRect, gap = 0): boolean =>
|
|
722
|
+
left.left < right.right + gap && left.right > right.left - gap &&
|
|
723
|
+
left.top < right.bottom + gap && left.bottom > right.top - gap
|
|
724
|
+
|
|
725
|
+
const segmentCrossesInterior = (start: ChartPoint, end: ChartPoint, rect: ChartRect): boolean => {
|
|
726
|
+
const epsilon = 0.001
|
|
727
|
+
const left = rect.left + epsilon
|
|
728
|
+
const right = rect.right - epsilon
|
|
729
|
+
const top = rect.top + epsilon
|
|
730
|
+
const bottom = rect.bottom - epsilon
|
|
731
|
+
const deltaX = end.x - start.x
|
|
732
|
+
const deltaY = end.y - start.y
|
|
733
|
+
let minimum = 0
|
|
734
|
+
let maximum = 1
|
|
735
|
+
const clip = (direction: number, origin: number, low: number, high: number): boolean => {
|
|
736
|
+
if (direction === 0) return origin >= low && origin <= high
|
|
737
|
+
const first = (low - origin) / direction
|
|
738
|
+
const second = (high - origin) / direction
|
|
739
|
+
minimum = Math.max(minimum, Math.min(first, second))
|
|
740
|
+
maximum = Math.min(maximum, Math.max(first, second))
|
|
741
|
+
return minimum <= maximum
|
|
742
|
+
}
|
|
743
|
+
return clip(deltaX, start.x, left, right) && clip(deltaY, start.y, top, bottom) &&
|
|
744
|
+
maximum > 0 && minimum < 1
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const pointSegmentDistance = (point: ChartPoint, start: ChartPoint, end: ChartPoint): number => {
|
|
748
|
+
const deltaX = end.x - start.x
|
|
749
|
+
const deltaY = end.y - start.y
|
|
750
|
+
const squaredLength = deltaX * deltaX + deltaY * deltaY
|
|
751
|
+
if (squaredLength === 0) return Math.hypot(point.x - start.x, point.y - start.y)
|
|
752
|
+
const ratio = Math.min(
|
|
753
|
+
1,
|
|
754
|
+
Math.max(0, ((point.x - start.x) * deltaX + (point.y - start.y) * deltaY) / squaredLength)
|
|
755
|
+
)
|
|
756
|
+
return Math.hypot(point.x - (start.x + ratio * deltaX), point.y - (start.y + ratio * deltaY))
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const labelDistance = (transition: LaidOutChartTransition): number => {
|
|
760
|
+
let distance = Number.POSITIVE_INFINITY
|
|
761
|
+
for (let index = 1; index < transition.points.length; index++) {
|
|
762
|
+
distance = Math.min(
|
|
763
|
+
distance,
|
|
764
|
+
pointSegmentDistance(transition.label, transition.points[index - 1]!, transition.points[index]!)
|
|
765
|
+
)
|
|
766
|
+
}
|
|
767
|
+
return distance
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
export const chartRouteLength = (points: ReadonlyArray<ChartPoint>): number =>
|
|
771
|
+
points.slice(1).reduce((total, point, index) => {
|
|
772
|
+
const previous = points[index]!
|
|
773
|
+
return total + Math.abs(point.x - previous.x) + Math.abs(point.y - previous.y)
|
|
774
|
+
}, 0)
|
|
775
|
+
|
|
776
|
+
interface OrthogonalSegment {
|
|
777
|
+
readonly edgeId: string
|
|
778
|
+
readonly start: ChartPoint
|
|
779
|
+
readonly end: ChartPoint
|
|
780
|
+
readonly horizontal: boolean
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const segments = (layout: LaidOutChart): ReadonlyArray<OrthogonalSegment> =>
|
|
784
|
+
layout.edges.flatMap((edge): ReadonlyArray<OrthogonalSegment> => {
|
|
785
|
+
const edgeId = edge.kind === "transition" ? edge.edge.id : edge.initial.id
|
|
786
|
+
return edge.points.slice(1).map((end, index) => ({
|
|
787
|
+
edgeId,
|
|
788
|
+
start: edge.points[index]!,
|
|
789
|
+
end,
|
|
790
|
+
horizontal: edge.points[index]!.y === end.y
|
|
791
|
+
}))
|
|
792
|
+
})
|
|
793
|
+
|
|
794
|
+
const crossingCount = (allSegments: ReadonlyArray<OrthogonalSegment>): number => {
|
|
795
|
+
let crossings = 0
|
|
796
|
+
for (let leftIndex = 0; leftIndex < allSegments.length; leftIndex++) {
|
|
797
|
+
const left = allSegments[leftIndex]!
|
|
798
|
+
for (let rightIndex = leftIndex + 1; rightIndex < allSegments.length; rightIndex++) {
|
|
799
|
+
const right = allSegments[rightIndex]!
|
|
800
|
+
if (left.edgeId === right.edgeId || left.horizontal === right.horizontal) continue
|
|
801
|
+
const horizontal = left.horizontal ? left : right
|
|
802
|
+
const vertical = left.horizontal ? right : left
|
|
803
|
+
const horizontalLeft = Math.min(horizontal.start.x, horizontal.end.x)
|
|
804
|
+
const horizontalRight = Math.max(horizontal.start.x, horizontal.end.x)
|
|
805
|
+
const verticalTop = Math.min(vertical.start.y, vertical.end.y)
|
|
806
|
+
const verticalBottom = Math.max(vertical.start.y, vertical.end.y)
|
|
807
|
+
if (
|
|
808
|
+
vertical.start.x > horizontalLeft && vertical.start.x < horizontalRight &&
|
|
809
|
+
horizontal.start.y > verticalTop && horizontal.start.y < verticalBottom
|
|
810
|
+
) crossings++
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
return crossings
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const collinearOverlap = (left: OrthogonalSegment, right: OrthogonalSegment): number => {
|
|
817
|
+
if (left.horizontal !== right.horizontal) return 0
|
|
818
|
+
if (left.horizontal) {
|
|
819
|
+
if (left.start.y !== right.start.y) return 0
|
|
820
|
+
return Math.max(
|
|
821
|
+
0,
|
|
822
|
+
Math.min(Math.max(left.start.x, left.end.x), Math.max(right.start.x, right.end.x)) -
|
|
823
|
+
Math.max(Math.min(left.start.x, left.end.x), Math.min(right.start.x, right.end.x))
|
|
824
|
+
)
|
|
825
|
+
}
|
|
826
|
+
if (left.start.x !== right.start.x) return 0
|
|
827
|
+
return Math.max(
|
|
828
|
+
0,
|
|
829
|
+
Math.min(Math.max(left.start.y, left.end.y), Math.max(right.start.y, right.end.y)) -
|
|
830
|
+
Math.max(Math.min(left.start.y, left.end.y), Math.min(right.start.y, right.end.y))
|
|
831
|
+
)
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const transitionTouchesNode = (edge: ChartEdge, path: string): boolean => {
|
|
835
|
+
const target = edgeTargetPath(edge)
|
|
836
|
+
return edge.source === path || isDescendantPath(edge.source, path) ||
|
|
837
|
+
target === path || target !== null && isDescendantPath(target, path)
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const laidOutEdgeId = (edge: LaidOutChartTransition | LaidOutChartInitialEdge): string =>
|
|
841
|
+
edge.kind === "transition" ? edge.edge.id : edge.initial.id
|
|
842
|
+
|
|
843
|
+
const validationScore = (validation: ChartLayoutValidation): number =>
|
|
844
|
+
validation.issues.length * 1_000_000 + validation.crossings * 10_000 + validation.routeLength
|
|
845
|
+
|
|
846
|
+
export const validateChartLayout = (
|
|
847
|
+
model: ChartModel,
|
|
848
|
+
layout: LaidOutChart
|
|
849
|
+
): ChartLayoutValidation => {
|
|
850
|
+
const issues: Array<ChartLayoutIssue> = []
|
|
851
|
+
const report = (code: ChartLayoutIssueCode, edgeId: string, relatedId: string | null = null): void => {
|
|
852
|
+
if (issues.some((issue) => issue.code === code && issue.edgeId === edgeId && issue.relatedId === relatedId)) return
|
|
853
|
+
issues.push({ code, edgeId, relatedId })
|
|
854
|
+
}
|
|
855
|
+
const transitions = layout.edges.filter(
|
|
856
|
+
(edge): edge is LaidOutChartTransition => edge.kind === "transition"
|
|
857
|
+
)
|
|
858
|
+
const transitionById = new Map(transitions.map((edge) => [edge.edge.id, edge]))
|
|
859
|
+
for (const edge of model.edges) {
|
|
860
|
+
if (!transitionById.has(edge.id)) report("missing-edge", edge.id)
|
|
861
|
+
}
|
|
862
|
+
const initialEdges = layout.edges.filter(
|
|
863
|
+
(edge): edge is LaidOutChartInitialEdge => edge.kind === "initial"
|
|
864
|
+
)
|
|
865
|
+
const initialById = new Map(initialEdges.map((edge) => [edge.initial.id, edge]))
|
|
866
|
+
for (const initial of model.initials) {
|
|
867
|
+
if (!initialById.has(initial.id)) report("missing-edge", initial.id)
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
for (const transition of transitions) {
|
|
871
|
+
const transitionLabelDistance = labelDistance(transition)
|
|
872
|
+
if (transitionLabelDistance > Math.max(transition.labelWidth, transition.labelHeight) / 2 + 12) {
|
|
873
|
+
report("label-detached", transition.edge.id, `${Math.round(transitionLabelDistance)}px`)
|
|
874
|
+
}
|
|
875
|
+
const end = transition.points.at(-1)
|
|
876
|
+
const bend = transition.points.at(-2)
|
|
877
|
+
if (
|
|
878
|
+
end === undefined || bend === undefined ||
|
|
879
|
+
Math.abs(end.x - bend.x) + Math.abs(end.y - bend.y) < 9
|
|
880
|
+
) report("short-terminal", transition.edge.id)
|
|
881
|
+
|
|
882
|
+
const label = labelRect(transition.label, transition.labelWidth, transition.labelHeight)
|
|
883
|
+
for (const node of layout.nodes) {
|
|
884
|
+
const obstacle = node.node.children.length > 0 && transitionTouchesNode(transition.edge, node.node.path)
|
|
885
|
+
? nodeHeaderRect(node)
|
|
886
|
+
: nodeRect(node)
|
|
887
|
+
if (overlaps(label, obstacle, 2)) report("label-node-overlap", transition.edge.id, node.node.path)
|
|
888
|
+
if (
|
|
889
|
+
transition.points.slice(1).some((point, index) =>
|
|
890
|
+
segmentCrossesInterior(transition.points[index]!, point, obstacle)
|
|
891
|
+
)
|
|
892
|
+
) report("node-crossing", transition.edge.id, node.node.path)
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
if (isSelfTransition(transition.edge)) {
|
|
896
|
+
const source = model.nodes.find((node) => node.path === transition.edge.source)
|
|
897
|
+
const sourceLayout = layout.nodes.find((node) => node.node.path === transition.edge.source)
|
|
898
|
+
if (
|
|
899
|
+
sourceLayout !== undefined &&
|
|
900
|
+
Math.max(...transition.points.map((point) => pointRectDistance(point, nodeRect(sourceLayout)))) <
|
|
901
|
+
chartSelfLoopMinimumClearance
|
|
902
|
+
) report("self-loop-clearance", transition.edge.id, sourceLayout.node.path)
|
|
903
|
+
const parent = source?.parent === null
|
|
904
|
+
? undefined
|
|
905
|
+
: layout.nodes.find((node) => node.node.path === source?.parent)
|
|
906
|
+
if (parent !== undefined) {
|
|
907
|
+
const content = {
|
|
908
|
+
left: parent.x,
|
|
909
|
+
right: parent.x + parent.width,
|
|
910
|
+
top: parent.y + parent.headerHeight,
|
|
911
|
+
bottom: parent.y + parent.height
|
|
912
|
+
}
|
|
913
|
+
const outside = transition.points.some((point) =>
|
|
914
|
+
point.x < content.left || point.x > content.right ||
|
|
915
|
+
point.y < content.top || point.y > content.bottom
|
|
916
|
+
) || label.left < content.left || label.right > content.right ||
|
|
917
|
+
label.top < content.top || label.bottom > content.bottom
|
|
918
|
+
if (outside) report("self-loop-outside-parent", transition.edge.id, parent.node.path)
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
for (const initial of initialEdges) {
|
|
924
|
+
for (const node of layout.nodes) {
|
|
925
|
+
const containsTarget = node.node.path === initial.initial.target ||
|
|
926
|
+
isDescendantPath(initial.initial.target, node.node.path)
|
|
927
|
+
const obstacle = node.node.children.length > 0 && containsTarget
|
|
928
|
+
? nodeHeaderRect(node)
|
|
929
|
+
: nodeRect(node)
|
|
930
|
+
if (
|
|
931
|
+
initial.points.slice(1).some((point, index) => segmentCrossesInterior(initial.points[index]!, point, obstacle))
|
|
932
|
+
) report("node-crossing", initial.initial.id, node.node.path)
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
for (let left = 0; left < transitions.length; left++) {
|
|
937
|
+
const leftEdge = transitions[left]!
|
|
938
|
+
const leftRect = labelRect(leftEdge.label, leftEdge.labelWidth, leftEdge.labelHeight)
|
|
939
|
+
for (let right = left + 1; right < transitions.length; right++) {
|
|
940
|
+
const rightEdge = transitions[right]!
|
|
941
|
+
const rightRect = labelRect(rightEdge.label, rightEdge.labelWidth, rightEdge.labelHeight)
|
|
942
|
+
if (overlaps(leftRect, rightRect, 2)) {
|
|
943
|
+
report("label-label-overlap", leftEdge.edge.id, rightEdge.edge.id)
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
for (const other of layout.edges) {
|
|
947
|
+
if (laidOutEdgeId(other) === leftEdge.edge.id) continue
|
|
948
|
+
if (
|
|
949
|
+
other.points.slice(1).some((point, index) => segmentCrossesInterior(other.points[index]!, point, leftRect))
|
|
950
|
+
) report("label-route-overlap", leftEdge.edge.id, laidOutEdgeId(other))
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const allSegments = segments(layout)
|
|
955
|
+
for (let left = 0; left < allSegments.length; left++) {
|
|
956
|
+
for (let right = left + 1; right < allSegments.length; right++) {
|
|
957
|
+
const first = allSegments[left]!
|
|
958
|
+
const second = allSegments[right]!
|
|
959
|
+
if (first.edgeId === second.edgeId) continue
|
|
960
|
+
if (collinearOverlap(first, second) > 4) report("route-overlap", first.edgeId, second.edgeId)
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
return {
|
|
965
|
+
valid: issues.length === 0,
|
|
966
|
+
issues,
|
|
967
|
+
crossings: crossingCount(allSegments),
|
|
968
|
+
routeLength: layout.edges.reduce((sum, edge) => sum + chartRouteLength(edge.points), 0)
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
882
972
|
type ChartLayoutEngine = (graph: ElkNode, portConstraints: PortConstraints) => Promise<ElkNode>
|
|
883
973
|
|
|
974
|
+
interface LayoutAttemptFailure {
|
|
975
|
+
readonly profile: string
|
|
976
|
+
readonly cause: unknown
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
interface InvalidLayoutCandidate {
|
|
980
|
+
readonly profile: string
|
|
981
|
+
readonly validation: ChartLayoutValidation
|
|
982
|
+
}
|
|
983
|
+
|
|
884
984
|
const causeMessage = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause)
|
|
885
985
|
|
|
986
|
+
const issueSummary = (validation: ChartLayoutValidation): string => {
|
|
987
|
+
const counts = new Map<ChartLayoutIssueCode, number>()
|
|
988
|
+
for (const issue of validation.issues) counts.set(issue.code, (counts.get(issue.code) ?? 0) + 1)
|
|
989
|
+
const summary = [...counts].map(([code, count]) => `${code} (${count})`).join(", ")
|
|
990
|
+
const examples = validation.issues.slice(0, 3).map(({ code, edgeId, relatedId }) =>
|
|
991
|
+
`${code}:${edgeId}${relatedId === null ? "" : `:${relatedId}`}`
|
|
992
|
+
).join(", ")
|
|
993
|
+
return examples.length === 0 ? summary : `${summary}; ${examples}`
|
|
994
|
+
}
|
|
995
|
+
|
|
886
996
|
export const layoutChartWith = (
|
|
887
997
|
model: ChartModel,
|
|
888
998
|
layout: ChartLayoutEngine
|
|
@@ -890,28 +1000,48 @@ export const layoutChartWith = (
|
|
|
890
1000
|
Effect.suspend(() => {
|
|
891
1001
|
const policy = makeChartLayoutPolicy(model)
|
|
892
1002
|
const regions = unconnectedRegions(model, policy)
|
|
893
|
-
const
|
|
894
|
-
|
|
895
|
-
try: () => layout(makeGraph(model, policy, regions, portConstraints), portConstraints),
|
|
896
|
-
catch: (cause) => cause
|
|
897
|
-
})
|
|
1003
|
+
const failures: Array<LayoutAttemptFailure> = []
|
|
1004
|
+
const invalid: Array<InvalidLayoutCandidate> = []
|
|
898
1005
|
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
1006
|
+
const attempt = (index: number): Effect.Effect<LaidOutChart, ChartLayoutError> => {
|
|
1007
|
+
const profile = layoutProfiles[index]
|
|
1008
|
+
if (profile === undefined) {
|
|
1009
|
+
const best = [...invalid].sort((left, right) =>
|
|
1010
|
+
validationScore(left.validation) - validationScore(right.validation)
|
|
1011
|
+
)[0]
|
|
1012
|
+
const detail = best === undefined
|
|
1013
|
+
? failures.map(({ cause, profile }) => `${profile}: ${causeMessage(cause)}`).join("; ")
|
|
1014
|
+
: `${best.profile}: ${issueSummary(best.validation)}`
|
|
1015
|
+
return Effect.fail(
|
|
1016
|
+
new ChartLayoutError({
|
|
1017
|
+
cause: { failures, invalid },
|
|
1018
|
+
message:
|
|
1019
|
+
`ELK did not produce a safe layout for ${model.machineId} after ${layoutProfiles.length} deterministic attempts: ${detail}`
|
|
1020
|
+
})
|
|
1021
|
+
)
|
|
1022
|
+
}
|
|
1023
|
+
return Effect.matchEffect(
|
|
1024
|
+
Effect.tryPromise({
|
|
1025
|
+
try: () => layout(makeGraph(model, policy, regions, profile), profile.portConstraints),
|
|
1026
|
+
catch: (cause) => cause
|
|
1027
|
+
}),
|
|
1028
|
+
{
|
|
1029
|
+
onFailure: (cause) => {
|
|
1030
|
+
failures.push({ profile: profile.id, cause })
|
|
1031
|
+
return attempt(index + 1)
|
|
1032
|
+
},
|
|
1033
|
+
onSuccess: (graph) => {
|
|
1034
|
+
const candidate = collectLayout(model, graph, regions)
|
|
1035
|
+
const validation = validateChartLayout(model, candidate)
|
|
1036
|
+
if (validation.valid) return Effect.succeed(candidate)
|
|
1037
|
+
invalid.push({ profile: profile.id, validation })
|
|
1038
|
+
return attempt(index + 1)
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
)
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
return attempt(0)
|
|
915
1045
|
})
|
|
916
1046
|
|
|
917
1047
|
export const layoutChart = (model: ChartModel): Effect.Effect<LaidOutChart, ChartLayoutError> =>
|