@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.
package/dist/client/index.html
CHANGED
|
@@ -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-
|
|
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.
|
|
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.
|
|
33
|
+
"elkjs": "0.12.0",
|
|
34
34
|
"typescript": "6.0.3",
|
|
35
35
|
"vite": "8.1.5",
|
|
36
|
-
"@typeonce/effect-machine": "^0.26.
|
|
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
|
-
|
|
150
|
-
edgePolicy: ReturnType<typeof makeChartLayoutPolicy>["edge"]
|
|
151
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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(
|
|
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
|
-
|
|
273
|
+
...(portConstraints === "fixed"
|
|
274
|
+
? { layoutOptions: { "elk.port.side": "WEST" } }
|
|
275
|
+
: {})
|
|
230
276
|
}],
|
|
231
|
-
|
|
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 + (
|
|
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
|
-
...
|
|
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
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
|
|
646
|
-
|
|
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
|
-
|
|
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
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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))
|