@typeonce/effect-machine-devtools 0.27.0 → 0.27.1

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