@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.
- package/NOTICE +4 -0
- package/README.md +13 -13
- package/dist/DevToolsProtocol.d.ts +66 -630
- package/dist/DevToolsProtocol.d.ts.map +1 -1
- package/dist/DevToolsProtocol.js +0 -190
- package/dist/DevToolsProtocol.js.map +1 -1
- package/dist/MachineDocument.d.ts +38 -18
- package/dist/MachineDocument.d.ts.map +1 -1
- package/dist/MachineDocument.js +14 -12
- package/dist/MachineDocument.js.map +1 -1
- package/dist/MachineRegistry.d.ts +22 -2
- package/dist/MachineRegistry.d.ts.map +1 -1
- package/dist/MachineWalkthrough.d.ts +171 -0
- package/dist/MachineWalkthrough.d.ts.map +1 -0
- package/dist/MachineWalkthrough.js +98 -0
- package/dist/MachineWalkthrough.js.map +1 -0
- package/dist/ProjectInspector.d.ts +0 -2
- package/dist/ProjectInspector.d.ts.map +1 -1
- package/dist/ProjectInspector.js.map +1 -1
- package/dist/client/assets/index-BsorPSDS.css +1 -0
- package/dist/client/assets/index-eiptehRd.js +37 -0
- package/dist/client/index.html +4 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/devServer.d.ts.map +1 -1
- package/dist/internal/devServer.js +4 -64
- package/dist/internal/devServer.js.map +1 -1
- package/dist/internal/evaluationWorker.d.ts.map +1 -1
- package/dist/internal/evaluationWorker.js +7 -263
- package/dist/internal/evaluationWorker.js.map +1 -1
- package/dist/internal/machineDocument.d.ts.map +1 -1
- package/dist/internal/machineDocument.js +3 -1
- package/dist/internal/machineDocument.js.map +1 -1
- package/dist/internal/machineWalkthrough.d.ts +26 -0
- package/dist/internal/machineWalkthrough.d.ts.map +1 -0
- package/dist/internal/machineWalkthrough.js +256 -0
- package/dist/internal/machineWalkthrough.js.map +1 -0
- package/dist/internal/projectInspector.d.ts.map +1 -1
- package/dist/internal/projectInspector.js +1 -11
- package/dist/internal/projectInspector.js.map +1 -1
- package/index.html +2 -2
- package/package.json +8 -7
- package/src/DevToolsProtocol.ts +0 -285
- package/src/MachineDocument.ts +21 -19
- package/src/MachineWalkthrough.ts +199 -0
- package/src/ProjectInspector.ts +0 -5
- package/src/index.ts +2 -2
- package/src/internal/browser/chart-layout-policy.ts +206 -0
- package/src/internal/browser/chart-layout.ts +686 -0
- package/src/internal/browser/chart-model.ts +244 -0
- package/src/internal/browser/chart-renderer.ts +774 -0
- package/src/internal/browser/example-machine.ts +15 -6
- package/src/internal/browser/input-form.ts +1 -418
- package/src/internal/browser/invoke-outcomes-example.ts +231 -0
- package/src/internal/browser/parallel-completion-example.ts +199 -0
- package/src/internal/browser/planner-example.ts +2 -0
- package/src/internal/browser/protocol-events-example.ts +200 -0
- package/src/internal/browser/styles.css +974 -342
- package/src/internal/browser/transition-semantics-example.ts +245 -0
- package/src/internal/browser/visualizer-analysis.ts +52 -0
- package/src/internal/browser/visualizer-app.ts +874 -706
- package/src/internal/browser/visualizer-model.ts +104 -0
- package/src/internal/devServer.ts +4 -82
- package/src/internal/evaluationWorker.ts +7 -371
- package/src/internal/machineDocument.ts +3 -1
- package/src/internal/machineWalkthrough.ts +355 -0
- package/src/internal/projectInspector.ts +3 -31
- package/dist/MachineSimulator.d.ts +0 -169
- package/dist/MachineSimulator.d.ts.map +0 -1
- package/dist/MachineSimulator.js +0 -98
- package/dist/MachineSimulator.js.map +0 -1
- package/dist/client/assets/index-B49Tjawc.js +0 -14
- package/dist/client/assets/index-BKH0cOG2.css +0 -1
- package/dist/internal/machineSimulator.d.ts +0 -5
- package/dist/internal/machineSimulator.d.ts.map +0 -1
- package/dist/internal/machineSimulator.js +0 -156
- package/dist/internal/machineSimulator.js.map +0 -1
- package/src/MachineSimulator.ts +0 -154
- package/src/internal/browser/simulation-client.ts +0 -20
- 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
|
+
})
|