@typeonce/effect-machine-devtools 0.27.1 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,9 +12,9 @@ import ELKBundle from "elkjs/lib/elk.bundled.js"
12
12
  import { type ChartPortSide, makeChartLayoutPolicy } from "./chart-layout-policy.js"
13
13
  import type { ChartEdge, ChartInitial, ChartModel, ChartNode, ChartRuntimeTarget } from "./chart-model.js"
14
14
 
15
- export const maxVisibleFields = 4
16
15
  export const maxVisibleActivities = 3
17
16
  export const chartSelfLoopMinimumClearance = 24
17
+ export const chartEdgeLabelSpacing = 5
18
18
 
19
19
  export interface ChartPoint {
20
20
  readonly x: number
@@ -82,6 +82,8 @@ export interface LaidOutChart {
82
82
  }
83
83
 
84
84
  export type ChartLayoutIssueCode =
85
+ | "detached-source"
86
+ | "detached-terminal"
85
87
  | "missing-edge"
86
88
  | "label-detached"
87
89
  | "label-label-overlap"
@@ -91,7 +93,10 @@ export type ChartLayoutIssueCode =
91
93
  | "route-overlap"
92
94
  | "self-loop-clearance"
93
95
  | "self-loop-outside-parent"
96
+ | "short-source"
94
97
  | "short-terminal"
98
+ | "wrong-source-direction"
99
+ | "wrong-terminal-direction"
95
100
 
96
101
  export interface ChartLayoutIssue {
97
102
  readonly code: ChartLayoutIssueCode
@@ -149,82 +154,98 @@ const layoutProfiles: ReadonlyArray<LayoutProfile> = [
149
154
  {
150
155
  id: "compact-fixed",
151
156
  portConstraints: "fixed",
152
- nodeSpacing: 64,
153
- layerSpacing: 148,
154
- edgeNodeSpacing: 42,
155
- edgeEdgeSpacing: 26,
156
- compoundNodeSpacing: 44,
157
- compoundLayerSpacing: 108,
158
- selfLoopSpacing: 32,
159
- padding: 44
157
+ nodeSpacing: 48,
158
+ layerSpacing: 72,
159
+ edgeNodeSpacing: 28,
160
+ edgeEdgeSpacing: 18,
161
+ compoundNodeSpacing: 32,
162
+ compoundLayerSpacing: 60,
163
+ selfLoopSpacing: 40,
164
+ padding: 36
160
165
  },
161
166
  {
162
167
  id: "spacious-fixed",
163
168
  portConstraints: "fixed",
164
- nodeSpacing: 88,
165
- layerSpacing: 188,
166
- edgeNodeSpacing: 58,
167
- edgeEdgeSpacing: 38,
168
- compoundNodeSpacing: 64,
169
- compoundLayerSpacing: 144,
170
- selfLoopSpacing: 40,
171
- padding: 56
169
+ nodeSpacing: 68,
170
+ layerSpacing: 96,
171
+ edgeNodeSpacing: 42,
172
+ edgeEdgeSpacing: 28,
173
+ compoundNodeSpacing: 48,
174
+ compoundLayerSpacing: 80,
175
+ selfLoopSpacing: 48,
176
+ padding: 48
172
177
  },
173
178
  {
174
179
  id: "roomy-fixed",
175
180
  portConstraints: "fixed",
176
- nodeSpacing: 112,
177
- layerSpacing: 232,
178
- edgeNodeSpacing: 76,
179
- edgeEdgeSpacing: 52,
180
- compoundNodeSpacing: 82,
181
- compoundLayerSpacing: 180,
182
- selfLoopSpacing: 48,
183
- padding: 68
181
+ nodeSpacing: 88,
182
+ layerSpacing: 124,
183
+ edgeNodeSpacing: 58,
184
+ edgeEdgeSpacing: 40,
185
+ compoundNodeSpacing: 64,
186
+ compoundLayerSpacing: 104,
187
+ selfLoopSpacing: 56,
188
+ padding: 60
184
189
  },
185
190
  {
186
191
  id: "spacious-relaxed",
187
192
  portConstraints: "relaxed",
188
- nodeSpacing: 88,
189
- layerSpacing: 188,
190
- edgeNodeSpacing: 58,
191
- edgeEdgeSpacing: 38,
192
- compoundNodeSpacing: 64,
193
- compoundLayerSpacing: 144,
194
- selfLoopSpacing: 40,
195
- padding: 56
193
+ nodeSpacing: 68,
194
+ layerSpacing: 96,
195
+ edgeNodeSpacing: 42,
196
+ edgeEdgeSpacing: 28,
197
+ compoundNodeSpacing: 48,
198
+ compoundLayerSpacing: 80,
199
+ selfLoopSpacing: 48,
200
+ padding: 48
196
201
  },
197
202
  {
198
203
  id: "roomy-relaxed",
199
204
  portConstraints: "relaxed",
200
- nodeSpacing: 112,
201
- layerSpacing: 232,
202
- edgeNodeSpacing: 76,
203
- edgeEdgeSpacing: 52,
204
- compoundNodeSpacing: 82,
205
- compoundLayerSpacing: 180,
206
- selfLoopSpacing: 48,
207
- padding: 68
205
+ nodeSpacing: 88,
206
+ layerSpacing: 124,
207
+ edgeNodeSpacing: 58,
208
+ edgeEdgeSpacing: 40,
209
+ compoundNodeSpacing: 64,
210
+ compoundLayerSpacing: 104,
211
+ selfLoopSpacing: 56,
212
+ padding: 60
208
213
  }
209
214
  ]
210
215
 
211
- const sectionHeight = (length: number, limit: number): number => {
216
+ const activitySectionHeight = (length: number): number => {
212
217
  if (length === 0) return 0
213
- const visible = Math.min(length, limit)
214
- return 24 + visible * 22 + (length > limit ? 18 : 0)
218
+ const visible = Math.min(length, maxVisibleActivities)
219
+ return 16 + visible * 20 + (length > maxVisibleActivities ? 16 : 0)
215
220
  }
216
221
 
217
- const nodeMetric = (node: ChartNode): NodeMetric => {
218
- const headerHeight = Math.max(
219
- 78,
220
- 76 +
221
- sectionHeight(node.fields.length, maxVisibleFields) +
222
- 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
223
235
  )
224
- 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))
225
244
  return {
226
245
  width,
227
- 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),
228
249
  headerHeight
229
250
  }
230
251
  }
@@ -234,14 +255,20 @@ const elk = new ELK()
234
255
 
235
256
  const sourcePortId = (edge: ChartEdge): string => `port:${edge.id}:source`
236
257
  const targetPortId = (edge: ChartEdge): string => `port:${edge.id}:target`
237
- const initialNodeId = (initial: ChartInitial): string => `node:${initial.id}`
238
- const initialTargetPortId = (initial: ChartInitial): string => `port:${initial.id}:target`
239
258
  const runtimeNodeId = (target: ChartRuntimeTarget): string => `node:${target.id}`
240
259
  const runtimeTargetPortId = (target: ChartRuntimeTarget): string => `port:${target.edgeId}:target`
241
260
  const unconnectedRegionId = (parent: string | null): string => `region:unconnected:${parent ?? "root"}`
242
261
  const isSelfTransition = (edge: ChartEdge): boolean => edge.kind === "targetless" || edge.target === edge.source
243
262
  const isDescendantPath = (path: string, ancestor: string): boolean => path.startsWith(`${ancestor}.`)
244
263
 
264
+ const selfLoopsBySource = (edges: ReadonlyArray<ChartEdge>): ReadonlyMap<string, number> => {
265
+ const counts = new Map<string, number>()
266
+ for (const edge of edges) {
267
+ if (isSelfTransition(edge)) counts.set(edge.source, (counts.get(edge.source) ?? 0) + 1)
268
+ }
269
+ return counts
270
+ }
271
+
245
272
  const unconnectedRegions = (
246
273
  model: ChartModel,
247
274
  policy: ReturnType<typeof makeChartLayoutPolicy>
@@ -306,12 +333,7 @@ const makeGraph = (
306
333
  const regionsByParent = new Map(regions.map((region) => [region.parent, region]))
307
334
  const sourceByEdgeId = new Map(model.edges.map((edge) => [edge.id, edge.source]))
308
335
  const runtimeByEdgeId = new Map(model.runtimeTargets.map((target) => [target.edgeId, target]))
309
- const initialsByParent = new Map<string | null, Array<ChartInitial>>()
310
- for (const initial of model.initials) {
311
- const siblings = initialsByParent.get(initial.parent) ?? []
312
- siblings.push(initial)
313
- initialsByParent.set(initial.parent, siblings)
314
- }
336
+ const selfLoops = selfLoopsBySource(model.edges)
315
337
  const runtimeTargetsByParent = new Map<string | null, Array<ChartRuntimeTarget>>()
316
338
  for (const target of model.runtimeTargets) {
317
339
  const siblings = runtimeTargetsByParent.get(target.parent) ?? []
@@ -319,25 +341,6 @@ const makeGraph = (
319
341
  runtimeTargetsByParent.set(target.parent, siblings)
320
342
  }
321
343
  const ports = portsByState(model.edges, policy.edge, profile.portConstraints)
322
- for (const initial of model.initials) {
323
- const statePorts = ports.get(initial.target) ?? []
324
- statePorts.push({
325
- id: initialTargetPortId(initial),
326
- width: 6,
327
- height: 6,
328
- ...(profile.portConstraints === "fixed"
329
- ? { layoutOptions: { "elk.port.side": "WEST" } }
330
- : {})
331
- })
332
- ports.set(initial.target, statePorts)
333
- }
334
-
335
- const initialNode = (initial: ChartInitial): ElkNode => ({
336
- id: initialNodeId(initial),
337
- width: 14,
338
- height: 14,
339
- layoutOptions: { "elk.layered.layering.layerConstraint": "FIRST" }
340
- })
341
344
  const runtimeNode = (target: ChartRuntimeTarget): ElkNode => ({
342
345
  id: runtimeNodeId(target),
343
346
  width: 118,
@@ -347,7 +350,7 @@ const makeGraph = (
347
350
  width: 6,
348
351
  height: 6,
349
352
  ...(profile.portConstraints === "fixed"
350
- ? { layoutOptions: { "elk.port.side": "WEST" } }
353
+ ? { layoutOptions: { "elk.port.side": "NORTH" } }
351
354
  : {})
352
355
  }],
353
356
  ...(profile.portConstraints === "fixed"
@@ -356,14 +359,16 @@ const makeGraph = (
356
359
  })
357
360
 
358
361
  const stateNode = (node: ChartNode, suppressUnconnectedRegion: boolean): ElkNode => {
359
- const metric = nodeMetric(node)
362
+ const metric = nodeMetric(node, selfLoops.get(node.path) ?? 0)
360
363
  const nodePolicy = policy.node(node.path)
361
364
  const descendants = children(node.path, suppressUnconnectedRegion || !nodePolicy.staticPath)
362
365
  const common = {
363
366
  id: node.path,
364
367
  ports: [...ports.get(node.path) ?? []],
365
368
  layoutOptions: {
366
- ...(profile.portConstraints === "fixed" ? { "elk.portConstraints": "FIXED_SIDE" } : {}),
369
+ ...(profile.portConstraints === "fixed" && node.children.length === 0
370
+ ? { "elk.portConstraints": "FIXED_SIDE" }
371
+ : {}),
367
372
  "elk.spacing.portPort": "24",
368
373
  ...(nodePolicy.layerConstraint === null
369
374
  ? {}
@@ -379,16 +384,15 @@ const makeGraph = (
379
384
  layoutOptions: {
380
385
  ...common.layoutOptions,
381
386
  "elk.algorithm": "layered",
382
- "elk.direction": node.type === "parallel" ? "DOWN" : "RIGHT",
387
+ "elk.direction": node.type === "parallel" ? "RIGHT" : "DOWN",
383
388
  "elk.padding": `[top=${metric.headerHeight + 36},left=36,bottom=36,right=36]`,
384
389
  "elk.nodeSize.constraints": "MINIMUM_SIZE",
385
390
  "elk.nodeSize.minimum": `(${metric.width}, ${metric.height})`,
386
391
  "elk.spacing.nodeNode": String(profile.compoundNodeSpacing),
387
- "elk.layered.spacing.nodeNodeBetweenLayers": String(
388
- node.type === "parallel" ? profile.compoundNodeSpacing + 24 : profile.compoundLayerSpacing
389
- ),
392
+ "elk.layered.spacing.nodeNodeBetweenLayers": String(profile.compoundLayerSpacing),
390
393
  "elk.spacing.edgeNode": String(profile.edgeNodeSpacing),
391
394
  "elk.spacing.edgeEdge": String(profile.edgeEdgeSpacing),
395
+ "elk.spacing.edgeLabel": String(chartEdgeLabelSpacing),
392
396
  "elk.spacing.nodeSelfLoop": String(profile.selfLoopSpacing),
393
397
  "elk.layered.spacing.edgeNodeBetweenLayers": String(profile.edgeNodeSpacing),
394
398
  "elk.layered.spacing.edgeEdgeBetweenLayers": String(profile.edgeEdgeSpacing)
@@ -399,11 +403,9 @@ const makeGraph = (
399
403
  function children(parent: string | null, suppressUnconnectedRegion = false): Array<ElkNode> {
400
404
  const region = suppressUnconnectedRegion ? undefined : regionsByParent.get(parent)
401
405
  const regionPaths = new Set(region?.nodePaths ?? [])
402
- const initials = initialsByParent.get(parent) ?? []
403
406
  const runtimeTargets = runtimeTargetsByParent.get(parent) ?? []
404
407
  const states = policy.children(parent)
405
408
  const regular: Array<ElkNode> = [
406
- ...initials.filter(({ target }) => !regionPaths.has(target)).map(initialNode),
407
409
  ...states.filter(({ path }) => !regionPaths.has(path)).map((node) => stateNode(node, suppressUnconnectedRegion)),
408
410
  ...runtimeTargets
409
411
  .filter(({ edgeId }) => !regionPaths.has(sourceByEdgeId.get(edgeId) ?? ""))
@@ -412,7 +414,6 @@ const makeGraph = (
412
414
  if (region === undefined) return regular
413
415
 
414
416
  const regionChildren: Array<ElkNode> = [
415
- ...initials.filter(({ target }) => regionPaths.has(target)).map(initialNode),
416
417
  ...states.filter(({ path }) => regionPaths.has(path)).map((node) => stateNode(node, true)),
417
418
  ...runtimeTargets
418
419
  .filter(({ edgeId }) => regionPaths.has(sourceByEdgeId.get(edgeId) ?? ""))
@@ -423,13 +424,14 @@ const makeGraph = (
423
424
  children: regionChildren,
424
425
  layoutOptions: {
425
426
  "elk.algorithm": "layered",
426
- "elk.direction": "RIGHT",
427
+ "elk.direction": "DOWN",
427
428
  "elk.padding": "[top=54,left=24,bottom=24,right=24]",
428
429
  "elk.layered.layering.layerConstraint": "LAST",
429
430
  "elk.spacing.nodeNode": String(profile.nodeSpacing),
430
431
  "elk.layered.spacing.nodeNodeBetweenLayers": String(profile.layerSpacing),
431
432
  "elk.spacing.edgeNode": String(profile.edgeNodeSpacing),
432
433
  "elk.spacing.edgeEdge": String(profile.edgeEdgeSpacing),
434
+ "elk.spacing.edgeLabel": String(chartEdgeLabelSpacing),
433
435
  "elk.spacing.nodeSelfLoop": String(profile.selfLoopSpacing)
434
436
  }
435
437
  })
@@ -455,21 +457,11 @@ const makeGraph = (
455
457
  "elk.layered.priority.straightness": "5"
456
458
  }
457
459
  }
458
- }),
459
- ...model.initials.map((initial): ElkExtendedEdge => ({
460
- id: initial.id,
461
- sources: [initialNodeId(initial)],
462
- targets: [initialTargetPortId(initial)],
463
- layoutOptions: {
464
- "elk.layered.priority.direction": "100",
465
- "elk.layered.priority.shortness": "100",
466
- "elk.layered.priority.straightness": "100"
467
- }
468
- }))
460
+ })
469
461
  ],
470
462
  layoutOptions: {
471
463
  "elk.algorithm": "layered",
472
- "elk.direction": "RIGHT",
464
+ "elk.direction": "DOWN",
473
465
  "elk.hierarchyHandling": "INCLUDE_CHILDREN",
474
466
  "elk.edgeRouting": "ORTHOGONAL",
475
467
  "elk.padding":
@@ -480,6 +472,7 @@ const makeGraph = (
480
472
  "elk.layered.spacing.edgeEdgeBetweenLayers": String(profile.edgeEdgeSpacing),
481
473
  "elk.spacing.edgeNode": String(profile.edgeNodeSpacing),
482
474
  "elk.spacing.edgeEdge": String(profile.edgeEdgeSpacing),
475
+ "elk.spacing.edgeLabel": String(chartEdgeLabelSpacing),
483
476
  "elk.spacing.nodeSelfLoop": String(profile.selfLoopSpacing),
484
477
  "elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES",
485
478
  "elk.layered.considerModelOrder.portModelOrder": "false",
@@ -564,13 +557,430 @@ const midpoint = (points: ReadonlyArray<ChartPoint>): ChartPoint => {
564
557
  return points.at(-1)!
565
558
  }
566
559
 
560
+ const horizontalBoundaryIntersection = (
561
+ start: ChartPoint,
562
+ end: ChartPoint,
563
+ y: number,
564
+ left: number,
565
+ right: number
566
+ ): ChartPoint | null => {
567
+ if (start.x === end.x) {
568
+ if (start.x < left || start.x > right || (start.y - y) * (end.y - y) > 0) return null
569
+ return { x: start.x, y }
570
+ }
571
+ if (start.y !== y || end.y !== y) return null
572
+ const minimum = Math.max(left, Math.min(start.x, end.x))
573
+ const maximum = Math.min(right, Math.max(start.x, end.x))
574
+ if (minimum > maximum) return null
575
+ return { x: start.x <= end.x ? minimum : maximum, y }
576
+ }
577
+
578
+ const trimRouteFromCompoundHeader = (
579
+ points: ReadonlyArray<ChartPoint>,
580
+ node: LaidOutChartNode
581
+ ): ReadonlyArray<ChartPoint> => {
582
+ const boundary = node.y + node.headerHeight
583
+ for (let index = 1; index < points.length; index++) {
584
+ const intersection = horizontalBoundaryIntersection(
585
+ points[index - 1]!,
586
+ points[index]!,
587
+ boundary,
588
+ node.x,
589
+ node.x + node.width
590
+ )
591
+ if (intersection !== null) return compactPoints([intersection, ...points.slice(index)])
592
+ }
593
+ return points
594
+ }
595
+
596
+ const normalizeHierarchyRoute = (
597
+ edge: ChartEdge,
598
+ points: ReadonlyArray<ChartPoint>,
599
+ nodes: ReadonlyMap<string, LaidOutChartNode>
600
+ ): ReadonlyArray<ChartPoint> => {
601
+ if (edge.target !== null && isDescendantPath(edge.target, edge.source)) {
602
+ const source = nodes.get(edge.source)
603
+ return source === undefined ? points : trimRouteFromCompoundHeader(points, source)
604
+ }
605
+ if (edge.target !== null && isDescendantPath(edge.source, edge.target)) {
606
+ const target = nodes.get(edge.target)
607
+ return target === undefined
608
+ ? points
609
+ : [...trimRouteFromCompoundHeader([...points].reverse(), target)].reverse()
610
+ }
611
+ return points
612
+ }
613
+
614
+ type RouteSide = "NORTH" | "EAST" | "SOUTH" | "WEST"
615
+
616
+ const routeNodeRect = (node: LaidOutChartNode): ChartRect =>
617
+ node.node.children.length > 0 ? nodeHeaderRect(node) : nodeRect(node)
618
+
619
+ const expandRect = (rect: ChartRect, amount: number): ChartRect => ({
620
+ left: rect.left - amount,
621
+ right: rect.right + amount,
622
+ top: rect.top - amount,
623
+ bottom: rect.bottom + amount
624
+ })
625
+
626
+ const endpointSide = (point: ChartPoint, node: LaidOutChartNode): RouteSide => {
627
+ const rect = routeNodeRect(node)
628
+ const distances: Array<readonly [RouteSide, number]> = [
629
+ ["NORTH", Math.abs(point.y - rect.top)],
630
+ ["SOUTH", Math.abs(point.y - rect.bottom)],
631
+ ["WEST", Math.abs(point.x - rect.left)],
632
+ ["EAST", Math.abs(point.x - rect.right)]
633
+ ]
634
+ return distances.sort((left, right) => left[1] - right[1])[0]![0]
635
+ }
636
+
637
+ const outwardPoint = (point: ChartPoint, side: RouteSide, distance: number): ChartPoint => {
638
+ switch (side) {
639
+ case "NORTH":
640
+ return { x: point.x, y: point.y - distance }
641
+ case "SOUTH":
642
+ return { x: point.x, y: point.y + distance }
643
+ case "WEST":
644
+ return { x: point.x - distance, y: point.y }
645
+ case "EAST":
646
+ return { x: point.x + distance, y: point.y }
647
+ }
648
+ }
649
+
650
+ const isOutwardStep = (boundary: ChartPoint, adjacent: ChartPoint, side: RouteSide): boolean =>
651
+ side === "NORTH"
652
+ ? adjacent.x === boundary.x && adjacent.y < boundary.y
653
+ : side === "SOUTH"
654
+ ? adjacent.x === boundary.x && adjacent.y > boundary.y
655
+ : side === "WEST"
656
+ ? adjacent.y === boundary.y && adjacent.x < boundary.x
657
+ : adjacent.y === boundary.y && adjacent.x > boundary.x
658
+
659
+ const pointOnNodeBoundary = (
660
+ point: ChartPoint,
661
+ node: LaidOutChartNode,
662
+ side: RouteSide
663
+ ): ChartPoint => {
664
+ const rect = routeNodeRect(node)
665
+ switch (side) {
666
+ case "NORTH":
667
+ return { x: Math.min(rect.right, Math.max(rect.left, point.x)), y: rect.top }
668
+ case "SOUTH":
669
+ return { x: Math.min(rect.right, Math.max(rect.left, point.x)), y: rect.bottom }
670
+ case "WEST":
671
+ return { x: rect.left, y: Math.min(rect.bottom, Math.max(rect.top, point.y)) }
672
+ case "EAST":
673
+ return { x: rect.right, y: Math.min(rect.bottom, Math.max(rect.top, point.y)) }
674
+ }
675
+ }
676
+
677
+ const nodeBoundaryDistance = (point: ChartPoint, node: LaidOutChartNode): number => {
678
+ const boundary = pointOnNodeBoundary(point, node, endpointSide(point, node))
679
+ return Math.abs(point.x - boundary.x) + Math.abs(point.y - boundary.y)
680
+ }
681
+
682
+ const orthogonalConnections = (
683
+ start: ChartPoint,
684
+ end: ChartPoint
685
+ ): ReadonlyArray<ReadonlyArray<ChartPoint>> => {
686
+ if (start.x === end.x || start.y === end.y) return [[start, end]]
687
+ return [
688
+ [start, { x: end.x, y: start.y }, end],
689
+ [start, { x: start.x, y: end.y }, end]
690
+ ]
691
+ }
692
+
693
+ const endpointConnections = (
694
+ start: ChartPoint,
695
+ end: ChartPoint,
696
+ target: ChartRect,
697
+ side: RouteSide
698
+ ): ReadonlyArray<ReadonlyArray<ChartPoint>> => {
699
+ const clearance = 12
700
+ const detours = side === "EAST" || side === "WEST"
701
+ ? [target.top - clearance, target.bottom + clearance].map((y) =>
702
+ compactPoints([start, { x: start.x, y }, { x: end.x, y }, end])
703
+ )
704
+ : [target.left - clearance, target.right + clearance].map((x) =>
705
+ compactPoints([start, { x, y: start.y }, { x, y: end.y }, end])
706
+ )
707
+ return [...orthogonalConnections(start, end), ...detours]
708
+ }
709
+
710
+ const routeObstacles = (
711
+ edge: ChartEdge,
712
+ nodes: ReadonlyArray<LaidOutChartNode>
713
+ ): ReadonlyArray<ChartRect> =>
714
+ nodes.flatMap((node) =>
715
+ node.node.path === edge.source || node.node.path === edge.target
716
+ ? []
717
+ : [expandRect(routeNodeRect(node), 6)]
718
+ )
719
+
720
+ const routeIsClear = (
721
+ points: ReadonlyArray<ChartPoint>,
722
+ obstacles: ReadonlyArray<ChartRect>
723
+ ): boolean =>
724
+ points.slice(1).every((point, index) => {
725
+ const previous = points[index]!
726
+ return (previous.x === point.x || previous.y === point.y) &&
727
+ obstacles.every((obstacle) => !segmentCrossesInterior(previous, point, obstacle))
728
+ })
729
+
730
+ const sameSideRoute = (
731
+ points: ReadonlyArray<ChartPoint>,
732
+ side: RouteSide,
733
+ lane: number
734
+ ): ReadonlyArray<ChartPoint> => {
735
+ const start = points[0]!
736
+ const end = points.at(-1)!
737
+ const offset = 24 + lane * 8
738
+ const coordinate = side === "NORTH"
739
+ ? { axis: "y" as const, value: Math.min(start.y, end.y) - offset }
740
+ : side === "SOUTH"
741
+ ? { axis: "y" as const, value: Math.max(start.y, end.y) + offset }
742
+ : side === "WEST"
743
+ ? { axis: "x" as const, value: Math.min(start.x, end.x) - offset }
744
+ : { axis: "x" as const, value: Math.max(start.x, end.x) + offset }
745
+ return coordinate.axis === "x"
746
+ ? compactPoints([start, { x: coordinate.value, y: start.y }, { x: coordinate.value, y: end.y }, end])
747
+ : compactPoints([start, { x: start.x, y: coordinate.value }, { x: end.x, y: coordinate.value }, end])
748
+ }
749
+
750
+ const shortenTransitionRoute = (
751
+ edge: ChartEdge,
752
+ points: ReadonlyArray<ChartPoint>,
753
+ nodes: ReadonlyMap<string, LaidOutChartNode>,
754
+ allNodes: ReadonlyArray<LaidOutChartNode>,
755
+ lanes: Map<string, number>
756
+ ): ReadonlyArray<ChartPoint> => {
757
+ if (edge.target === null || isSelfTransition(edge)) return points
758
+ const source = nodes.get(edge.source)
759
+ const target = nodes.get(edge.target)
760
+ if (source === undefined || target === undefined) return points
761
+ const sourceSide = endpointSide(points[0]!, source)
762
+ const targetSide = endpointSide(points.at(-1)!, target)
763
+ if (sourceSide !== targetSide) return points
764
+ const obstacles = routeObstacles(edge, allNodes)
765
+ const currentLength = chartRouteLength(points)
766
+ const key = `${edge.target}:${targetSide}`
767
+ const lane = lanes.get(key) ?? 0
768
+ const candidate = sameSideRoute(points, sourceSide, lane)
769
+ if (!routeIsClear(candidate, obstacles) || chartRouteLength(candidate) + 16 >= currentLength) return points
770
+ lanes.set(key, lane + 1)
771
+ return candidate
772
+ }
773
+
774
+ const normalizeTerminalDirection = (
775
+ edge: ChartEdge,
776
+ points: ReadonlyArray<ChartPoint>,
777
+ nodes: ReadonlyMap<string, LaidOutChartNode>,
778
+ allNodes: ReadonlyArray<LaidOutChartNode>
779
+ ): ReadonlyArray<ChartPoint> => {
780
+ if (edge.target === null || isSelfTransition(edge) || points.length < 2) return points
781
+ const target = nodes.get(edge.target)
782
+ if (target === undefined) return points
783
+ const rawEnd = points.at(-1)!
784
+ const side = endpointSide(rawEnd, target)
785
+ const end = pointOnNodeBoundary(rawEnd, target, side)
786
+ const prefix = points.slice(0, -1)
787
+ const previous = prefix.at(-1)!
788
+ const attached = compactPoints([...prefix, end])
789
+ if (isOutwardStep(end, previous, side)) return attached
790
+
791
+ const targetStub = outwardPoint(end, side, 18)
792
+ const obstacles = [...routeObstacles(edge, allNodes), routeNodeRect(target)]
793
+ return endpointConnections(previous, targetStub, routeNodeRect(target), side)
794
+ .filter((connection) =>
795
+ !connection.slice(0, -1).some((point) => point.x === end.x && point.y === end.y) &&
796
+ routeIsClear([...connection, end], obstacles)
797
+ )
798
+ .map((connection) => compactPoints([...prefix, ...connection.slice(1), end]))
799
+ .sort((left, right) => chartRouteLength(left) - chartRouteLength(right))[0] ?? attached
800
+ }
801
+
802
+ const normalizeSourceDirection = (
803
+ edge: ChartEdge,
804
+ points: ReadonlyArray<ChartPoint>,
805
+ nodes: ReadonlyMap<string, LaidOutChartNode>,
806
+ allNodes: ReadonlyArray<LaidOutChartNode>
807
+ ): ReadonlyArray<ChartPoint> => {
808
+ if (isSelfTransition(edge) || points.length < 2) return points
809
+ const source = nodes.get(edge.source)
810
+ if (source === undefined || source.node.children.length > 0) return points
811
+ const rawStart = points[0]!
812
+ const side = endpointSide(rawStart, source)
813
+ const start = pointOnNodeBoundary(rawStart, source, side)
814
+ const tail = points.slice(1)
815
+ const next = tail[0]!
816
+ const attached = compactPoints([start, ...tail])
817
+ if (isOutwardStep(start, next, side)) return attached
818
+
819
+ const sourceStub = outwardPoint(start, side, 18)
820
+ const obstacles = [...routeObstacles(edge, allNodes), routeNodeRect(source)]
821
+ return endpointConnections(sourceStub, next, routeNodeRect(source), side)
822
+ .filter((connection) => routeIsClear([start, ...connection, ...tail.slice(1)], obstacles))
823
+ .map((connection) => compactPoints([start, ...connection, ...tail.slice(1)]))
824
+ .sort((left, right) => chartRouteLength(left) - chartRouteLength(right))[0] ?? attached
825
+ }
826
+
827
+ const headerDetour = (
828
+ points: ReadonlyArray<ChartPoint>,
829
+ node: LaidOutChartNode,
830
+ nodes: ReadonlyArray<LaidOutChartNode>,
831
+ lanes: Map<string, number>
832
+ ): ReadonlyArray<ChartPoint> => {
833
+ const header = nodeHeaderRect(node)
834
+ for (let index = 1; index < points.length; index++) {
835
+ const start = points[index - 1]!
836
+ const end = points[index]!
837
+ if (start.x !== end.x || !segmentCrossesInterior(start, end, header)) continue
838
+ const candidates = (["left", "right"] as const).map((side) => {
839
+ const key = `${node.node.path}:${side}`
840
+ const used = lanes.get(key) ?? 0
841
+ const laneX = side === "left"
842
+ ? header.left - 12 - used * 8
843
+ : header.right + 12 + used * 8
844
+ const route = compactPoints([
845
+ ...points.slice(0, index),
846
+ { x: laneX, y: start.y },
847
+ { x: laneX, y: end.y },
848
+ end,
849
+ ...points.slice(index + 1)
850
+ ])
851
+ const crossings = nodes.reduce((count, obstacleNode) => {
852
+ if (obstacleNode.node.path === node.node.path) return count
853
+ const obstacle = obstacleNode.node.children.length > 0
854
+ ? nodeHeaderRect(obstacleNode)
855
+ : nodeRect(obstacleNode)
856
+ return count +
857
+ route.slice(1).filter((point, segmentIndex) => segmentCrossesInterior(route[segmentIndex]!, point, obstacle))
858
+ .length
859
+ }, 0)
860
+ return {
861
+ key,
862
+ route,
863
+ score: crossings * 1_000_000 + used * 10_000 + chartRouteLength(route)
864
+ }
865
+ })
866
+ const selected = candidates.sort((left, right) => left.score - right.score)[0]!
867
+ lanes.set(selected.key, (lanes.get(selected.key) ?? 0) + 1)
868
+ return selected.route
869
+ }
870
+ return points
871
+ }
872
+
873
+ const avoidCompoundHeaders = (
874
+ edge: ChartEdge,
875
+ points: ReadonlyArray<ChartPoint>,
876
+ nodes: ReadonlyArray<LaidOutChartNode>,
877
+ lanes: Map<string, number>
878
+ ): ReadonlyArray<ChartPoint> => {
879
+ let routed = points
880
+ for (const node of nodes) {
881
+ if (node.node.children.length === 0 || !transitionTouchesNode(edge, node.node.path)) continue
882
+ if (!routed.slice(1).some((point, index) => segmentCrossesInterior(routed[index]!, point, nodeHeaderRect(node)))) {
883
+ continue
884
+ }
885
+ routed = headerDetour(routed, node, nodes, lanes)
886
+ }
887
+ return routed
888
+ }
889
+
890
+ const routeLabelCandidates = (
891
+ edge: ChartEdge,
892
+ points: ReadonlyArray<ChartPoint>,
893
+ fallback: ChartPoint,
894
+ width: number,
895
+ height: number
896
+ ): ReadonlyArray<ChartPoint> => {
897
+ if (isSelfTransition(edge)) return [fallback]
898
+ const candidates = points.slice(1).flatMap((end, index) => {
899
+ const start = points[index]!
900
+ const horizontal = start.y === end.y
901
+ const length = Math.abs(end.x - start.x) + Math.abs(end.y - start.y)
902
+ const required = (horizontal ? width : height) + 24
903
+ return length < required ? [] : [{ start, end, horizontal, length }]
904
+ })
905
+ const ordered = [
906
+ ...candidates.filter(({ horizontal }) => !horizontal).sort((left, right) => right.length - left.length),
907
+ ...candidates.filter(({ horizontal }) => horizontal).sort((left, right) => right.length - left.length)
908
+ ].flatMap(({ start, end, horizontal, length }) => {
909
+ const clearance = (horizontal ? width : height) / 2 + 12
910
+ return [0.5, 2 / 3, 1 / 3].flatMap((ratio) => {
911
+ const distance = length * ratio
912
+ if (distance < clearance || length - distance < clearance) return []
913
+ return [{
914
+ x: start.x + (end.x - start.x) * ratio,
915
+ y: start.y + (end.y - start.y) * ratio
916
+ }]
917
+ })
918
+ })
919
+ return [...ordered, fallback].filter((candidate, index, all) =>
920
+ all.findIndex((other) => other.x === candidate.x && other.y === candidate.y) === index
921
+ )
922
+ }
923
+
924
+ const placeTransitionLabels = (
925
+ transitions: ReadonlyArray<LaidOutChartTransition>,
926
+ nodes: ReadonlyArray<LaidOutChartNode>
927
+ ): ReadonlyArray<LaidOutChartTransition> => {
928
+ const placed: Array<LaidOutChartTransition> = []
929
+ for (const transition of transitions) {
930
+ const candidates = routeLabelCandidates(
931
+ transition.edge,
932
+ transition.points,
933
+ transition.label,
934
+ transition.labelWidth,
935
+ transition.labelHeight
936
+ )
937
+ const position = candidates.find((candidate) => {
938
+ const candidateRect = labelRect(candidate, transition.labelWidth, transition.labelHeight)
939
+ if (
940
+ nodes.some((node) => {
941
+ const touchesCompound = node.node.children.length > 0 && transitionTouchesNode(
942
+ transition.edge,
943
+ node.node.path
944
+ )
945
+ return overlaps(candidateRect, touchesCompound ? nodeHeaderRect(node) : nodeRect(node), 2)
946
+ })
947
+ ) return false
948
+ if (
949
+ placed.some((other) =>
950
+ overlaps(
951
+ candidateRect,
952
+ labelRect(other.label, other.labelWidth, other.labelHeight),
953
+ 2
954
+ )
955
+ )
956
+ ) return false
957
+ if (
958
+ transitions.some((other) =>
959
+ other.edge.id !== transition.edge.id && overlaps(
960
+ candidateRect,
961
+ labelRect(other.label, other.labelWidth, other.labelHeight),
962
+ 2
963
+ )
964
+ )
965
+ ) return false
966
+ return transitions.every((other) =>
967
+ other.edge.id === transition.edge.id ||
968
+ !other.points.slice(1).some((point, index) =>
969
+ segmentCrossesInterior(other.points[index]!, point, candidateRect)
970
+ )
971
+ )
972
+ }) ?? transition.label
973
+ placed.push({ ...transition, label: position })
974
+ }
975
+ return placed
976
+ }
977
+
567
978
  const collectLayout = (
568
979
  model: ChartModel,
569
980
  graph: ElkNode,
570
981
  unconnected: ReadonlyArray<UnconnectedRegion>
571
982
  ): LaidOutChart => {
572
983
  const chartNodes = new Map(model.nodes.map((node) => [node.path, node]))
573
- const chartInitials = new Map(model.initials.map((initial) => [initialNodeId(initial), initial]))
574
984
  const chartRuntimeTargets = new Map(model.runtimeTargets.map((target) => [runtimeNodeId(target), target]))
575
985
  const chartRegions = new Map(unconnected.map((region) => [region.id, region]))
576
986
  const offsets = new Map<string, ChartPoint>([[graph.id, { x: 0, y: 0 }]])
@@ -578,6 +988,7 @@ const collectLayout = (
578
988
  const nodes: Array<LaidOutChartNode> = []
579
989
  const initials: Array<LaidOutChartInitial> = []
580
990
  const runtimeTargets: Array<LaidOutChartRuntimeTarget> = []
991
+ const selfLoops = selfLoopsBySource(model.edges)
581
992
 
582
993
  const visit = (node: ElkNode, parentOffset: ChartPoint): void => {
583
994
  const absolute = add(parentOffset, { x: node.x ?? 0, y: node.y ?? 0 })
@@ -596,7 +1007,7 @@ const collectLayout = (
596
1007
  }
597
1008
  const chartNode = chartNodes.get(node.id)
598
1009
  if (chartNode !== undefined) {
599
- const metric = nodeMetric(chartNode)
1010
+ const metric = nodeMetric(chartNode, selfLoops.get(chartNode.path) ?? 0)
600
1011
  nodes.push({
601
1012
  node: chartNode,
602
1013
  x: absolute.x,
@@ -606,16 +1017,6 @@ const collectLayout = (
606
1017
  headerHeight: metric.headerHeight
607
1018
  })
608
1019
  }
609
- const initial = chartInitials.get(node.id)
610
- if (initial !== undefined) {
611
- initials.push({
612
- initial,
613
- x: absolute.x,
614
- y: absolute.y,
615
- width: node.width ?? 14,
616
- height: node.height ?? 14
617
- })
618
- }
619
1020
  const runtimeTarget = chartRuntimeTargets.get(node.id)
620
1021
  if (runtimeTarget !== undefined) {
621
1022
  runtimeTargets.push({
@@ -630,39 +1031,90 @@ const collectLayout = (
630
1031
  }
631
1032
  for (const child of graph.children ?? []) visit(child, { x: 0, y: 0 })
632
1033
 
1034
+ const nodesByPath = new Map(nodes.map((node) => [node.node.path, node]))
1035
+ for (const initial of model.initials) {
1036
+ const target = nodesByPath.get(initial.target)
1037
+ if (target === undefined) continue
1038
+ initials.push({
1039
+ initial,
1040
+ x: target.x + 9,
1041
+ y: target.y - 17,
1042
+ width: 7,
1043
+ height: 7
1044
+ })
1045
+ }
1046
+
633
1047
  const chartEdges = new Map(model.edges.map((edge) => [edge.id, edge]))
634
- const initialEdges = new Map(model.initials.map((initial) => [initial.id, initial]))
635
- const edges = (graph.edges ?? []).flatMap(
636
- (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> => {
637
1052
  const offset = offsets.get(edge.container ?? graph.id) ?? { x: 0, y: 0 }
638
- const points = edgePoints(edge, offset)
639
- if (points === undefined) return []
1053
+ const elkPoints = edgePoints(edge, offset)
1054
+ if (elkPoints === undefined) return []
640
1055
  const chartEdge = chartEdges.get(edge.id)
641
- if (chartEdge !== undefined) {
642
- const metric = labelMetric(chartEdge.label)
643
- const label = edge.labels?.[0]
644
- const labelWidth = label?.width ?? metric.width
645
- const labelHeight = label?.height ?? metric.height
646
- return [{
647
- kind: "transition",
648
- edge: chartEdge,
649
- points,
650
- label: label?.x === undefined || label.y === undefined
651
- ? midpoint(points)
652
- : add(offset, {
653
- x: label.x + labelWidth / 2,
654
- y: label.y + labelHeight / 2
655
- }),
656
- labelWidth,
657
- labelHeight
658
- }]
659
- }
660
- const initial = initialEdges.get(edge.id)
661
- return initial === undefined ? [] : [{ kind: "initial", initial, points }]
1056
+ if (chartEdge === undefined) return []
1057
+ const points = normalizeTerminalDirection(
1058
+ chartEdge,
1059
+ normalizeSourceDirection(
1060
+ chartEdge,
1061
+ avoidCompoundHeaders(
1062
+ chartEdge,
1063
+ shortenTransitionRoute(
1064
+ chartEdge,
1065
+ normalizeHierarchyRoute(chartEdge, elkPoints, nodesByPath),
1066
+ nodesByPath,
1067
+ nodes,
1068
+ directLanes
1069
+ ),
1070
+ nodes,
1071
+ hierarchyLanes
1072
+ ),
1073
+ nodesByPath,
1074
+ nodes
1075
+ ),
1076
+ nodesByPath,
1077
+ nodes
1078
+ )
1079
+ const metric = labelMetric(chartEdge.label)
1080
+ const label = edge.labels?.[0]
1081
+ const labelWidth = label?.width ?? metric.width
1082
+ const labelHeight = label?.height ?? metric.height
1083
+ const elkLabel = label?.x === undefined || label.y === undefined
1084
+ ? midpoint(points)
1085
+ : add(offset, {
1086
+ x: label.x + labelWidth / 2,
1087
+ y: label.y + labelHeight / 2
1088
+ })
1089
+ const routeChanged = points.length !== elkPoints.length ||
1090
+ points.slice(1, -1).some((point, index) =>
1091
+ point.x !== elkPoints[index + 1]?.x || point.y !== elkPoints[index + 1]?.y
1092
+ )
1093
+ return [{
1094
+ kind: "transition",
1095
+ edge: chartEdge,
1096
+ points,
1097
+ label: !isSelfTransition(chartEdge) && routeChanged ? midpoint(points) : elkLabel,
1098
+ labelWidth,
1099
+ labelHeight
1100
+ }]
662
1101
  }
663
1102
  )
664
-
665
- const transitionEdges = edges.filter((edge) => edge.kind === "transition")
1103
+ const initialEdges = initials.flatMap(({ initial, x, y, width, height }): ReadonlyArray<LaidOutChartInitialEdge> => {
1104
+ const target = nodesByPath.get(initial.target)
1105
+ if (target === undefined) return []
1106
+ const start = { x: x + width / 2, y: y + height / 2 }
1107
+ return [{
1108
+ kind: "initial",
1109
+ initial,
1110
+ points: compactPoints([start, { x: start.x, y: target.y }])
1111
+ }]
1112
+ })
1113
+ const transitionEdges = placeTransitionLabels(rawTransitionEdges, nodes)
1114
+ const edges: ReadonlyArray<LaidOutChartTransition | LaidOutChartInitialEdge> = [
1115
+ ...transitionEdges,
1116
+ ...initialEdges
1117
+ ]
666
1118
  const contentWidth = Math.max(
667
1119
  0,
668
1120
  ...nodes.map(({ width, x }) => x + width),
@@ -872,16 +1324,44 @@ export const validateChartLayout = (
872
1324
  if (transitionLabelDistance > Math.max(transition.labelWidth, transition.labelHeight) / 2 + 12) {
873
1325
  report("label-detached", transition.edge.id, `${Math.round(transitionLabelDistance)}px`)
874
1326
  }
1327
+ const start = transition.points[0]
1328
+ const next = transition.points[1]
1329
+ if (start !== undefined && next !== undefined && !isSelfTransition(transition.edge)) {
1330
+ const source = layout.nodes.find(({ node }) => node.path === transition.edge.source)
1331
+ if (source !== undefined && source.node.children.length === 0) {
1332
+ if (Math.abs(start.x - next.x) + Math.abs(start.y - next.y) < 9) {
1333
+ report("short-source", transition.edge.id)
1334
+ }
1335
+ if (nodeBoundaryDistance(start, source) > 0.5) {
1336
+ report("detached-source", transition.edge.id, transition.edge.source)
1337
+ } else if (!isOutwardStep(start, next, endpointSide(start, source))) {
1338
+ report("wrong-source-direction", transition.edge.id, transition.edge.source)
1339
+ }
1340
+ }
1341
+ }
1342
+
875
1343
  const end = transition.points.at(-1)
876
1344
  const bend = transition.points.at(-2)
877
1345
  if (
878
1346
  end === undefined || bend === undefined ||
879
1347
  Math.abs(end.x - bend.x) + Math.abs(end.y - bend.y) < 9
880
1348
  ) report("short-terminal", transition.edge.id)
1349
+ if (end !== undefined && transition.edge.target !== null && !isSelfTransition(transition.edge)) {
1350
+ const target = layout.nodes.find(({ node }) => node.path === transition.edge.target)
1351
+ if (target !== undefined && nodeBoundaryDistance(end, target) > 0.5) {
1352
+ report("detached-terminal", transition.edge.id, transition.edge.target)
1353
+ } else if (target !== undefined && bend !== undefined && !isOutwardStep(end, bend, endpointSide(end, target))) {
1354
+ report("wrong-terminal-direction", transition.edge.id, transition.edge.target)
1355
+ }
1356
+ }
881
1357
 
882
1358
  const label = labelRect(transition.label, transition.labelWidth, transition.labelHeight)
883
1359
  for (const node of layout.nodes) {
884
- const obstacle = node.node.children.length > 0 && transitionTouchesNode(transition.edge, node.node.path)
1360
+ const touchesCompound = node.node.children.length > 0 && transitionTouchesNode(
1361
+ transition.edge,
1362
+ node.node.path
1363
+ )
1364
+ const obstacle = touchesCompound
885
1365
  ? nodeHeaderRect(node)
886
1366
  : nodeRect(node)
887
1367
  if (overlaps(label, obstacle, 2)) report("label-node-overlap", transition.edge.id, node.node.path)
@@ -970,6 +1450,7 @@ export const validateChartLayout = (
970
1450
  }
971
1451
 
972
1452
  type ChartLayoutEngine = (graph: ElkNode, portConstraints: PortConstraints) => Promise<ElkNode>
1453
+ type ChartLayoutValidator = (model: ChartModel, layout: LaidOutChart) => ChartLayoutValidation
973
1454
 
974
1455
  interface LayoutAttemptFailure {
975
1456
  readonly profile: string
@@ -978,9 +1459,18 @@ interface LayoutAttemptFailure {
978
1459
 
979
1460
  interface InvalidLayoutCandidate {
980
1461
  readonly profile: string
1462
+ readonly layout: LaidOutChart
981
1463
  readonly validation: ChartLayoutValidation
982
1464
  }
983
1465
 
1466
+ const isWarningOnlyLayout = ({ issues }: ChartLayoutValidation): boolean =>
1467
+ issues.length > 0 && issues.every(({ code }) => code === "label-route-overlap")
1468
+
1469
+ const bestLayoutCandidate = (
1470
+ candidates: ReadonlyArray<InvalidLayoutCandidate>
1471
+ ): InvalidLayoutCandidate | undefined =>
1472
+ [...candidates].sort((left, right) => validationScore(left.validation) - validationScore(right.validation))[0]
1473
+
984
1474
  const causeMessage = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause)
985
1475
 
986
1476
  const issueSummary = (validation: ChartLayoutValidation): string => {
@@ -995,7 +1485,8 @@ const issueSummary = (validation: ChartLayoutValidation): string => {
995
1485
 
996
1486
  export const layoutChartWith = (
997
1487
  model: ChartModel,
998
- layout: ChartLayoutEngine
1488
+ layout: ChartLayoutEngine,
1489
+ validate: ChartLayoutValidator = validateChartLayout
999
1490
  ): Effect.Effect<LaidOutChart, ChartLayoutError> =>
1000
1491
  Effect.suspend(() => {
1001
1492
  const policy = makeChartLayoutPolicy(model)
@@ -1006,9 +1497,9 @@ export const layoutChartWith = (
1006
1497
  const attempt = (index: number): Effect.Effect<LaidOutChart, ChartLayoutError> => {
1007
1498
  const profile = layoutProfiles[index]
1008
1499
  if (profile === undefined) {
1009
- const best = [...invalid].sort((left, right) =>
1010
- validationScore(left.validation) - validationScore(right.validation)
1011
- )[0]
1500
+ const fallback = bestLayoutCandidate(invalid.filter(({ validation }) => isWarningOnlyLayout(validation)))
1501
+ if (fallback !== undefined) return Effect.succeed(fallback.layout)
1502
+ const best = bestLayoutCandidate(invalid)
1012
1503
  const detail = best === undefined
1013
1504
  ? failures.map(({ cause, profile }) => `${profile}: ${causeMessage(cause)}`).join("; ")
1014
1505
  : `${best.profile}: ${issueSummary(best.validation)}`
@@ -1032,9 +1523,9 @@ export const layoutChartWith = (
1032
1523
  },
1033
1524
  onSuccess: (graph) => {
1034
1525
  const candidate = collectLayout(model, graph, regions)
1035
- const validation = validateChartLayout(model, candidate)
1526
+ const validation = validate(model, candidate)
1036
1527
  if (validation.valid) return Effect.succeed(candidate)
1037
- invalid.push({ profile: profile.id, validation })
1528
+ invalid.push({ profile: profile.id, layout: candidate, validation })
1038
1529
  return attempt(index + 1)
1039
1530
  }
1040
1531
  }