@typeonce/effect-machine-devtools 0.24.0 → 0.26.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 (82) hide show
  1. package/NOTICE +4 -0
  2. package/README.md +13 -13
  3. package/dist/DevToolsProtocol.d.ts +66 -630
  4. package/dist/DevToolsProtocol.d.ts.map +1 -1
  5. package/dist/DevToolsProtocol.js +0 -190
  6. package/dist/DevToolsProtocol.js.map +1 -1
  7. package/dist/MachineDocument.d.ts +38 -18
  8. package/dist/MachineDocument.d.ts.map +1 -1
  9. package/dist/MachineDocument.js +14 -12
  10. package/dist/MachineDocument.js.map +1 -1
  11. package/dist/MachineRegistry.d.ts +22 -2
  12. package/dist/MachineRegistry.d.ts.map +1 -1
  13. package/dist/MachineWalkthrough.d.ts +171 -0
  14. package/dist/MachineWalkthrough.d.ts.map +1 -0
  15. package/dist/MachineWalkthrough.js +98 -0
  16. package/dist/MachineWalkthrough.js.map +1 -0
  17. package/dist/ProjectInspector.d.ts +0 -2
  18. package/dist/ProjectInspector.d.ts.map +1 -1
  19. package/dist/ProjectInspector.js.map +1 -1
  20. package/dist/client/assets/index-BsorPSDS.css +1 -0
  21. package/dist/client/assets/index-eiptehRd.js +37 -0
  22. package/dist/client/index.html +4 -4
  23. package/dist/index.d.ts +2 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +2 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/internal/devServer.d.ts.map +1 -1
  28. package/dist/internal/devServer.js +4 -64
  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 +7 -263
  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 +3 -1
  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 +1 -11
  42. package/dist/internal/projectInspector.js.map +1 -1
  43. package/index.html +2 -2
  44. package/package.json +8 -7
  45. package/src/DevToolsProtocol.ts +0 -285
  46. package/src/MachineDocument.ts +21 -19
  47. package/src/MachineWalkthrough.ts +199 -0
  48. package/src/ProjectInspector.ts +0 -5
  49. package/src/index.ts +2 -2
  50. package/src/internal/browser/chart-layout-policy.ts +206 -0
  51. package/src/internal/browser/chart-layout.ts +686 -0
  52. package/src/internal/browser/chart-model.ts +244 -0
  53. package/src/internal/browser/chart-renderer.ts +774 -0
  54. package/src/internal/browser/example-machine.ts +15 -6
  55. package/src/internal/browser/input-form.ts +1 -418
  56. package/src/internal/browser/invoke-outcomes-example.ts +231 -0
  57. package/src/internal/browser/parallel-completion-example.ts +199 -0
  58. package/src/internal/browser/planner-example.ts +2 -0
  59. package/src/internal/browser/protocol-events-example.ts +200 -0
  60. package/src/internal/browser/styles.css +974 -342
  61. package/src/internal/browser/transition-semantics-example.ts +245 -0
  62. package/src/internal/browser/visualizer-analysis.ts +52 -0
  63. package/src/internal/browser/visualizer-app.ts +874 -706
  64. package/src/internal/browser/visualizer-model.ts +104 -0
  65. package/src/internal/devServer.ts +4 -82
  66. package/src/internal/evaluationWorker.ts +7 -371
  67. package/src/internal/machineDocument.ts +3 -1
  68. package/src/internal/machineWalkthrough.ts +355 -0
  69. package/src/internal/projectInspector.ts +3 -31
  70. package/dist/MachineSimulator.d.ts +0 -169
  71. package/dist/MachineSimulator.d.ts.map +0 -1
  72. package/dist/MachineSimulator.js +0 -98
  73. package/dist/MachineSimulator.js.map +0 -1
  74. package/dist/client/assets/index-B49Tjawc.js +0 -14
  75. package/dist/client/assets/index-BKH0cOG2.css +0 -1
  76. package/dist/internal/machineSimulator.d.ts +0 -5
  77. package/dist/internal/machineSimulator.d.ts.map +0 -1
  78. package/dist/internal/machineSimulator.js +0 -156
  79. package/dist/internal/machineSimulator.js.map +0 -1
  80. package/src/MachineSimulator.ts +0 -154
  81. package/src/internal/browser/simulation-client.ts +0 -20
  82. package/src/internal/machineSimulator.ts +0 -199
@@ -0,0 +1,686 @@
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 ChartPortSide, makeChartLayoutPolicy } from "./chart-layout-policy.js"
13
+ import type { ChartEdge, ChartInitial, ChartModel, ChartNode, ChartRuntimeTarget } from "./chart-model.js"
14
+
15
+ export const maxVisibleFields = 4
16
+ export const maxVisibleActivities = 3
17
+
18
+ export interface ChartPoint {
19
+ readonly x: number
20
+ readonly y: number
21
+ }
22
+
23
+ export interface LaidOutChartNode {
24
+ readonly node: ChartNode
25
+ readonly x: number
26
+ readonly y: number
27
+ readonly width: number
28
+ readonly height: number
29
+ readonly headerHeight: number
30
+ }
31
+
32
+ export interface LaidOutChartInitial {
33
+ readonly initial: ChartInitial
34
+ readonly x: number
35
+ readonly y: number
36
+ readonly width: number
37
+ readonly height: number
38
+ }
39
+
40
+ export interface LaidOutChartRuntimeTarget {
41
+ readonly target: ChartRuntimeTarget
42
+ readonly x: number
43
+ readonly y: number
44
+ readonly width: number
45
+ readonly height: number
46
+ }
47
+
48
+ export interface LaidOutChartRegion {
49
+ readonly kind: "unconnected"
50
+ readonly parent: string | null
51
+ readonly nodePaths: ReadonlyArray<string>
52
+ readonly x: number
53
+ readonly y: number
54
+ readonly width: number
55
+ readonly height: number
56
+ }
57
+
58
+ export interface LaidOutChartTransition {
59
+ readonly kind: "transition"
60
+ readonly edge: ChartEdge
61
+ readonly points: ReadonlyArray<ChartPoint>
62
+ readonly label: ChartPoint
63
+ readonly labelWidth: number
64
+ readonly labelHeight: number
65
+ }
66
+
67
+ export interface LaidOutChartInitialEdge {
68
+ readonly kind: "initial"
69
+ readonly initial: ChartInitial
70
+ readonly points: ReadonlyArray<ChartPoint>
71
+ }
72
+
73
+ export interface LaidOutChart {
74
+ readonly width: number
75
+ readonly height: number
76
+ readonly regions: ReadonlyArray<LaidOutChartRegion>
77
+ readonly nodes: ReadonlyArray<LaidOutChartNode>
78
+ readonly initials: ReadonlyArray<LaidOutChartInitial>
79
+ readonly runtimeTargets: ReadonlyArray<LaidOutChartRuntimeTarget>
80
+ readonly edges: ReadonlyArray<LaidOutChartTransition | LaidOutChartInitialEdge>
81
+ }
82
+
83
+ export class ChartLayoutError extends Data.TaggedError("ChartLayoutError")<{
84
+ readonly cause: unknown
85
+ }> {}
86
+
87
+ interface NodeMetric {
88
+ readonly width: number
89
+ readonly height: number
90
+ readonly headerHeight: number
91
+ }
92
+
93
+ const sectionHeight = (length: number, limit: number): number => {
94
+ if (length === 0) return 0
95
+ const visible = Math.min(length, limit)
96
+ return 24 + visible * 22 + (length > limit ? 18 : 0)
97
+ }
98
+
99
+ const nodeMetric = (node: ChartNode): NodeMetric => {
100
+ const headerHeight = Math.max(
101
+ 78,
102
+ 76 +
103
+ sectionHeight(node.fields.length, maxVisibleFields) +
104
+ sectionHeight(node.activities.length, maxVisibleActivities)
105
+ )
106
+ const width = node.type === "choice" || node.type === "history" ? 176 : node.children.length > 0 ? 340 : 276
107
+ return {
108
+ width,
109
+ height: node.children.length === 0 ? headerHeight : Math.max(240, headerHeight + 104),
110
+ headerHeight
111
+ }
112
+ }
113
+
114
+ const ELK = ELKBundle as unknown as new(args?: ELKConstructorArguments) => ElkApi
115
+ const elk = new ELK()
116
+
117
+ const sourcePortId = (edge: ChartEdge): string => `port:${edge.id}:source`
118
+ const targetPortId = (edge: ChartEdge): string => `port:${edge.id}:target`
119
+ const initialNodeId = (initial: ChartInitial): string => `node:${initial.id}`
120
+ const initialTargetPortId = (initial: ChartInitial): string => `port:${initial.id}:target`
121
+ const runtimeNodeId = (target: ChartRuntimeTarget): string => `node:${target.id}`
122
+ const runtimeTargetPortId = (target: ChartRuntimeTarget): string => `port:${target.edgeId}:target`
123
+ const unconnectedRegionId = (parent: string | null): string => `region:unconnected:${parent ?? "root"}`
124
+ const isSelfTransition = (edge: ChartEdge): boolean => edge.kind === "targetless" || edge.target === edge.source
125
+
126
+ interface UnconnectedRegion {
127
+ readonly id: string
128
+ readonly parent: string | null
129
+ readonly nodePaths: ReadonlyArray<string>
130
+ }
131
+
132
+ const unconnectedRegions = (
133
+ model: ChartModel,
134
+ policy: ReturnType<typeof makeChartLayoutPolicy>
135
+ ): ReadonlyArray<UnconnectedRegion> => {
136
+ const parents = new Set<string | null>([null, ...model.nodes.map(({ parent }) => parent)])
137
+ return [...parents].flatMap((parent): ReadonlyArray<UnconnectedRegion> => {
138
+ if (parent !== null && !policy.node(parent).staticPath) return []
139
+ const nodePaths = policy.children(parent)
140
+ .filter(({ path }) => !policy.node(path).staticPath)
141
+ .map(({ path }) => path)
142
+ return nodePaths.length === 0
143
+ ? []
144
+ : [{ id: unconnectedRegionId(parent), parent, nodePaths }]
145
+ })
146
+ }
147
+
148
+ const portsByState = (
149
+ model: ChartModel,
150
+ edgePolicy: ReturnType<typeof makeChartLayoutPolicy>["edge"]
151
+ ): ReadonlyMap<string, ReadonlyArray<ElkPort>> => {
152
+ const ports = new Map<string, Array<ElkPort>>()
153
+ const add = (path: string, id: string, side: ChartPortSide): void => {
154
+ const statePorts = ports.get(path) ?? []
155
+ statePorts.push({
156
+ id,
157
+ width: 6,
158
+ height: 6,
159
+ layoutOptions: {
160
+ "elk.port.side": side
161
+ }
162
+ })
163
+ ports.set(path, statePorts)
164
+ }
165
+
166
+ for (const edge of model.edges) {
167
+ const policy = edgePolicy(edge)
168
+ add(edge.source, sourcePortId(edge), policy.sourceSide)
169
+ if (edge.kind === "target" && edge.target !== null) {
170
+ add(edge.target, targetPortId(edge), policy.targetSide)
171
+ } else if (edge.kind === "targetless") {
172
+ add(edge.source, targetPortId(edge), policy.targetSide)
173
+ }
174
+ }
175
+ for (const initial of model.initials) add(initial.target, initialTargetPortId(initial), "WEST")
176
+ return ports
177
+ }
178
+
179
+ const labelMetric = (label: string): { readonly width: number; readonly height: number } => ({
180
+ width: Math.min(230, Math.max(72, label.length * 7 + 20)),
181
+ height: 26
182
+ })
183
+
184
+ const makeGraph = (
185
+ model: ChartModel,
186
+ policy: ReturnType<typeof makeChartLayoutPolicy>,
187
+ regions: ReadonlyArray<UnconnectedRegion>
188
+ ): ElkNode => {
189
+ const nodesByPath = new Map(model.nodes.map((node) => [node.path, node]))
190
+ const regionsByParent = new Map(regions.map((region) => [region.parent, region]))
191
+ 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
+ )
199
+ const initialsByParent = new Map<string | null, Array<ChartInitial>>()
200
+ for (const initial of model.initials) {
201
+ const siblings = initialsByParent.get(initial.parent) ?? []
202
+ siblings.push(initial)
203
+ initialsByParent.set(initial.parent, siblings)
204
+ }
205
+ const runtimeTargetsByParent = new Map<string | null, Array<ChartRuntimeTarget>>()
206
+ for (const target of model.runtimeTargets) {
207
+ const siblings = runtimeTargetsByParent.get(target.parent) ?? []
208
+ siblings.push(target)
209
+ runtimeTargetsByParent.set(target.parent, siblings)
210
+ }
211
+ const ports = portsByState(model, policy.edge)
212
+
213
+ const initialNode = (initial: ChartInitial): ElkNode => ({
214
+ id: initialNodeId(initial),
215
+ width: 14,
216
+ height: 14,
217
+ layoutOptions: {
218
+ "elk.layered.layering.layerConstraint": "FIRST"
219
+ }
220
+ })
221
+ const runtimeNode = (target: ChartRuntimeTarget): ElkNode => ({
222
+ id: runtimeNodeId(target),
223
+ width: 118,
224
+ height: 34,
225
+ ports: [{
226
+ id: runtimeTargetPortId(target),
227
+ width: 6,
228
+ height: 6,
229
+ layoutOptions: { "elk.port.side": "WEST" }
230
+ }],
231
+ layoutOptions: { "elk.portConstraints": "FIXED_SIDE" }
232
+ })
233
+
234
+ const stateNode = (node: ChartNode, suppressUnconnectedRegion: boolean): ElkNode => {
235
+ const metric = nodeMetric(node)
236
+ const nodePolicy = policy.node(node.path)
237
+ const descendants = children(node.path, suppressUnconnectedRegion || !nodePolicy.staticPath)
238
+ const bottomPadding = 28 + (selfLoopParents.has(node.path) ? chartSelfLoopParentAllowance : 0)
239
+ const common = {
240
+ id: node.path,
241
+ ports: [...ports.get(node.path) ?? []],
242
+ layoutOptions: {
243
+ "elk.portConstraints": "FIXED_SIDE",
244
+ "elk.spacing.portPort": "22",
245
+ ...(nodePolicy.layerConstraint === null
246
+ ? {}
247
+ : { "elk.layered.layering.layerConstraint": nodePolicy.layerConstraint })
248
+ }
249
+ }
250
+ if (descendants.length === 0) {
251
+ return {
252
+ ...common,
253
+ width: metric.width,
254
+ height: metric.height
255
+ }
256
+ }
257
+ return {
258
+ ...common,
259
+ children: descendants,
260
+ layoutOptions: {
261
+ ...common.layoutOptions,
262
+ "elk.algorithm": "layered",
263
+ "elk.direction": node.type === "parallel" ? "DOWN" : "RIGHT",
264
+ "elk.padding": `[top=${metric.headerHeight + 28},left=28,bottom=${bottomPadding},right=28]`,
265
+ "elk.nodeSize.constraints": "MINIMUM_SIZE",
266
+ "elk.nodeSize.minimum": `(${metric.width}, ${metric.height})`,
267
+ "elk.spacing.nodeNode": "44",
268
+ "elk.layered.spacing.nodeNodeBetweenLayers": node.type === "parallel" ? "64" : "108"
269
+ }
270
+ }
271
+ }
272
+
273
+ function children(parent: string | null, suppressUnconnectedRegion = false): Array<ElkNode> {
274
+ const region = suppressUnconnectedRegion ? undefined : regionsByParent.get(parent)
275
+ const regionPaths = new Set(region?.nodePaths ?? [])
276
+ const initials = initialsByParent.get(parent) ?? []
277
+ const runtimeTargets = runtimeTargetsByParent.get(parent) ?? []
278
+ const states = policy.children(parent)
279
+ const regular: Array<ElkNode> = [
280
+ ...initials.filter(({ target }) => !regionPaths.has(target)).map(initialNode),
281
+ ...states.filter(({ path }) => !regionPaths.has(path)).map((node) => stateNode(node, suppressUnconnectedRegion)),
282
+ ...runtimeTargets
283
+ .filter(({ edgeId }) => !regionPaths.has(sourceByEdgeId.get(edgeId) ?? ""))
284
+ .map(runtimeNode)
285
+ ]
286
+ if (region === undefined) return regular
287
+
288
+ const regionChildren: Array<ElkNode> = [
289
+ ...initials.filter(({ target }) => regionPaths.has(target)).map(initialNode),
290
+ ...states.filter(({ path }) => regionPaths.has(path)).map((node) => stateNode(node, true)),
291
+ ...runtimeTargets
292
+ .filter(({ edgeId }) => regionPaths.has(sourceByEdgeId.get(edgeId) ?? ""))
293
+ .map(runtimeNode)
294
+ ]
295
+ regular.push({
296
+ id: region.id,
297
+ children: regionChildren,
298
+ layoutOptions: {
299
+ "elk.algorithm": "layered",
300
+ "elk.direction": "RIGHT",
301
+ "elk.padding": "[top=54,left=24,bottom=24,right=24]",
302
+ "elk.layered.layering.layerConstraint": "LAST",
303
+ "elk.spacing.nodeNode": "52",
304
+ "elk.layered.spacing.nodeNodeBetweenLayers": "128"
305
+ }
306
+ })
307
+ return regular
308
+ }
309
+
310
+ return {
311
+ id: "chart-root",
312
+ children: children(null),
313
+ edges: [
314
+ ...model.edges.map((edge): ElkExtendedEdge => {
315
+ const label = labelMetric(edge.label)
316
+ const edgeLayout = policy.edge(edge)
317
+ return {
318
+ id: edge.id,
319
+ sources: [sourcePortId(edge)],
320
+ targets: [targetPortId(edge)],
321
+ labels: [{ text: edge.label, width: label.width, height: label.height }],
322
+ layoutOptions: {
323
+ "elk.layered.priority.direction": edgeLayout.direction === "forward" ? "10" : "1",
324
+ "elk.layered.priority.shortness": "5",
325
+ "elk.layered.priority.straightness": "5"
326
+ }
327
+ }
328
+ }),
329
+ ...model.initials.map((initial): ElkExtendedEdge => ({
330
+ id: initial.id,
331
+ sources: [initialNodeId(initial)],
332
+ targets: [initialTargetPortId(initial)],
333
+ layoutOptions: {
334
+ "elk.layered.priority.direction": "100",
335
+ "elk.layered.priority.shortness": "100",
336
+ "elk.layered.priority.straightness": "100"
337
+ }
338
+ }))
339
+ ],
340
+ layoutOptions: {
341
+ "elk.algorithm": "layered",
342
+ "elk.direction": "RIGHT",
343
+ "elk.hierarchyHandling": "INCLUDE_CHILDREN",
344
+ "elk.edgeRouting": "ORTHOGONAL",
345
+ "elk.padding": "[top=44,left=44,bottom=44,right=44]",
346
+ "elk.spacing.nodeNode": "64",
347
+ "elk.layered.spacing.nodeNodeBetweenLayers": "148",
348
+ "elk.layered.spacing.edgeNodeBetweenLayers": "42",
349
+ "elk.layered.spacing.edgeEdgeBetweenLayers": "26",
350
+ "elk.spacing.edgeNode": "28",
351
+ "elk.spacing.edgeEdge": "20",
352
+ "elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES",
353
+ "elk.layered.considerModelOrder.portModelOrder": "false",
354
+ "elk.layered.considerModelOrder.crossingCounterNodeInfluence": "0.001",
355
+ "elk.layered.considerModelOrder.components": "FORCE_MODEL_ORDER",
356
+ "elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
357
+ "elk.layered.crossingMinimization.hierarchicalSweepiness": "1",
358
+ "elk.layered.crossingMinimization.greedySwitchHierarchical.type": "TWO_SIDED",
359
+ "elk.layered.nodePlacement.favorStraightEdges": "true",
360
+ "elk.layered.mergeHierarchyEdges": "false",
361
+ "elk.layered.thoroughness": "12",
362
+ "elk.randomSeed": "1"
363
+ }
364
+ }
365
+ }
366
+
367
+ const add = (left: ChartPoint, right: ChartPoint): ChartPoint => ({
368
+ x: left.x + right.x,
369
+ y: left.y + right.y
370
+ })
371
+
372
+ const compactPoints = (points: ReadonlyArray<ChartPoint>): ReadonlyArray<ChartPoint> => {
373
+ const result: Array<ChartPoint> = []
374
+ for (const point of points) {
375
+ const previous = result.at(-1)
376
+ if (previous !== undefined && previous.x === point.x && previous.y === point.y) continue
377
+ const beforePrevious = result.at(-2)
378
+ if (
379
+ beforePrevious !== undefined && previous !== undefined &&
380
+ (beforePrevious.x === previous.x && previous.x === point.x ||
381
+ beforePrevious.y === previous.y && previous.y === point.y)
382
+ ) {
383
+ result[result.length - 1] = point
384
+ } else {
385
+ result.push(point)
386
+ }
387
+ }
388
+ return result
389
+ }
390
+
391
+ const edgePoints = (edge: ElkExtendedEdge, offset: ChartPoint): ReadonlyArray<ChartPoint> | undefined => {
392
+ const sections = edge.sections
393
+ if (sections === undefined || sections.length === 0) return undefined
394
+ const byId = new Map(sections.map((section) => [section.id, section]))
395
+ let section =
396
+ sections.find((candidate) =>
397
+ candidate.incomingShape !== undefined || candidate.incomingSections === undefined ||
398
+ candidate.incomingSections.length === 0
399
+ ) ?? sections[0]
400
+ const points: Array<ElkPoint> = []
401
+ const visited = new Set<string>()
402
+ while (section !== undefined && !visited.has(section.id)) {
403
+ visited.add(section.id)
404
+ if (points.length === 0) points.push(section.startPoint)
405
+ points.push(...(section.bendPoints ?? []), section.endPoint)
406
+ const next = section.outgoingSections?.[0]
407
+ section = next === undefined ? undefined : byId.get(next)
408
+ }
409
+ return points.length < 2 ? undefined : compactPoints(points.map((point) => add(point, offset)))
410
+ }
411
+
412
+ const midpoint = (points: ReadonlyArray<ChartPoint>): ChartPoint => {
413
+ const lengths = points.slice(1).map((point, index) => {
414
+ const previous = points[index]!
415
+ return Math.abs(point.x - previous.x) + Math.abs(point.y - previous.y)
416
+ })
417
+ const total = lengths.reduce((sum, length) => sum + length, 0)
418
+ let remaining = total / 2
419
+ for (let index = 0; index < lengths.length; index++) {
420
+ const length = lengths[index]!
421
+ const start = points[index]!
422
+ const end = points[index + 1]!
423
+ if (remaining <= length) {
424
+ const ratio = length === 0 ? 0 : remaining / length
425
+ return {
426
+ x: start.x + (end.x - start.x) * ratio,
427
+ y: start.y + (end.y - start.y) * ratio
428
+ }
429
+ }
430
+ remaining -= length
431
+ }
432
+ return points.at(-1)!
433
+ }
434
+
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
+ const longestSegment = (
462
+ points: ReadonlyArray<ChartPoint>,
463
+ matches: (start: ChartPoint, end: ChartPoint) => boolean,
464
+ length: (start: ChartPoint, end: ChartPoint) => number
465
+ ): readonly [ChartPoint, ChartPoint] | undefined => {
466
+ let result: readonly [ChartPoint, ChartPoint] | undefined
467
+ let resultLength = -1
468
+ for (let index = 1; index < points.length; index++) {
469
+ const start = points[index - 1]!
470
+ const end = points[index]!
471
+ if (!matches(start, end)) continue
472
+ const candidateLength = length(start, end)
473
+ if (candidateLength > resultLength) {
474
+ result = [start, end]
475
+ resultLength = candidateLength
476
+ }
477
+ }
478
+ return result
479
+ }
480
+
481
+ export const selfLoopLabelPosition = (
482
+ points: ReadonlyArray<ChartPoint>,
483
+ labelWidth: number,
484
+ labelHeight: number
485
+ ): ChartPoint => {
486
+ const start = points[0]
487
+ const end = points.at(-1)
488
+ if (start === undefined || end === undefined) return midpoint(points)
489
+
490
+ if (Math.abs(start.y - end.y) <= Math.abs(start.x - end.x)) {
491
+ const outerY = Math.max(...points.map(({ y }) => y))
492
+ const segment = longestSegment(
493
+ points,
494
+ (left, right) => left.y === outerY && right.y === outerY,
495
+ (left, right) => Math.abs(right.x - left.x)
496
+ )
497
+ return {
498
+ x: segment === undefined ? (start.x + end.x) / 2 : (segment[0].x + segment[1].x) / 2,
499
+ y: outerY + chartSelfLoopLabelGap + labelHeight / 2
500
+ }
501
+ }
502
+
503
+ const outerX = Math.max(...points.map(({ x }) => x))
504
+ const segment = longestSegment(
505
+ points,
506
+ (top, bottom) => top.x === outerX && bottom.x === outerX,
507
+ (top, bottom) => Math.abs(bottom.y - top.y)
508
+ )
509
+ return {
510
+ x: outerX + chartSelfLoopLabelGap + labelWidth / 2,
511
+ y: segment === undefined ? (start.y + end.y) / 2 : (segment[0].y + segment[1].y) / 2
512
+ }
513
+ }
514
+
515
+ export const ensureChartEdgeTerminalClearance = (
516
+ points: ReadonlyArray<ChartPoint>
517
+ ): ReadonlyArray<ChartPoint> => {
518
+ const end = points.at(-1)
519
+ const bend = points.at(-2)
520
+ if (end === undefined || bend === undefined || points.length < 3) return points
521
+ const horizontal = end.y === bend.y
522
+ const length = horizontal ? Math.abs(end.x - bend.x) : Math.abs(end.y - bend.y)
523
+ if (length >= chartEdgeTerminalClearance) return points
524
+
525
+ const result = points.map((point) => ({ ...point }))
526
+ if (horizontal) {
527
+ const direction = Math.sign(end.x - bend.x)
528
+ if (direction === 0) return points
529
+ let first = points.length - 2
530
+ while (first > 0 && points[first - 1]!.x === bend.x) first--
531
+ if (first === 0) return points
532
+ const x = end.x - direction * chartEdgeTerminalClearance
533
+ for (let index = first; index < points.length - 1; index++) result[index]!.x = x
534
+ } else {
535
+ const direction = Math.sign(end.y - bend.y)
536
+ if (direction === 0) return points
537
+ let first = points.length - 2
538
+ while (first > 0 && points[first - 1]!.y === bend.y) first--
539
+ if (first === 0) return points
540
+ const y = end.y - direction * chartEdgeTerminalClearance
541
+ for (let index = first; index < points.length - 1; index++) result[index]!.y = y
542
+ }
543
+ return compactPoints(result)
544
+ }
545
+
546
+ const collectLayout = (
547
+ model: ChartModel,
548
+ graph: ElkNode,
549
+ unconnected: ReadonlyArray<UnconnectedRegion>
550
+ ): LaidOutChart => {
551
+ const chartNodes = new Map(model.nodes.map((node) => [node.path, node]))
552
+ const chartInitials = new Map(model.initials.map((initial) => [initialNodeId(initial), initial]))
553
+ const chartRuntimeTargets = new Map(model.runtimeTargets.map((target) => [runtimeNodeId(target), target]))
554
+ const chartRegions = new Map(unconnected.map((region) => [region.id, region]))
555
+ const offsets = new Map<string, ChartPoint>([[graph.id, { x: 0, y: 0 }]])
556
+ const regions: Array<LaidOutChartRegion> = []
557
+ const nodes: Array<LaidOutChartNode> = []
558
+ const initials: Array<LaidOutChartInitial> = []
559
+ const runtimeTargets: Array<LaidOutChartRuntimeTarget> = []
560
+
561
+ const visit = (node: ElkNode, parentOffset: ChartPoint): void => {
562
+ const absolute = add(parentOffset, { x: node.x ?? 0, y: node.y ?? 0 })
563
+ offsets.set(node.id, absolute)
564
+ const chartRegion = chartRegions.get(node.id)
565
+ if (chartRegion !== undefined) {
566
+ regions.push({
567
+ kind: "unconnected",
568
+ parent: chartRegion.parent,
569
+ nodePaths: chartRegion.nodePaths,
570
+ x: absolute.x,
571
+ y: absolute.y,
572
+ width: node.width ?? 0,
573
+ height: node.height ?? 0
574
+ })
575
+ }
576
+ const chartNode = chartNodes.get(node.id)
577
+ if (chartNode !== undefined) {
578
+ const metric = nodeMetric(chartNode)
579
+ nodes.push({
580
+ node: chartNode,
581
+ x: absolute.x,
582
+ y: absolute.y,
583
+ width: node.width ?? metric.width,
584
+ height: node.height ?? metric.height,
585
+ headerHeight: metric.headerHeight
586
+ })
587
+ }
588
+ const initial = chartInitials.get(node.id)
589
+ if (initial !== undefined) {
590
+ initials.push({
591
+ initial,
592
+ x: absolute.x,
593
+ y: absolute.y,
594
+ width: node.width ?? 14,
595
+ height: node.height ?? 14
596
+ })
597
+ }
598
+ const runtimeTarget = chartRuntimeTargets.get(node.id)
599
+ if (runtimeTarget !== undefined) {
600
+ runtimeTargets.push({
601
+ target: runtimeTarget,
602
+ x: absolute.x,
603
+ y: absolute.y,
604
+ width: node.width ?? 118,
605
+ height: node.height ?? 34
606
+ })
607
+ }
608
+ for (const child of node.children ?? []) visit(child, absolute)
609
+ }
610
+ for (const child of graph.children ?? []) visit(child, { x: 0, y: 0 })
611
+
612
+ const chartEdges = new Map(model.edges.map((edge) => [edge.id, edge]))
613
+ 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
+ }]
644
+ }
645
+ const initial = initialEdges.get(edge.id)
646
+ return initial === undefined ? [] : [{ kind: "initial", initial, points }]
647
+ })
648
+
649
+ const transitionEdges = edges.filter((edge) => edge.kind === "transition")
650
+ const contentWidth = Math.max(
651
+ 0,
652
+ ...nodes.map(({ width, x }) => x + width),
653
+ ...initials.map(({ width, x }) => x + width),
654
+ ...runtimeTargets.map(({ width, x }) => x + width),
655
+ ...edges.flatMap(({ points }) => points.map(({ x }) => x)),
656
+ ...transitionEdges.map(({ label, labelWidth }) => label.x + labelWidth / 2)
657
+ )
658
+ const contentHeight = Math.max(
659
+ 0,
660
+ ...nodes.map(({ height, y }) => y + height),
661
+ ...initials.map(({ height, y }) => y + height),
662
+ ...runtimeTargets.map(({ height, y }) => y + height),
663
+ ...edges.flatMap(({ points }) => points.map(({ y }) => y)),
664
+ ...transitionEdges.map(({ label, labelHeight }) => label.y + labelHeight / 2)
665
+ )
666
+
667
+ return {
668
+ width: Math.max(360, graph.width ?? 0, contentWidth + 20),
669
+ height: Math.max(280, graph.height ?? 0, contentHeight + 20),
670
+ regions,
671
+ nodes,
672
+ initials,
673
+ runtimeTargets,
674
+ edges
675
+ }
676
+ }
677
+
678
+ export const layoutChart = (model: ChartModel): Effect.Effect<LaidOutChart, ChartLayoutError> =>
679
+ Effect.suspend(() => {
680
+ const policy = makeChartLayoutPolicy(model)
681
+ 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)))
686
+ })