@typeonce/effect-machine-devtools 0.26.0 → 0.26.2

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.
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <meta name="description" content="Interactive statechart visualization for Effect Machine" />
7
7
  <title>Effect Machine · Statechart</title>
8
- <script type="module" crossorigin src="/assets/index-eiptehRd.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-H0UhNc0S.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-BsorPSDS.css">
10
10
  </head>
11
11
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typeonce/effect-machine-devtools",
3
- "version": "0.26.0",
3
+ "version": "0.26.2",
4
4
  "description": "Local development tools for Effect Machine",
5
5
  "author": "Sandro Maglione",
6
6
  "repository": {
@@ -30,10 +30,10 @@
30
30
  "@effect/platform-browser": "4.0.0-rc.112",
31
31
  "@effect/platform-node": "4.0.0-rc.112",
32
32
  "chokidar": "4.0.3",
33
- "elkjs": "0.11.1",
33
+ "elkjs": "0.12.0",
34
34
  "typescript": "6.0.3",
35
35
  "vite": "8.1.5",
36
- "@typeonce/effect-machine": "^0.26.0"
36
+ "@typeonce/effect-machine": "^0.26.2"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "effect": "4.0.0-rc.112"
@@ -82,6 +82,7 @@ export interface LaidOutChart {
82
82
 
83
83
  export class ChartLayoutError extends Data.TaggedError("ChartLayoutError")<{
84
84
  readonly cause: unknown
85
+ readonly message: string
85
86
  }> {}
86
87
 
87
88
  interface NodeMetric {
@@ -122,6 +123,26 @@ const runtimeNodeId = (target: ChartRuntimeTarget): string => `node:${target.id}
122
123
  const runtimeTargetPortId = (target: ChartRuntimeTarget): string => `port:${target.edgeId}:target`
123
124
  const unconnectedRegionId = (parent: string | null): string => `region:unconnected:${parent ?? "root"}`
124
125
  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
+ const isDescendantPath = (path: string, ancestor: string): boolean => path.startsWith(`${ancestor}.`)
140
+
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
125
146
 
126
147
  interface UnconnectedRegion {
127
148
  readonly id: string
@@ -146,9 +167,10 @@ const unconnectedRegions = (
146
167
  }
147
168
 
148
169
  const portsByState = (
149
- model: ChartModel,
150
- edgePolicy: ReturnType<typeof makeChartLayoutPolicy>["edge"]
151
- ): ReadonlyMap<string, ReadonlyArray<ElkPort>> => {
170
+ edges: ReadonlyArray<ChartEdge>,
171
+ edgePolicy: ReturnType<typeof makeChartLayoutPolicy>["edge"],
172
+ portConstraints: PortConstraints
173
+ ): Map<string, Array<ElkPort>> => {
152
174
  const ports = new Map<string, Array<ElkPort>>()
153
175
  const add = (path: string, id: string, side: ChartPortSide): void => {
154
176
  const statePorts = ports.get(path) ?? []
@@ -156,23 +178,20 @@ const portsByState = (
156
178
  id,
157
179
  width: 6,
158
180
  height: 6,
159
- layoutOptions: {
160
- "elk.port.side": side
161
- }
181
+ ...(portConstraints === "fixed"
182
+ ? { layoutOptions: { "elk.port.side": side } }
183
+ : {})
162
184
  })
163
185
  ports.set(path, statePorts)
164
186
  }
165
187
 
166
- for (const edge of model.edges) {
188
+ for (const edge of edges) {
167
189
  const policy = edgePolicy(edge)
168
190
  add(edge.source, sourcePortId(edge), policy.sourceSide)
169
191
  if (edge.kind === "target" && edge.target !== null) {
170
192
  add(edge.target, targetPortId(edge), policy.targetSide)
171
- } else if (edge.kind === "targetless") {
172
- add(edge.source, targetPortId(edge), policy.targetSide)
173
193
  }
174
194
  }
175
- for (const initial of model.initials) add(initial.target, initialTargetPortId(initial), "WEST")
176
195
  return ports
177
196
  }
178
197
 
@@ -184,18 +203,31 @@ const labelMetric = (label: string): { readonly width: number; readonly height:
184
203
  const makeGraph = (
185
204
  model: ChartModel,
186
205
  policy: ReturnType<typeof makeChartLayoutPolicy>,
187
- regions: ReadonlyArray<UnconnectedRegion>
206
+ regions: ReadonlyArray<UnconnectedRegion>,
207
+ portConstraints: PortConstraints
188
208
  ): ElkNode => {
189
209
  const nodesByPath = new Map(model.nodes.map((node) => [node.path, node]))
190
210
  const regionsByParent = new Map(regions.map((region) => [region.parent, region]))
191
211
  const sourceByEdgeId = new Map(model.edges.map((edge) => [edge.id, edge.source]))
192
- const selfLoopParents = new Set(
193
- model.edges.flatMap((edge) => {
194
- if (!isSelfTransition(edge)) return []
195
- const parent = nodesByPath.get(edge.source)?.parent
196
- return parent === null || parent === undefined ? [] : [parent]
197
- })
198
- )
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
+ }
199
231
  const initialsByParent = new Map<string | null, Array<ChartInitial>>()
200
232
  for (const initial of model.initials) {
201
233
  const siblings = initialsByParent.get(initial.parent) ?? []
@@ -208,7 +240,19 @@ const makeGraph = (
208
240
  siblings.push(target)
209
241
  runtimeTargetsByParent.set(target.parent, siblings)
210
242
  }
211
- const ports = portsByState(model, policy.edge)
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
+ }
212
256
 
213
257
  const initialNode = (initial: ChartInitial): ElkNode => ({
214
258
  id: initialNodeId(initial),
@@ -226,21 +270,25 @@ const makeGraph = (
226
270
  id: runtimeTargetPortId(target),
227
271
  width: 6,
228
272
  height: 6,
229
- layoutOptions: { "elk.port.side": "WEST" }
273
+ ...(portConstraints === "fixed"
274
+ ? { layoutOptions: { "elk.port.side": "WEST" } }
275
+ : {})
230
276
  }],
231
- layoutOptions: { "elk.portConstraints": "FIXED_SIDE" }
277
+ ...(portConstraints === "fixed"
278
+ ? { layoutOptions: { "elk.portConstraints": "FIXED_SIDE" } }
279
+ : {})
232
280
  })
233
281
 
234
282
  const stateNode = (node: ChartNode, suppressUnconnectedRegion: boolean): ElkNode => {
235
283
  const metric = nodeMetric(node)
236
284
  const nodePolicy = policy.node(node.path)
237
285
  const descendants = children(node.path, suppressUnconnectedRegion || !nodePolicy.staticPath)
238
- const bottomPadding = 28 + (selfLoopParents.has(node.path) ? chartSelfLoopParentAllowance : 0)
286
+ const bottomPadding = 28 + (selfLoopAllowanceByParent.get(node.path) ?? 0)
239
287
  const common = {
240
288
  id: node.path,
241
289
  ports: [...ports.get(node.path) ?? []],
242
290
  layoutOptions: {
243
- "elk.portConstraints": "FIXED_SIDE",
291
+ ...(portConstraints === "fixed" ? { "elk.portConstraints": "FIXED_SIDE" } : {}),
244
292
  "elk.spacing.portPort": "22",
245
293
  ...(nodePolicy.layerConstraint === null
246
294
  ? {}
@@ -311,7 +359,7 @@ const makeGraph = (
311
359
  id: "chart-root",
312
360
  children: children(null),
313
361
  edges: [
314
- ...model.edges.map((edge): ElkExtendedEdge => {
362
+ ...layoutEdges.map((edge): ElkExtendedEdge => {
315
363
  const label = labelMetric(edge.label)
316
364
  const edgeLayout = policy.edge(edge)
317
365
  return {
@@ -432,32 +480,6 @@ const midpoint = (points: ReadonlyArray<ChartPoint>): ChartPoint => {
432
480
  return points.at(-1)!
433
481
  }
434
482
 
435
- const expandSelfLoop = (points: ReadonlyArray<ChartPoint>): ReadonlyArray<ChartPoint> => {
436
- const start = points[0]
437
- const end = points.at(-1)
438
- if (start === undefined || end === undefined) return points
439
- if (Math.abs(start.y - end.y) <= Math.abs(start.x - end.x)) {
440
- const outerY = Math.max(...points.map(({ y }) => y)) + 30
441
- return compactPoints([
442
- start,
443
- { x: start.x, y: outerY },
444
- { x: end.x, y: outerY },
445
- end
446
- ])
447
- }
448
- const outerX = Math.max(...points.map(({ x }) => x)) + 30
449
- return compactPoints([
450
- start,
451
- { x: outerX, y: start.y },
452
- { x: outerX, y: end.y },
453
- end
454
- ])
455
- }
456
-
457
- export const chartEdgeTerminalClearance = 24
458
- export const chartSelfLoopLabelGap = 8
459
- export const chartSelfLoopParentAllowance = 44
460
-
461
483
  const longestSegment = (
462
484
  points: ReadonlyArray<ChartPoint>,
463
485
  matches: (start: ChartPoint, end: ChartPoint) => boolean,
@@ -512,6 +534,33 @@ export const selfLoopLabelPosition = (
512
534
  }
513
535
  }
514
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
+
515
564
  export const ensureChartEdgeTerminalClearance = (
516
565
  points: ReadonlyArray<ChartPoint>
517
566
  ): ReadonlyArray<ChartPoint> => {
@@ -543,6 +592,132 @@ export const ensureChartEdgeTerminalClearance = (
543
592
  return compactPoints(result)
544
593
  }
545
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
+
546
721
  const collectLayout = (
547
722
  model: ChartModel,
548
723
  graph: ElkNode,
@@ -611,40 +786,69 @@ const collectLayout = (
611
786
 
612
787
  const chartEdges = new Map(model.edges.map((edge) => [edge.id, edge]))
613
788
  const initialEdges = new Map(model.initials.map((initial) => [initial.id, initial]))
614
- const edges = (graph.edges ?? []).flatMap((edge): ReadonlyArray<LaidOutChartTransition | LaidOutChartInitialEdge> => {
615
- const offset = offsets.get(edge.container ?? graph.id) ?? { x: 0, y: 0 }
616
- const points = edgePoints(edge, offset)
617
- if (points === undefined) return []
618
- const chartEdge = chartEdges.get(edge.id)
619
- if (chartEdge !== undefined) {
620
- const metric = labelMetric(chartEdge.label)
621
- const label = edge.labels?.[0]
622
- const selfTransition = isSelfTransition(chartEdge)
623
- const routedPoints = selfTransition
624
- ? expandSelfLoop(points)
625
- : points
626
- const transitionPoints = ensureChartEdgeTerminalClearance(routedPoints)
627
- const labelWidth = label?.width ?? metric.width
628
- const labelHeight = label?.height ?? metric.height
629
- return [{
630
- kind: "transition",
631
- edge: chartEdge,
632
- points: transitionPoints,
633
- label: selfTransition
634
- ? selfLoopLabelPosition(transitionPoints, labelWidth, labelHeight)
635
- : label?.x === undefined || label.y === undefined
636
- ? midpoint(transitionPoints)
637
- : add(offset, {
638
- x: label.x + labelWidth / 2,
639
- y: label.y + labelHeight / 2
640
- }),
641
- labelWidth,
642
- labelHeight
643
- }]
789
+ const elkEdges = (graph.edges ?? []).flatMap(
790
+ (edge): ReadonlyArray<LaidOutChartTransition | LaidOutChartInitialEdge> => {
791
+ const offset = offsets.get(edge.container ?? graph.id) ?? { x: 0, y: 0 }
792
+ const points = edgePoints(edge, offset)
793
+ if (points === undefined) return []
794
+ 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 }]
644
817
  }
645
- const initial = initialEdges.get(edge.id)
646
- return initial === undefined ? [] : [{ kind: "initial", initial, points }]
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
+ )
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]
647
846
  })
847
+ const edges: ReadonlyArray<LaidOutChartTransition | LaidOutChartInitialEdge> = [
848
+ ...elkTransitions,
849
+ ...elkEdges.filter((edge): edge is LaidOutChartInitialEdge => edge.kind === "initial"),
850
+ ...selfEdges
851
+ ]
648
852
 
649
853
  const transitionEdges = edges.filter((edge) => edge.kind === "transition")
650
854
  const contentWidth = Math.max(
@@ -675,12 +879,40 @@ const collectLayout = (
675
879
  }
676
880
  }
677
881
 
678
- export const layoutChart = (model: ChartModel): Effect.Effect<LaidOutChart, ChartLayoutError> =>
882
+ type ChartLayoutEngine = (graph: ElkNode, portConstraints: PortConstraints) => Promise<ElkNode>
883
+
884
+ const causeMessage = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause)
885
+
886
+ export const layoutChartWith = (
887
+ model: ChartModel,
888
+ layout: ChartLayoutEngine
889
+ ): Effect.Effect<LaidOutChart, ChartLayoutError> =>
679
890
  Effect.suspend(() => {
680
891
  const policy = makeChartLayoutPolicy(model)
681
892
  const regions = unconnectedRegions(model, policy)
682
- return Effect.tryPromise({
683
- try: () => elk.layout(makeGraph(model, policy, regions)),
684
- catch: (cause) => new ChartLayoutError({ cause })
685
- }).pipe(Effect.map((graph) => collectLayout(model, graph, regions)))
893
+ const attempt = (portConstraints: PortConstraints) =>
894
+ Effect.tryPromise({
895
+ try: () => layout(makeGraph(model, policy, regions, portConstraints), portConstraints),
896
+ catch: (cause) => cause
897
+ })
898
+
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
+ )
686
915
  })
916
+
917
+ export const layoutChart = (model: ChartModel): Effect.Effect<LaidOutChart, ChartLayoutError> =>
918
+ layoutChartWith(model, (graph) => elk.layout(graph))