@typeonce/effect-machine-devtools 0.27.0 → 0.28.0

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