@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.
- package/README.md +2 -2
- package/dist/client/assets/index-BsorPSDS.css +1 -0
- package/dist/client/assets/index-eiptehRd.js +37 -0
- package/dist/client/index.html +2 -2
- package/package.json +6 -6
- package/src/internal/browser/chart-layout-policy.ts +206 -0
- package/src/internal/browser/chart-layout.ts +334 -81
- package/src/internal/browser/chart-model.ts +10 -0
- package/src/internal/browser/chart-renderer.ts +127 -14
- package/src/internal/browser/styles.css +188 -10
- package/src/internal/browser/transition-semantics-example.ts +10 -1
- package/src/internal/browser/visualizer-analysis.ts +52 -0
- package/src/internal/browser/visualizer-app.ts +110 -10
- package/dist/client/assets/index-BTOAhezX.js +0 -37
- package/dist/client/assets/index-Cg7xzSkz.css +0 -1
|
@@ -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 = (
|
|
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:
|
|
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
|
-
|
|
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),
|
|
170
|
+
add(edge.target, targetPortId(edge), policy.targetSide)
|
|
132
171
|
} else if (edge.kind === "targetless") {
|
|
133
|
-
add(edge.source, targetPortId(edge),
|
|
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 = (
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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 +
|
|
409
|
-
y: label.y +
|
|
638
|
+
x: label.x + labelWidth / 2,
|
|
639
|
+
y: label.y + labelHeight / 2
|
|
410
640
|
}),
|
|
411
|
-
labelWidth
|
|
412
|
-
labelHeight
|
|
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.
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
}))
|