@typeonce/effect-machine-devtools 0.25.0 → 0.26.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.
@@ -9,6 +9,7 @@ import type {
9
9
  ElkPort
10
10
  } from "elkjs/lib/elk-api.js"
11
11
  import ELKBundle from "elkjs/lib/elk.bundled.js"
12
+ import { type ChartPortSide, makeChartLayoutPolicy } from "./chart-layout-policy.js"
12
13
  import type { ChartEdge, ChartInitial, ChartModel, ChartNode, ChartRuntimeTarget } from "./chart-model.js"
13
14
 
14
15
  export const maxVisibleFields = 4
@@ -44,6 +45,16 @@ export interface LaidOutChartRuntimeTarget {
44
45
  readonly height: number
45
46
  }
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
+
47
58
  export interface LaidOutChartTransition {
48
59
  readonly kind: "transition"
49
60
  readonly edge: ChartEdge
@@ -62,6 +73,7 @@ export interface LaidOutChartInitialEdge {
62
73
  export interface LaidOutChart {
63
74
  readonly width: number
64
75
  readonly height: number
76
+ readonly regions: ReadonlyArray<LaidOutChartRegion>
65
77
  readonly nodes: ReadonlyArray<LaidOutChartNode>
66
78
  readonly initials: ReadonlyArray<LaidOutChartInitial>
67
79
  readonly runtimeTargets: ReadonlyArray<LaidOutChartRuntimeTarget>
@@ -108,29 +120,56 @@ const initialNodeId = (initial: ChartInitial): string => `node:${initial.id}`
108
120
  const initialTargetPortId = (initial: ChartInitial): string => `port:${initial.id}:target`
109
121
  const runtimeNodeId = (target: ChartRuntimeTarget): string => `node:${target.id}`
110
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
+ }
111
147
 
112
- const portsByState = (model: ChartModel): ReadonlyMap<string, ReadonlyArray<ElkPort>> => {
148
+ const portsByState = (
149
+ model: ChartModel,
150
+ edgePolicy: ReturnType<typeof makeChartLayoutPolicy>["edge"]
151
+ ): ReadonlyMap<string, ReadonlyArray<ElkPort>> => {
113
152
  const ports = new Map<string, Array<ElkPort>>()
114
- const add = (path: string, id: string, side: "EAST" | "WEST"): void => {
153
+ const add = (path: string, id: string, side: ChartPortSide): void => {
115
154
  const statePorts = ports.get(path) ?? []
116
155
  statePorts.push({
117
156
  id,
118
157
  width: 6,
119
158
  height: 6,
120
159
  layoutOptions: {
121
- "elk.port.side": side,
122
- "elk.port.index": String(statePorts.length)
160
+ "elk.port.side": side
123
161
  }
124
162
  })
125
163
  ports.set(path, statePorts)
126
164
  }
127
165
 
128
166
  for (const edge of model.edges) {
129
- add(edge.source, sourcePortId(edge), "EAST")
167
+ const policy = edgePolicy(edge)
168
+ add(edge.source, sourcePortId(edge), policy.sourceSide)
130
169
  if (edge.kind === "target" && edge.target !== null) {
131
- add(edge.target, targetPortId(edge), "WEST")
170
+ add(edge.target, targetPortId(edge), policy.targetSide)
132
171
  } else if (edge.kind === "targetless") {
133
- add(edge.source, targetPortId(edge), "EAST")
172
+ add(edge.source, targetPortId(edge), policy.targetSide)
134
173
  }
135
174
  }
136
175
  for (const initial of model.initials) add(initial.target, initialTargetPortId(initial), "WEST")
@@ -142,13 +181,21 @@ const labelMetric = (label: string): { readonly width: number; readonly height:
142
181
  height: 26
143
182
  })
144
183
 
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
- }
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
+ )
152
199
  const initialsByParent = new Map<string | null, Array<ChartInitial>>()
153
200
  for (const initial of model.initials) {
154
201
  const siblings = initialsByParent.get(initial.parent) ?? []
@@ -161,61 +208,104 @@ const makeGraph = (model: ChartModel): ElkNode => {
161
208
  siblings.push(target)
162
209
  runtimeTargetsByParent.set(target.parent, siblings)
163
210
  }
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
- }
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 })
189
248
  }
249
+ }
250
+ if (descendants.length === 0) {
190
251
  return {
191
252
  ...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
- }
253
+ width: metric.width,
254
+ height: metric.height
204
255
  }
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
- ]
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
+ }
219
309
 
220
310
  return {
221
311
  id: "chart-root",
@@ -223,17 +313,28 @@ const makeGraph = (model: ChartModel): ElkNode => {
223
313
  edges: [
224
314
  ...model.edges.map((edge): ElkExtendedEdge => {
225
315
  const label = labelMetric(edge.label)
316
+ const edgeLayout = policy.edge(edge)
226
317
  return {
227
318
  id: edge.id,
228
319
  sources: [sourcePortId(edge)],
229
320
  targets: [targetPortId(edge)],
230
- labels: [{ text: edge.label, width: label.width, height: label.height }]
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
+ }
231
327
  }
232
328
  }),
233
329
  ...model.initials.map((initial): ElkExtendedEdge => ({
234
330
  id: initial.id,
235
331
  sources: [initialNodeId(initial)],
236
- targets: [initialTargetPortId(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
+ }
237
338
  }))
238
339
  ],
239
340
  layoutOptions: {
@@ -248,7 +349,12 @@ const makeGraph = (model: ChartModel): ElkNode => {
248
349
  "elk.layered.spacing.edgeEdgeBetweenLayers": "26",
249
350
  "elk.spacing.edgeNode": "28",
250
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",
251
356
  "elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
357
+ "elk.layered.crossingMinimization.hierarchicalSweepiness": "1",
252
358
  "elk.layered.crossingMinimization.greedySwitchHierarchical.type": "TWO_SIDED",
253
359
  "elk.layered.nodePlacement.favorStraightEdges": "true",
254
360
  "elk.layered.mergeHierarchyEdges": "false",
@@ -326,10 +432,19 @@ const midpoint = (points: ReadonlyArray<ChartPoint>): ChartPoint => {
326
432
  return points.at(-1)!
327
433
  }
328
434
 
329
- const expandTargetlessLoop = (points: ReadonlyArray<ChartPoint>): ReadonlyArray<ChartPoint> => {
435
+ const expandSelfLoop = (points: ReadonlyArray<ChartPoint>): ReadonlyArray<ChartPoint> => {
330
436
  const start = points[0]
331
437
  const end = points.at(-1)
332
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
+ }
333
448
  const outerX = Math.max(...points.map(({ x }) => x)) + 30
334
449
  return compactPoints([
335
450
  start,
@@ -339,11 +454,106 @@ const expandTargetlessLoop = (points: ReadonlyArray<ChartPoint>): ReadonlyArray<
339
454
  ])
340
455
  }
341
456
 
342
- const collectLayout = (model: ChartModel, graph: ElkNode): LaidOutChart => {
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 => {
343
551
  const chartNodes = new Map(model.nodes.map((node) => [node.path, node]))
344
552
  const chartInitials = new Map(model.initials.map((initial) => [initialNodeId(initial), initial]))
345
553
  const chartRuntimeTargets = new Map(model.runtimeTargets.map((target) => [runtimeNodeId(target), target]))
554
+ const chartRegions = new Map(unconnected.map((region) => [region.id, region]))
346
555
  const offsets = new Map<string, ChartPoint>([[graph.id, { x: 0, y: 0 }]])
556
+ const regions: Array<LaidOutChartRegion> = []
347
557
  const nodes: Array<LaidOutChartNode> = []
348
558
  const initials: Array<LaidOutChartInitial> = []
349
559
  const runtimeTargets: Array<LaidOutChartRuntimeTarget> = []
@@ -351,6 +561,18 @@ const collectLayout = (model: ChartModel, graph: ElkNode): LaidOutChart => {
351
561
  const visit = (node: ElkNode, parentOffset: ChartPoint): void => {
352
562
  const absolute = add(parentOffset, { x: node.x ?? 0, y: node.y ?? 0 })
353
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
+ }
354
576
  const chartNode = chartNodes.get(node.id)
355
577
  if (chartNode !== undefined) {
356
578
  const metric = nodeMetric(chartNode)
@@ -397,28 +619,55 @@ const collectLayout = (model: ChartModel, graph: ElkNode): LaidOutChart => {
397
619
  if (chartEdge !== undefined) {
398
620
  const metric = labelMetric(chartEdge.label)
399
621
  const label = edge.labels?.[0]
400
- const transitionPoints = chartEdge.kind === "targetless" ? expandTargetlessLoop(points) : points
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
401
629
  return [{
402
630
  kind: "transition",
403
631
  edge: chartEdge,
404
632
  points: transitionPoints,
405
- label: label?.x === undefined || label.y === undefined
633
+ label: selfTransition
634
+ ? selfLoopLabelPosition(transitionPoints, labelWidth, labelHeight)
635
+ : label?.x === undefined || label.y === undefined
406
636
  ? midpoint(transitionPoints)
407
637
  : add(offset, {
408
- x: label.x + (label.width ?? metric.width) / 2,
409
- y: label.y + (label.height ?? metric.height) / 2
638
+ x: label.x + labelWidth / 2,
639
+ y: label.y + labelHeight / 2
410
640
  }),
411
- labelWidth: label?.width ?? metric.width,
412
- labelHeight: label?.height ?? metric.height
641
+ labelWidth,
642
+ labelHeight
413
643
  }]
414
644
  }
415
645
  const initial = initialEdges.get(edge.id)
416
646
  return initial === undefined ? [] : [{ kind: "initial", initial, points }]
417
647
  })
418
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
+
419
667
  return {
420
- width: Math.max(360, graph.width ?? 0),
421
- height: Math.max(280, graph.height ?? 0),
668
+ width: Math.max(360, graph.width ?? 0, contentWidth + 20),
669
+ height: Math.max(280, graph.height ?? 0, contentHeight + 20),
670
+ regions,
422
671
  nodes,
423
672
  initials,
424
673
  runtimeTargets,
@@ -427,7 +676,11 @@ const collectLayout = (model: ChartModel, graph: ElkNode): LaidOutChart => {
427
676
  }
428
677
 
429
678
  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)))
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
+ })
@@ -42,6 +42,7 @@ export interface ChartEdge {
42
42
  readonly target: string | null
43
43
  readonly label: string
44
44
  readonly trigger: VisualizationTransition["trigger"]
45
+ readonly activityKind: ChartActivity["kind"] | null
45
46
  readonly reenter: boolean
46
47
  readonly acceptance: VisualizationTransition["acceptance"]
47
48
  }
@@ -156,6 +157,12 @@ export const makeChartModel = (document: VisualizationDocument): ChartModel => {
156
157
  const active = new Set(document.snapshot?.activePaths ?? [])
157
158
  const initialPaths = new Set([document.initial.target])
158
159
  const activities = new Map(document.activities.map((activity) => [activity.id, activity]))
160
+ const activitiesBySource = new Map<string, Map<string, ChartActivity["kind"]>>()
161
+ for (const activity of document.activities) {
162
+ const sourceActivities = activitiesBySource.get(activity.source) ?? new Map()
163
+ sourceActivities.set(activity.lifecycleId, activity.type)
164
+ activitiesBySource.set(activity.source, sourceActivities)
165
+ }
159
166
  const states = new Map(document.states.map((state) => [state.path, state]))
160
167
 
161
168
  for (const state of document.states) {
@@ -200,6 +207,9 @@ export const makeChartModel = (document: VisualizationDocument): ChartModel => {
200
207
  ? transitionLabel(transition, branches[0]!)
201
208
  : `${triggerLabel(transition)} · ${branches.length} branches`,
202
209
  trigger: transition.trigger,
210
+ activityKind: transition.trigger.type === "invoke"
211
+ ? activitiesBySource.get(transition.source)?.get(transition.trigger.id) ?? null
212
+ : null,
203
213
  reenter: transition.reenter,
204
214
  acceptance: transition.acceptance
205
215
  }))