@typeonce/effect-machine-devtools 0.23.0 → 0.25.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.
Files changed (80) hide show
  1. package/NOTICE +4 -0
  2. package/README.md +14 -8
  3. package/dist/DevServer.d.ts +2 -1
  4. package/dist/DevServer.d.ts.map +1 -1
  5. package/dist/DevServer.js.map +1 -1
  6. package/dist/DevToolsProtocol.d.ts +167 -17
  7. package/dist/DevToolsProtocol.d.ts.map +1 -1
  8. package/dist/DevToolsProtocol.js +1 -1
  9. package/dist/MachineDocument.d.ts +98 -2
  10. package/dist/MachineDocument.d.ts.map +1 -1
  11. package/dist/MachineDocument.js +35 -1
  12. package/dist/MachineDocument.js.map +1 -1
  13. package/dist/MachineRegistry.d.ts +56 -6
  14. package/dist/MachineRegistry.d.ts.map +1 -1
  15. package/dist/MachineWalkthrough.d.ts +171 -0
  16. package/dist/MachineWalkthrough.d.ts.map +1 -0
  17. package/dist/MachineWalkthrough.js +98 -0
  18. package/dist/MachineWalkthrough.js.map +1 -0
  19. package/dist/client/assets/index-BTOAhezX.js +37 -0
  20. package/dist/client/assets/index-Cg7xzSkz.css +1 -0
  21. package/dist/client/index.html +4 -4
  22. package/dist/index.d.ts +2 -2
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +2 -2
  25. package/dist/index.js.map +1 -1
  26. package/dist/internal/devServer.d.ts +2 -1
  27. package/dist/internal/devServer.d.ts.map +1 -1
  28. package/dist/internal/devServer.js +4 -3
  29. package/dist/internal/devServer.js.map +1 -1
  30. package/dist/internal/evaluationWorker.d.ts.map +1 -1
  31. package/dist/internal/evaluationWorker.js +28 -9
  32. package/dist/internal/evaluationWorker.js.map +1 -1
  33. package/dist/internal/machineDocument.d.ts.map +1 -1
  34. package/dist/internal/machineDocument.js +65 -2
  35. package/dist/internal/machineDocument.js.map +1 -1
  36. package/dist/internal/machineWalkthrough.d.ts +26 -0
  37. package/dist/internal/machineWalkthrough.d.ts.map +1 -0
  38. package/dist/internal/machineWalkthrough.js +256 -0
  39. package/dist/internal/machineWalkthrough.js.map +1 -0
  40. package/dist/internal/projectInspector.d.ts.map +1 -1
  41. package/dist/internal/projectInspector.js +15 -9
  42. package/dist/internal/projectInspector.js.map +1 -1
  43. package/index.html +2 -2
  44. package/package.json +4 -3
  45. package/src/DevServer.ts +6 -2
  46. package/src/DevToolsProtocol.ts +1 -1
  47. package/src/MachineDocument.ts +56 -1
  48. package/src/MachineWalkthrough.ts +199 -0
  49. package/src/index.ts +2 -2
  50. package/src/internal/browser/chart-layout.ts +433 -0
  51. package/src/internal/browser/chart-model.ts +234 -0
  52. package/src/internal/browser/chart-renderer.ts +661 -0
  53. package/src/internal/browser/example-machine.ts +15 -6
  54. package/src/internal/browser/input-form.ts +252 -0
  55. package/src/internal/browser/invoke-outcomes-example.ts +231 -0
  56. package/src/internal/browser/parallel-completion-example.ts +199 -0
  57. package/src/internal/browser/planner-example.ts +145 -0
  58. package/src/internal/browser/protocol-events-example.ts +200 -0
  59. package/src/internal/browser/styles.css +959 -185
  60. package/src/internal/browser/transition-semantics-example.ts +236 -0
  61. package/src/internal/browser/visualizer-app.ts +818 -389
  62. package/src/internal/browser/visualizer-model.ts +104 -0
  63. package/src/internal/browser/visualizer.ts +1 -1
  64. package/src/internal/devServer.ts +9 -4
  65. package/src/internal/evaluationWorker.ts +28 -10
  66. package/src/internal/machineDocument.ts +77 -2
  67. package/src/internal/machineWalkthrough.ts +355 -0
  68. package/src/internal/projectInspector.ts +29 -14
  69. package/dist/MachineSimulator.d.ts +0 -169
  70. package/dist/MachineSimulator.d.ts.map +0 -1
  71. package/dist/MachineSimulator.js +0 -98
  72. package/dist/MachineSimulator.js.map +0 -1
  73. package/dist/client/assets/index-BGOhE3Ng.css +0 -1
  74. package/dist/client/assets/index-DiqGhsGR.js +0 -14
  75. package/dist/internal/machineSimulator.d.ts +0 -5
  76. package/dist/internal/machineSimulator.d.ts.map +0 -1
  77. package/dist/internal/machineSimulator.js +0 -156
  78. package/dist/internal/machineSimulator.js.map +0 -1
  79. package/src/MachineSimulator.ts +0 -154
  80. package/src/internal/machineSimulator.ts +0 -199
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Side-effect-free topology walkthroughs over machine documents.
3
+ *
4
+ * @since 0.25.0
5
+ */
6
+ import { dual } from "effect/Function"
7
+ import * as Result from "effect/Result"
8
+ import * as Schema from "effect/Schema"
9
+ import * as internal from "./internal/machineWalkthrough.js"
10
+ import type * as MachineDocument from "./MachineDocument.js"
11
+
12
+ const TypeId = internal.SessionTypeId
13
+
14
+ /**
15
+ * An immutable walkthrough session. Its timeline can be inspected and its
16
+ * cursor can move without evaluating machine callbacks.
17
+ *
18
+ * @category models
19
+ * @since 0.25.0
20
+ */
21
+ export interface Session {
22
+ readonly [TypeId]: typeof TypeId
23
+ }
24
+
25
+ /**
26
+ * Why taking a documented branch requires an explicit human decision.
27
+ *
28
+ * @category models
29
+ * @since 0.25.0
30
+ */
31
+ export type Decision =
32
+ | "conditional-branch"
33
+ | "declinable-transition"
34
+ | "automatic-trigger"
35
+ | "invoke-outcome"
36
+
37
+ /**
38
+ * Why a documented branch cannot advance topology without runtime data.
39
+ *
40
+ * @category models
41
+ * @since 0.25.0
42
+ */
43
+ export type UnavailableReason = "history-unavailable" | "runtime-target"
44
+
45
+ /**
46
+ * One branch that can be explored from the current active configuration.
47
+ *
48
+ * @category models
49
+ * @since 0.25.0
50
+ */
51
+ export interface Choice {
52
+ readonly id: string
53
+ readonly transitionId: string
54
+ readonly branchId: string
55
+ readonly branchIndex: number
56
+ readonly branchKey: string | null
57
+ readonly title: string | null
58
+ readonly source: string
59
+ readonly trigger: MachineDocument.Trigger
60
+ readonly target: string | null
61
+ readonly selection: MachineDocument.Selection
62
+ readonly updates: ReadonlyArray<string>
63
+ readonly decisions: ReadonlyArray<Decision>
64
+ readonly unavailableReason: UnavailableReason | null
65
+ readonly input: MachineDocument.InputSchema | null
66
+ }
67
+
68
+ /**
69
+ * The active state paths at one point in a walkthrough.
70
+ *
71
+ * @category models
72
+ * @since 0.25.0
73
+ */
74
+ export interface Snapshot {
75
+ readonly activePaths: ReadonlyArray<string>
76
+ }
77
+
78
+ /**
79
+ * One initial or manually selected topology step.
80
+ *
81
+ * @category models
82
+ * @since 0.25.0
83
+ */
84
+ export interface Frame {
85
+ readonly step: number
86
+ readonly choice: Choice | null
87
+ readonly before: Snapshot
88
+ readonly after: Snapshot
89
+ readonly exitPaths: ReadonlyArray<string>
90
+ readonly entryPaths: ReadonlyArray<string>
91
+ readonly changed: boolean
92
+ }
93
+
94
+ /**
95
+ * A requested choice is not available from the cursor's configuration.
96
+ *
97
+ * @category errors
98
+ * @since 0.25.0
99
+ */
100
+ export class ChoiceNotFound extends Schema.Error<ChoiceNotFound>(
101
+ "@typeonce/effect-machine-devtools/MachineWalkthrough/ChoiceNotFound"
102
+ )({
103
+ _tag: Schema.tag("ChoiceNotFound"),
104
+ choiceId: Schema.String
105
+ }) {}
106
+
107
+ /**
108
+ * A choice depends on runtime information absent from the machine document.
109
+ *
110
+ * @category errors
111
+ * @since 0.25.0
112
+ */
113
+ export class ChoiceUnavailable extends Schema.Error<ChoiceUnavailable>(
114
+ "@typeonce/effect-machine-devtools/MachineWalkthrough/ChoiceUnavailable"
115
+ )({
116
+ _tag: Schema.tag("ChoiceUnavailable"),
117
+ choiceId: Schema.String,
118
+ reason: Schema.Literals(["history-unavailable", "runtime-target"])
119
+ }) {}
120
+
121
+ /**
122
+ * A requested timeline step does not exist.
123
+ *
124
+ * @category errors
125
+ * @since 0.25.0
126
+ */
127
+ export class StepNotFound extends Schema.Error<StepNotFound>(
128
+ "@typeonce/effect-machine-devtools/MachineWalkthrough/StepNotFound"
129
+ )({
130
+ _tag: Schema.tag("StepNotFound"),
131
+ step: Schema.Number
132
+ }) {}
133
+
134
+ const api = { ChoiceNotFound, ChoiceUnavailable, StepNotFound }
135
+
136
+ /**
137
+ * Starts at the document's captured snapshot when present, otherwise at its
138
+ * statically declared initial configuration.
139
+ *
140
+ * @category constructors
141
+ * @since 0.25.0
142
+ */
143
+ export const start: (document: MachineDocument.MachineDocument) => Session = internal.start
144
+
145
+ /**
146
+ * Returns the frame selected by the session cursor.
147
+ *
148
+ * @category getters
149
+ * @since 0.25.0
150
+ */
151
+ export const current: (self: Session) => Frame = internal.current
152
+
153
+ /**
154
+ * Returns every retained frame, including frames after the current cursor.
155
+ *
156
+ * @category getters
157
+ * @since 0.25.0
158
+ */
159
+ export const timeline: (self: Session) => ReadonlyArray<Frame> = internal.timeline
160
+
161
+ /**
162
+ * Returns the current zero-based timeline position.
163
+ *
164
+ * @category getters
165
+ * @since 0.25.0
166
+ */
167
+ export const cursor: (self: Session) => number = internal.cursor
168
+
169
+ /**
170
+ * Lists documented branches whose sources are active at the current cursor.
171
+ * Runtime-dependent choices remain visible with an unavailable reason.
172
+ *
173
+ * @category getters
174
+ * @since 0.25.0
175
+ */
176
+ export const choices: (self: Session) => ReadonlyArray<Choice> = internal.choices
177
+
178
+ /**
179
+ * Takes one documented branch. Taking a branch from the past truncates the
180
+ * future timeline before appending the new frame.
181
+ *
182
+ * @category combinators
183
+ * @since 0.25.0
184
+ */
185
+ export const take: {
186
+ (choiceId: string): (self: Session) => Result.Result<Session, ChoiceNotFound | ChoiceUnavailable>
187
+ (self: Session, choiceId: string): Result.Result<Session, ChoiceNotFound | ChoiceUnavailable>
188
+ } = dual(2, internal.take(api))
189
+
190
+ /**
191
+ * Moves the cursor to an existing frame without changing the timeline.
192
+ *
193
+ * @category combinators
194
+ * @since 0.25.0
195
+ */
196
+ export const seek: {
197
+ (step: number): (self: Session) => Result.Result<Session, StepNotFound>
198
+ (self: Session, step: number): Result.Result<Session, StepNotFound>
199
+ } = dual(2, internal.seek(api))
package/src/index.ts CHANGED
@@ -15,6 +15,6 @@ export * as DevToolsProtocol from "./DevToolsProtocol.js"
15
15
  export * as MachineDocument from "./MachineDocument.js"
16
16
 
17
17
  /**
18
- * @since 0.23.0
18
+ * @since 0.25.0
19
19
  */
20
- export * as MachineSimulator from "./MachineSimulator.js"
20
+ export * as MachineWalkthrough from "./MachineWalkthrough.js"
@@ -0,0 +1,433 @@
1
+ import * as Data from "effect/Data"
2
+ import * as Effect from "effect/Effect"
3
+ import type {
4
+ ELK as ElkApi,
5
+ ELKConstructorArguments,
6
+ ElkExtendedEdge,
7
+ ElkNode,
8
+ ElkPoint,
9
+ ElkPort
10
+ } from "elkjs/lib/elk-api.js"
11
+ import ELKBundle from "elkjs/lib/elk.bundled.js"
12
+ import type { ChartEdge, ChartInitial, ChartModel, ChartNode, ChartRuntimeTarget } from "./chart-model.js"
13
+
14
+ export const maxVisibleFields = 4
15
+ export const maxVisibleActivities = 3
16
+
17
+ export interface ChartPoint {
18
+ readonly x: number
19
+ readonly y: number
20
+ }
21
+
22
+ export interface LaidOutChartNode {
23
+ readonly node: ChartNode
24
+ readonly x: number
25
+ readonly y: number
26
+ readonly width: number
27
+ readonly height: number
28
+ readonly headerHeight: number
29
+ }
30
+
31
+ export interface LaidOutChartInitial {
32
+ readonly initial: ChartInitial
33
+ readonly x: number
34
+ readonly y: number
35
+ readonly width: number
36
+ readonly height: number
37
+ }
38
+
39
+ export interface LaidOutChartRuntimeTarget {
40
+ readonly target: ChartRuntimeTarget
41
+ readonly x: number
42
+ readonly y: number
43
+ readonly width: number
44
+ readonly height: number
45
+ }
46
+
47
+ export interface LaidOutChartTransition {
48
+ readonly kind: "transition"
49
+ readonly edge: ChartEdge
50
+ readonly points: ReadonlyArray<ChartPoint>
51
+ readonly label: ChartPoint
52
+ readonly labelWidth: number
53
+ readonly labelHeight: number
54
+ }
55
+
56
+ export interface LaidOutChartInitialEdge {
57
+ readonly kind: "initial"
58
+ readonly initial: ChartInitial
59
+ readonly points: ReadonlyArray<ChartPoint>
60
+ }
61
+
62
+ export interface LaidOutChart {
63
+ readonly width: number
64
+ readonly height: number
65
+ readonly nodes: ReadonlyArray<LaidOutChartNode>
66
+ readonly initials: ReadonlyArray<LaidOutChartInitial>
67
+ readonly runtimeTargets: ReadonlyArray<LaidOutChartRuntimeTarget>
68
+ readonly edges: ReadonlyArray<LaidOutChartTransition | LaidOutChartInitialEdge>
69
+ }
70
+
71
+ export class ChartLayoutError extends Data.TaggedError("ChartLayoutError")<{
72
+ readonly cause: unknown
73
+ }> {}
74
+
75
+ interface NodeMetric {
76
+ readonly width: number
77
+ readonly height: number
78
+ readonly headerHeight: number
79
+ }
80
+
81
+ const sectionHeight = (length: number, limit: number): number => {
82
+ if (length === 0) return 0
83
+ const visible = Math.min(length, limit)
84
+ return 24 + visible * 22 + (length > limit ? 18 : 0)
85
+ }
86
+
87
+ const nodeMetric = (node: ChartNode): NodeMetric => {
88
+ const headerHeight = Math.max(
89
+ 78,
90
+ 76 +
91
+ sectionHeight(node.fields.length, maxVisibleFields) +
92
+ sectionHeight(node.activities.length, maxVisibleActivities)
93
+ )
94
+ const width = node.type === "choice" || node.type === "history" ? 176 : node.children.length > 0 ? 340 : 276
95
+ return {
96
+ width,
97
+ height: node.children.length === 0 ? headerHeight : Math.max(240, headerHeight + 104),
98
+ headerHeight
99
+ }
100
+ }
101
+
102
+ const ELK = ELKBundle as unknown as new(args?: ELKConstructorArguments) => ElkApi
103
+ const elk = new ELK()
104
+
105
+ const sourcePortId = (edge: ChartEdge): string => `port:${edge.id}:source`
106
+ const targetPortId = (edge: ChartEdge): string => `port:${edge.id}:target`
107
+ const initialNodeId = (initial: ChartInitial): string => `node:${initial.id}`
108
+ const initialTargetPortId = (initial: ChartInitial): string => `port:${initial.id}:target`
109
+ const runtimeNodeId = (target: ChartRuntimeTarget): string => `node:${target.id}`
110
+ const runtimeTargetPortId = (target: ChartRuntimeTarget): string => `port:${target.edgeId}:target`
111
+
112
+ const portsByState = (model: ChartModel): ReadonlyMap<string, ReadonlyArray<ElkPort>> => {
113
+ const ports = new Map<string, Array<ElkPort>>()
114
+ const add = (path: string, id: string, side: "EAST" | "WEST"): void => {
115
+ const statePorts = ports.get(path) ?? []
116
+ statePorts.push({
117
+ id,
118
+ width: 6,
119
+ height: 6,
120
+ layoutOptions: {
121
+ "elk.port.side": side,
122
+ "elk.port.index": String(statePorts.length)
123
+ }
124
+ })
125
+ ports.set(path, statePorts)
126
+ }
127
+
128
+ for (const edge of model.edges) {
129
+ add(edge.source, sourcePortId(edge), "EAST")
130
+ if (edge.kind === "target" && edge.target !== null) {
131
+ add(edge.target, targetPortId(edge), "WEST")
132
+ } else if (edge.kind === "targetless") {
133
+ add(edge.source, targetPortId(edge), "EAST")
134
+ }
135
+ }
136
+ for (const initial of model.initials) add(initial.target, initialTargetPortId(initial), "WEST")
137
+ return ports
138
+ }
139
+
140
+ const labelMetric = (label: string): { readonly width: number; readonly height: number } => ({
141
+ width: Math.min(230, Math.max(72, label.length * 7 + 20)),
142
+ height: 26
143
+ })
144
+
145
+ const makeGraph = (model: ChartModel): ElkNode => {
146
+ const nodesByParent = new Map<string | null, Array<ChartNode>>()
147
+ for (const node of model.nodes) {
148
+ const siblings = nodesByParent.get(node.parent) ?? []
149
+ siblings.push(node)
150
+ nodesByParent.set(node.parent, siblings)
151
+ }
152
+ const initialsByParent = new Map<string | null, Array<ChartInitial>>()
153
+ for (const initial of model.initials) {
154
+ const siblings = initialsByParent.get(initial.parent) ?? []
155
+ siblings.push(initial)
156
+ initialsByParent.set(initial.parent, siblings)
157
+ }
158
+ const runtimeTargetsByParent = new Map<string | null, Array<ChartRuntimeTarget>>()
159
+ for (const target of model.runtimeTargets) {
160
+ const siblings = runtimeTargetsByParent.get(target.parent) ?? []
161
+ siblings.push(target)
162
+ runtimeTargetsByParent.set(target.parent, siblings)
163
+ }
164
+ const ports = portsByState(model)
165
+
166
+ const children = (parent: string | null): Array<ElkNode> => [
167
+ ...(initialsByParent.get(parent) ?? []).map((initial): ElkNode => ({
168
+ id: initialNodeId(initial),
169
+ width: 14,
170
+ height: 14
171
+ })),
172
+ ...(nodesByParent.get(parent) ?? []).map((node): ElkNode => {
173
+ const metric = nodeMetric(node)
174
+ const descendants = children(node.path)
175
+ const common = {
176
+ id: node.path,
177
+ ports: [...ports.get(node.path) ?? []]
178
+ }
179
+ if (descendants.length === 0) {
180
+ return {
181
+ ...common,
182
+ width: metric.width,
183
+ height: metric.height,
184
+ layoutOptions: {
185
+ "elk.portConstraints": "FIXED_ORDER",
186
+ "elk.spacing.portPort": "22"
187
+ }
188
+ }
189
+ }
190
+ return {
191
+ ...common,
192
+ children: descendants,
193
+ layoutOptions: {
194
+ "elk.algorithm": "layered",
195
+ "elk.direction": "RIGHT",
196
+ "elk.padding": `[top=${metric.headerHeight + 28},left=28,bottom=28,right=28]`,
197
+ "elk.nodeSize.constraints": "MINIMUM_SIZE",
198
+ "elk.nodeSize.minimum": `(${metric.width}, ${metric.height})`,
199
+ "elk.portConstraints": "FIXED_ORDER",
200
+ "elk.spacing.portPort": "22",
201
+ "elk.spacing.nodeNode": "44",
202
+ "elk.layered.spacing.nodeNodeBetweenLayers": "108"
203
+ }
204
+ }
205
+ }),
206
+ ...(runtimeTargetsByParent.get(parent) ?? []).map((target): ElkNode => ({
207
+ id: runtimeNodeId(target),
208
+ width: 118,
209
+ height: 34,
210
+ ports: [{
211
+ id: runtimeTargetPortId(target),
212
+ width: 6,
213
+ height: 6,
214
+ layoutOptions: { "elk.port.side": "WEST" }
215
+ }],
216
+ layoutOptions: { "elk.portConstraints": "FIXED_SIDE" }
217
+ }))
218
+ ]
219
+
220
+ return {
221
+ id: "chart-root",
222
+ children: children(null),
223
+ edges: [
224
+ ...model.edges.map((edge): ElkExtendedEdge => {
225
+ const label = labelMetric(edge.label)
226
+ return {
227
+ id: edge.id,
228
+ sources: [sourcePortId(edge)],
229
+ targets: [targetPortId(edge)],
230
+ labels: [{ text: edge.label, width: label.width, height: label.height }]
231
+ }
232
+ }),
233
+ ...model.initials.map((initial): ElkExtendedEdge => ({
234
+ id: initial.id,
235
+ sources: [initialNodeId(initial)],
236
+ targets: [initialTargetPortId(initial)]
237
+ }))
238
+ ],
239
+ layoutOptions: {
240
+ "elk.algorithm": "layered",
241
+ "elk.direction": "RIGHT",
242
+ "elk.hierarchyHandling": "INCLUDE_CHILDREN",
243
+ "elk.edgeRouting": "ORTHOGONAL",
244
+ "elk.padding": "[top=44,left=44,bottom=44,right=44]",
245
+ "elk.spacing.nodeNode": "64",
246
+ "elk.layered.spacing.nodeNodeBetweenLayers": "148",
247
+ "elk.layered.spacing.edgeNodeBetweenLayers": "42",
248
+ "elk.layered.spacing.edgeEdgeBetweenLayers": "26",
249
+ "elk.spacing.edgeNode": "28",
250
+ "elk.spacing.edgeEdge": "20",
251
+ "elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
252
+ "elk.layered.crossingMinimization.greedySwitchHierarchical.type": "TWO_SIDED",
253
+ "elk.layered.nodePlacement.favorStraightEdges": "true",
254
+ "elk.layered.mergeHierarchyEdges": "false",
255
+ "elk.layered.thoroughness": "12",
256
+ "elk.randomSeed": "1"
257
+ }
258
+ }
259
+ }
260
+
261
+ const add = (left: ChartPoint, right: ChartPoint): ChartPoint => ({
262
+ x: left.x + right.x,
263
+ y: left.y + right.y
264
+ })
265
+
266
+ const compactPoints = (points: ReadonlyArray<ChartPoint>): ReadonlyArray<ChartPoint> => {
267
+ const result: Array<ChartPoint> = []
268
+ for (const point of points) {
269
+ const previous = result.at(-1)
270
+ if (previous !== undefined && previous.x === point.x && previous.y === point.y) continue
271
+ const beforePrevious = result.at(-2)
272
+ if (
273
+ beforePrevious !== undefined && previous !== undefined &&
274
+ (beforePrevious.x === previous.x && previous.x === point.x ||
275
+ beforePrevious.y === previous.y && previous.y === point.y)
276
+ ) {
277
+ result[result.length - 1] = point
278
+ } else {
279
+ result.push(point)
280
+ }
281
+ }
282
+ return result
283
+ }
284
+
285
+ const edgePoints = (edge: ElkExtendedEdge, offset: ChartPoint): ReadonlyArray<ChartPoint> | undefined => {
286
+ const sections = edge.sections
287
+ if (sections === undefined || sections.length === 0) return undefined
288
+ const byId = new Map(sections.map((section) => [section.id, section]))
289
+ let section =
290
+ sections.find((candidate) =>
291
+ candidate.incomingShape !== undefined || candidate.incomingSections === undefined ||
292
+ candidate.incomingSections.length === 0
293
+ ) ?? sections[0]
294
+ const points: Array<ElkPoint> = []
295
+ const visited = new Set<string>()
296
+ while (section !== undefined && !visited.has(section.id)) {
297
+ visited.add(section.id)
298
+ if (points.length === 0) points.push(section.startPoint)
299
+ points.push(...(section.bendPoints ?? []), section.endPoint)
300
+ const next = section.outgoingSections?.[0]
301
+ section = next === undefined ? undefined : byId.get(next)
302
+ }
303
+ return points.length < 2 ? undefined : compactPoints(points.map((point) => add(point, offset)))
304
+ }
305
+
306
+ const midpoint = (points: ReadonlyArray<ChartPoint>): ChartPoint => {
307
+ const lengths = points.slice(1).map((point, index) => {
308
+ const previous = points[index]!
309
+ return Math.abs(point.x - previous.x) + Math.abs(point.y - previous.y)
310
+ })
311
+ const total = lengths.reduce((sum, length) => sum + length, 0)
312
+ let remaining = total / 2
313
+ for (let index = 0; index < lengths.length; index++) {
314
+ const length = lengths[index]!
315
+ const start = points[index]!
316
+ const end = points[index + 1]!
317
+ if (remaining <= length) {
318
+ const ratio = length === 0 ? 0 : remaining / length
319
+ return {
320
+ x: start.x + (end.x - start.x) * ratio,
321
+ y: start.y + (end.y - start.y) * ratio
322
+ }
323
+ }
324
+ remaining -= length
325
+ }
326
+ return points.at(-1)!
327
+ }
328
+
329
+ const expandTargetlessLoop = (points: ReadonlyArray<ChartPoint>): ReadonlyArray<ChartPoint> => {
330
+ const start = points[0]
331
+ const end = points.at(-1)
332
+ if (start === undefined || end === undefined) return points
333
+ const outerX = Math.max(...points.map(({ x }) => x)) + 30
334
+ return compactPoints([
335
+ start,
336
+ { x: outerX, y: start.y },
337
+ { x: outerX, y: end.y },
338
+ end
339
+ ])
340
+ }
341
+
342
+ const collectLayout = (model: ChartModel, graph: ElkNode): LaidOutChart => {
343
+ const chartNodes = new Map(model.nodes.map((node) => [node.path, node]))
344
+ const chartInitials = new Map(model.initials.map((initial) => [initialNodeId(initial), initial]))
345
+ const chartRuntimeTargets = new Map(model.runtimeTargets.map((target) => [runtimeNodeId(target), target]))
346
+ const offsets = new Map<string, ChartPoint>([[graph.id, { x: 0, y: 0 }]])
347
+ const nodes: Array<LaidOutChartNode> = []
348
+ const initials: Array<LaidOutChartInitial> = []
349
+ const runtimeTargets: Array<LaidOutChartRuntimeTarget> = []
350
+
351
+ const visit = (node: ElkNode, parentOffset: ChartPoint): void => {
352
+ const absolute = add(parentOffset, { x: node.x ?? 0, y: node.y ?? 0 })
353
+ offsets.set(node.id, absolute)
354
+ const chartNode = chartNodes.get(node.id)
355
+ if (chartNode !== undefined) {
356
+ const metric = nodeMetric(chartNode)
357
+ nodes.push({
358
+ node: chartNode,
359
+ x: absolute.x,
360
+ y: absolute.y,
361
+ width: node.width ?? metric.width,
362
+ height: node.height ?? metric.height,
363
+ headerHeight: metric.headerHeight
364
+ })
365
+ }
366
+ const initial = chartInitials.get(node.id)
367
+ if (initial !== undefined) {
368
+ initials.push({
369
+ initial,
370
+ x: absolute.x,
371
+ y: absolute.y,
372
+ width: node.width ?? 14,
373
+ height: node.height ?? 14
374
+ })
375
+ }
376
+ const runtimeTarget = chartRuntimeTargets.get(node.id)
377
+ if (runtimeTarget !== undefined) {
378
+ runtimeTargets.push({
379
+ target: runtimeTarget,
380
+ x: absolute.x,
381
+ y: absolute.y,
382
+ width: node.width ?? 118,
383
+ height: node.height ?? 34
384
+ })
385
+ }
386
+ for (const child of node.children ?? []) visit(child, absolute)
387
+ }
388
+ for (const child of graph.children ?? []) visit(child, { x: 0, y: 0 })
389
+
390
+ const chartEdges = new Map(model.edges.map((edge) => [edge.id, edge]))
391
+ const initialEdges = new Map(model.initials.map((initial) => [initial.id, initial]))
392
+ const edges = (graph.edges ?? []).flatMap((edge): ReadonlyArray<LaidOutChartTransition | LaidOutChartInitialEdge> => {
393
+ const offset = offsets.get(edge.container ?? graph.id) ?? { x: 0, y: 0 }
394
+ const points = edgePoints(edge, offset)
395
+ if (points === undefined) return []
396
+ const chartEdge = chartEdges.get(edge.id)
397
+ if (chartEdge !== undefined) {
398
+ const metric = labelMetric(chartEdge.label)
399
+ const label = edge.labels?.[0]
400
+ const transitionPoints = chartEdge.kind === "targetless" ? expandTargetlessLoop(points) : points
401
+ return [{
402
+ kind: "transition",
403
+ edge: chartEdge,
404
+ points: transitionPoints,
405
+ label: label?.x === undefined || label.y === undefined
406
+ ? midpoint(transitionPoints)
407
+ : add(offset, {
408
+ x: label.x + (label.width ?? metric.width) / 2,
409
+ y: label.y + (label.height ?? metric.height) / 2
410
+ }),
411
+ labelWidth: label?.width ?? metric.width,
412
+ labelHeight: label?.height ?? metric.height
413
+ }]
414
+ }
415
+ const initial = initialEdges.get(edge.id)
416
+ return initial === undefined ? [] : [{ kind: "initial", initial, points }]
417
+ })
418
+
419
+ return {
420
+ width: Math.max(360, graph.width ?? 0),
421
+ height: Math.max(280, graph.height ?? 0),
422
+ nodes,
423
+ initials,
424
+ runtimeTargets,
425
+ edges
426
+ }
427
+ }
428
+
429
+ export const layoutChart = (model: ChartModel): Effect.Effect<LaidOutChart, ChartLayoutError> =>
430
+ Effect.tryPromise({
431
+ try: () => elk.layout(makeGraph(model)),
432
+ catch: (cause) => new ChartLayoutError({ cause })
433
+ }).pipe(Effect.map((graph) => collectLayout(model, graph)))