@typeonce/effect-machine-devtools 0.23.0 → 0.25.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 +14 -8
- package/dist/DevServer.d.ts +2 -1
- package/dist/DevServer.d.ts.map +1 -1
- package/dist/DevServer.js.map +1 -1
- package/dist/DevToolsProtocol.d.ts +167 -17
- package/dist/DevToolsProtocol.d.ts.map +1 -1
- package/dist/DevToolsProtocol.js +1 -1
- package/dist/MachineDocument.d.ts +98 -2
- package/dist/MachineDocument.d.ts.map +1 -1
- package/dist/MachineDocument.js +35 -1
- package/dist/MachineDocument.js.map +1 -1
- package/dist/MachineRegistry.d.ts +56 -6
- 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/client/assets/index-BTOAhezX.js +37 -0
- package/dist/client/assets/index-Cg7xzSkz.css +1 -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 +2 -1
- package/dist/internal/devServer.d.ts.map +1 -1
- package/dist/internal/devServer.js +4 -3
- package/dist/internal/devServer.js.map +1 -1
- package/dist/internal/evaluationWorker.d.ts.map +1 -1
- package/dist/internal/evaluationWorker.js +28 -9
- package/dist/internal/evaluationWorker.js.map +1 -1
- package/dist/internal/machineDocument.d.ts.map +1 -1
- package/dist/internal/machineDocument.js +65 -2
- 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 +15 -9
- package/dist/internal/projectInspector.js.map +1 -1
- package/index.html +2 -2
- package/package.json +4 -3
- package/src/DevServer.ts +6 -2
- package/src/DevToolsProtocol.ts +1 -1
- package/src/MachineDocument.ts +56 -1
- package/src/MachineWalkthrough.ts +199 -0
- package/src/index.ts +2 -2
- package/src/internal/browser/chart-layout.ts +433 -0
- package/src/internal/browser/chart-model.ts +234 -0
- package/src/internal/browser/chart-renderer.ts +661 -0
- package/src/internal/browser/example-machine.ts +15 -6
- package/src/internal/browser/input-form.ts +252 -0
- 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 +145 -0
- package/src/internal/browser/protocol-events-example.ts +200 -0
- package/src/internal/browser/styles.css +959 -185
- package/src/internal/browser/transition-semantics-example.ts +236 -0
- package/src/internal/browser/visualizer-app.ts +818 -389
- package/src/internal/browser/visualizer-model.ts +104 -0
- package/src/internal/browser/visualizer.ts +1 -1
- package/src/internal/devServer.ts +9 -4
- package/src/internal/evaluationWorker.ts +28 -10
- package/src/internal/machineDocument.ts +77 -2
- package/src/internal/machineWalkthrough.ts +355 -0
- package/src/internal/projectInspector.ts +29 -14
- 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-BGOhE3Ng.css +0 -1
- package/dist/client/assets/index-DiqGhsGR.js +0 -14
- 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/machineSimulator.ts +0 -199
|
@@ -1,17 +1,29 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect"
|
|
2
|
+
import * as Result from "effect/Result"
|
|
1
3
|
import type { Diagnostic } from "../../DevToolsProtocol.js"
|
|
2
4
|
import type {
|
|
3
5
|
Activity as VisualizationActivity,
|
|
4
6
|
Branch as VisualizationBranch,
|
|
7
|
+
InputSchema,
|
|
5
8
|
MachineDocument as VisualizationDocument,
|
|
6
|
-
Transition as VisualizationTransition
|
|
9
|
+
Transition as VisualizationTransition,
|
|
10
|
+
Trigger
|
|
7
11
|
} from "../../MachineDocument.js"
|
|
8
|
-
import * as
|
|
12
|
+
import * as MachineWalkthrough from "../../MachineWalkthrough.js"
|
|
9
13
|
import {
|
|
10
|
-
type
|
|
14
|
+
type ChartInteractionAnchor,
|
|
15
|
+
type ChartPresentation,
|
|
16
|
+
type ChartView,
|
|
17
|
+
maximumChartZoom,
|
|
18
|
+
minimumChartZoom,
|
|
19
|
+
renderChart
|
|
20
|
+
} from "./chart-renderer.js"
|
|
21
|
+
import { type InputField, projectInputSchema } from "./input-form.js"
|
|
22
|
+
import {
|
|
23
|
+
branchTargetApi,
|
|
11
24
|
type IncomingTransition,
|
|
12
25
|
makeVisualizerModel,
|
|
13
26
|
type StateInspection,
|
|
14
|
-
type TopologyNode,
|
|
15
27
|
triggerLabel
|
|
16
28
|
} from "./visualizer-model.js"
|
|
17
29
|
|
|
@@ -26,16 +38,33 @@ const createElement = <Tag extends keyof HTMLElementTagNameMap>(
|
|
|
26
38
|
return element
|
|
27
39
|
}
|
|
28
40
|
|
|
29
|
-
|
|
41
|
+
type MetadataValue = string | Node | null | undefined
|
|
42
|
+
|
|
43
|
+
const metadata = (items: ReadonlyArray<readonly [string, MetadataValue]>): HTMLDListElement => {
|
|
30
44
|
const list = createElement("dl", "metadata")
|
|
31
45
|
for (const [label, value] of items) {
|
|
32
|
-
|
|
46
|
+
if (value === null || value === undefined || value === "" || value === "none") continue
|
|
47
|
+
const description = createElement("dd")
|
|
48
|
+
description.append(value)
|
|
49
|
+
list.append(createElement("dt", undefined, label), description)
|
|
33
50
|
}
|
|
34
51
|
return list
|
|
35
52
|
}
|
|
36
53
|
|
|
37
54
|
const badge = (text: string, kind = "neutral"): HTMLSpanElement => createElement("span", `badge badge-${kind}`, text)
|
|
38
55
|
|
|
56
|
+
const stateStatus = (active: boolean, initial: boolean): HTMLSpanElement => {
|
|
57
|
+
const status = createElement(
|
|
58
|
+
"span",
|
|
59
|
+
`chart-state-status${active ? " is-active" : ""}${initial ? " is-initial" : ""}`
|
|
60
|
+
)
|
|
61
|
+
status.setAttribute(
|
|
62
|
+
"aria-label",
|
|
63
|
+
active && initial ? "active, initial state" : active ? "active" : initial ? "initial state" : "inactive"
|
|
64
|
+
)
|
|
65
|
+
return status
|
|
66
|
+
}
|
|
67
|
+
|
|
39
68
|
type StateNavigator = (path: string) => void
|
|
40
69
|
|
|
41
70
|
const stateLink = (path: string, label: string, navigate: StateNavigator): HTMLButtonElement => {
|
|
@@ -45,26 +74,35 @@ const stateLink = (path: string, label: string, navigate: StateNavigator): HTMLB
|
|
|
45
74
|
return link
|
|
46
75
|
}
|
|
47
76
|
|
|
48
|
-
const renderBranch = (
|
|
77
|
+
const renderBranch = (
|
|
78
|
+
document: VisualizationDocument,
|
|
79
|
+
source: string,
|
|
80
|
+
branch: VisualizationBranch,
|
|
81
|
+
navigate: StateNavigator
|
|
82
|
+
): HTMLElement => {
|
|
49
83
|
const row = createElement("div", "branch-row")
|
|
50
84
|
const main = createElement("div", "branch-main")
|
|
51
85
|
if (branch.type === "branch") main.append(badge(branch.title, "condition"))
|
|
52
|
-
main.append(createElement("span", "branch-arrow", "→"))
|
|
53
86
|
if (branch.target !== null) {
|
|
54
|
-
main.append(stateLink(branch.target, branch.target, navigate))
|
|
87
|
+
main.append(createElement("span", "branch-arrow", "→"), stateLink(branch.target, branch.target, navigate))
|
|
88
|
+
} else if (branch.selection.kind === "update") {
|
|
89
|
+
main.append(
|
|
90
|
+
badge(branch.selection.scope === "local" ? "to.local.update" : "value update", "update"),
|
|
91
|
+
createElement("span", "branch-target", "Updates the owner value")
|
|
92
|
+
)
|
|
55
93
|
} else {
|
|
56
|
-
main.append(createElement("span", "branch-target",
|
|
94
|
+
main.append(createElement("span", "branch-target", "No target state"))
|
|
57
95
|
}
|
|
58
96
|
row.append(main)
|
|
59
97
|
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
["
|
|
63
|
-
|
|
98
|
+
const api = branchTargetApi(document, source, branch)
|
|
99
|
+
const details: Array<readonly [string, MetadataValue]> = api === undefined
|
|
100
|
+
? [["Selection", branch.selection.kind], ["Scope", branch.selection.scope]]
|
|
101
|
+
: [["API", api]]
|
|
64
102
|
row.append(metadata(details))
|
|
65
103
|
if (branch.updates.length > 0) {
|
|
66
104
|
const updates = createElement("div", "branch-updates")
|
|
67
|
-
updates.append(createElement("span", "branch-updates-label", "
|
|
105
|
+
updates.append(createElement("span", "branch-updates-label", "Value owner"))
|
|
68
106
|
branch.updates.forEach((path) => updates.append(stateLink(path, path, navigate)))
|
|
69
107
|
row.append(updates)
|
|
70
108
|
}
|
|
@@ -72,6 +110,7 @@ const renderBranch = (branch: VisualizationBranch, navigate: StateNavigator): HT
|
|
|
72
110
|
}
|
|
73
111
|
|
|
74
112
|
const renderTransition = (
|
|
113
|
+
document: VisualizationDocument,
|
|
75
114
|
transition: VisualizationTransition,
|
|
76
115
|
navigate: StateNavigator,
|
|
77
116
|
showSource = false
|
|
@@ -79,44 +118,52 @@ const renderTransition = (
|
|
|
79
118
|
const card = createElement("article", "inspection-card transition-card")
|
|
80
119
|
const header = createElement("div", "card-header")
|
|
81
120
|
const title = createElement("div", "card-title")
|
|
82
|
-
title.append(
|
|
121
|
+
title.append(createElement("strong", undefined, triggerLabel(transition)))
|
|
83
122
|
const flags = createElement("div", "card-flags")
|
|
123
|
+
flags.append(badge(transition.trigger.type, "trigger"))
|
|
84
124
|
if (transition.reenter) flags.append(badge("reenter"))
|
|
85
125
|
if (transition.acceptance === "declinable") flags.append(badge("declinable"))
|
|
86
126
|
header.append(title, flags)
|
|
87
127
|
card.append(header)
|
|
88
128
|
if (showSource) {
|
|
89
|
-
const source =
|
|
90
|
-
source.
|
|
129
|
+
const source = metadata([["Source", stateLink(transition.source, transition.source, navigate)]])
|
|
130
|
+
source.classList.add("card-metadata")
|
|
91
131
|
card.append(source)
|
|
92
132
|
}
|
|
93
133
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
branches.
|
|
97
|
-
|
|
98
|
-
|
|
134
|
+
if (transition.branches.length > 0) {
|
|
135
|
+
const branches = createElement("div", "branch-list")
|
|
136
|
+
transition.branches.forEach((branch) =>
|
|
137
|
+
branches.append(renderBranch(document, transition.source, branch, navigate))
|
|
138
|
+
)
|
|
139
|
+
card.append(branches)
|
|
99
140
|
}
|
|
100
|
-
card.append(branches)
|
|
101
141
|
return card
|
|
102
142
|
}
|
|
103
143
|
|
|
104
|
-
const renderIncomingTransition = (
|
|
144
|
+
const renderIncomingTransition = (
|
|
145
|
+
document: VisualizationDocument,
|
|
146
|
+
incoming: IncomingTransition,
|
|
147
|
+
navigate: StateNavigator
|
|
148
|
+
): HTMLElement => {
|
|
105
149
|
const card = createElement("article", "inspection-card incoming-card")
|
|
106
150
|
const header = createElement("div", "card-header")
|
|
107
151
|
const title = createElement("div", "card-title")
|
|
108
|
-
title.append(
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
)
|
|
112
|
-
header.append(title, stateLink(incoming.transition.source, incoming.transition.source, navigate))
|
|
152
|
+
title.append(createElement("strong", undefined, triggerLabel(incoming.transition)))
|
|
153
|
+
const flags = createElement("div", "card-flags")
|
|
154
|
+
flags.append(badge(incoming.transition.trigger.type, "trigger"))
|
|
155
|
+
header.append(title, flags)
|
|
113
156
|
card.append(header)
|
|
114
157
|
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
["
|
|
158
|
+
const api = branchTargetApi(document, incoming.transition.source, incoming.branch)
|
|
159
|
+
const details: Array<readonly [string, MetadataValue]> = [
|
|
160
|
+
["Source", stateLink(incoming.transition.source, incoming.transition.source, navigate)],
|
|
161
|
+
["API", api]
|
|
118
162
|
]
|
|
119
|
-
if (
|
|
163
|
+
if (api === undefined) {
|
|
164
|
+
details.push(["Selection", incoming.branch.selection.kind], ["Scope", incoming.branch.selection.scope])
|
|
165
|
+
}
|
|
166
|
+
if (incoming.branch.type === "branch") details.unshift(["Title", incoming.branch.title])
|
|
120
167
|
card.append(metadata(details))
|
|
121
168
|
return card
|
|
122
169
|
}
|
|
@@ -133,15 +180,19 @@ const activityTitle = (activity: VisualizationActivity): string => {
|
|
|
133
180
|
}
|
|
134
181
|
}
|
|
135
182
|
|
|
136
|
-
const renderActivity = (activity: VisualizationActivity): HTMLElement => {
|
|
183
|
+
const renderActivity = (activity: VisualizationActivity, navigate: StateNavigator): HTMLElement => {
|
|
137
184
|
const card = createElement("article", "inspection-card activity-card")
|
|
138
185
|
const header = createElement("div", "card-header")
|
|
139
186
|
const title = createElement("div", "card-title")
|
|
140
|
-
title.append(
|
|
141
|
-
|
|
187
|
+
title.append(createElement("strong", undefined, activityTitle(activity)))
|
|
188
|
+
const flags = createElement("div", "card-flags")
|
|
189
|
+
flags.append(badge(activity.type, "activity"))
|
|
190
|
+
header.append(title, flags)
|
|
142
191
|
card.append(header)
|
|
143
192
|
|
|
144
|
-
const details: Array<readonly [string,
|
|
193
|
+
const details: Array<readonly [string, MetadataValue]> = [
|
|
194
|
+
["Owner", stateLink(activity.source, activity.source, navigate)]
|
|
195
|
+
]
|
|
145
196
|
if (activity.type === "timer") details.push(["Duration", activity.duration])
|
|
146
197
|
if (activity.type === "effect") {
|
|
147
198
|
details.push(["Success", activity.outcomes.success], ["Failure", activity.outcomes.failure])
|
|
@@ -153,83 +204,270 @@ const renderActivity = (activity: VisualizationActivity): HTMLElement => {
|
|
|
153
204
|
return card
|
|
154
205
|
}
|
|
155
206
|
|
|
156
|
-
const inspectionSection = (title: string, count
|
|
207
|
+
const inspectionSection = (title: string, count?: number): HTMLElement => {
|
|
157
208
|
const header = createElement("div", "section-heading")
|
|
158
|
-
header.append(createElement("h3", undefined, title)
|
|
209
|
+
header.append(createElement("h3", undefined, title))
|
|
210
|
+
if (count !== undefined) header.append(createElement("span", "section-count", String(count)))
|
|
159
211
|
return header
|
|
160
212
|
}
|
|
161
213
|
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
"
|
|
167
|
-
|
|
168
|
-
"
|
|
169
|
-
|
|
170
|
-
"
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
214
|
+
const inputType = (field: InputField): string => {
|
|
215
|
+
switch (field._tag) {
|
|
216
|
+
case "String":
|
|
217
|
+
return field.format ?? "string"
|
|
218
|
+
case "Number":
|
|
219
|
+
return field.integer ? "integer" : "number"
|
|
220
|
+
case "Boolean":
|
|
221
|
+
return "boolean"
|
|
222
|
+
case "Enum":
|
|
223
|
+
return "enum"
|
|
224
|
+
case "Literal":
|
|
225
|
+
return "literal"
|
|
226
|
+
case "Object":
|
|
227
|
+
return "object"
|
|
228
|
+
case "Array":
|
|
229
|
+
return `${inputType(field.item)}[]`
|
|
230
|
+
case "Union":
|
|
231
|
+
return "union"
|
|
232
|
+
case "Unsupported":
|
|
233
|
+
return "unknown"
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const inputConstraints = (field: InputField): ReadonlyArray<string> => {
|
|
238
|
+
switch (field._tag) {
|
|
239
|
+
case "String":
|
|
240
|
+
return [
|
|
241
|
+
field.minLength === undefined ? undefined : `min ${field.minLength}`,
|
|
242
|
+
field.maxLength === undefined ? undefined : `max ${field.maxLength}`,
|
|
243
|
+
field.pattern === undefined ? undefined : `pattern ${field.pattern}`
|
|
244
|
+
].filter((value): value is string => value !== undefined)
|
|
245
|
+
case "Number":
|
|
246
|
+
return [
|
|
247
|
+
field.minimum === undefined ? undefined : `≥ ${field.minimum}`,
|
|
248
|
+
field.maximum === undefined ? undefined : `≤ ${field.maximum}`
|
|
249
|
+
].filter((value): value is string => value !== undefined)
|
|
250
|
+
case "Enum":
|
|
251
|
+
return field.values.map((value) => JSON.stringify(value))
|
|
252
|
+
case "Literal":
|
|
253
|
+
return [JSON.stringify(field.value)]
|
|
254
|
+
case "Array":
|
|
255
|
+
return [
|
|
256
|
+
field.minItems > 0 ? `min ${field.minItems}` : undefined,
|
|
257
|
+
field.maxItems === undefined ? undefined : `max ${field.maxItems}`
|
|
258
|
+
].filter((value): value is string => value !== undefined)
|
|
259
|
+
default:
|
|
260
|
+
return []
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const renderContractField = (
|
|
265
|
+
label: string,
|
|
266
|
+
required: boolean,
|
|
267
|
+
field: InputField,
|
|
268
|
+
omit: ReadonlySet<string>
|
|
269
|
+
): HTMLElement | null => {
|
|
270
|
+
if (omit.has(label)) return null
|
|
271
|
+
const row = createElement("div", "input-contract-field")
|
|
272
|
+
const heading = createElement("div", "input-field-heading")
|
|
273
|
+
const identity = createElement("div", "input-field-identity")
|
|
274
|
+
identity.append(
|
|
275
|
+
createElement("span", "input-label", label),
|
|
276
|
+
createElement("span", "input-field-type", inputType(field)),
|
|
277
|
+
createElement("span", required ? "input-required" : "input-optional", required ? "required" : "optional")
|
|
278
|
+
)
|
|
279
|
+
heading.append(identity)
|
|
280
|
+
row.append(heading)
|
|
281
|
+
const constraints = inputConstraints(field)
|
|
282
|
+
if (constraints.length > 0) {
|
|
283
|
+
const values = createElement("div", "input-constraints")
|
|
284
|
+
constraints.forEach((value) => values.append(createElement("span", undefined, value)))
|
|
285
|
+
row.append(values)
|
|
286
|
+
}
|
|
287
|
+
if (field.description !== undefined) row.append(createElement("p", "input-description", field.description))
|
|
288
|
+
if (field._tag === "Object") {
|
|
289
|
+
const children = createElement("div", "input-contract-children")
|
|
290
|
+
field.fields.forEach(({ key, required, field }) => {
|
|
291
|
+
const child = renderContractField(key, required, field, omit)
|
|
292
|
+
if (child !== null) children.append(child)
|
|
293
|
+
})
|
|
294
|
+
if (children.childElementCount > 0) row.append(children)
|
|
295
|
+
} else if (field._tag === "Array" && field.item._tag === "Object") {
|
|
296
|
+
const children = createElement("div", "input-contract-children")
|
|
297
|
+
field.item.fields.forEach(({ key, required, field }) => {
|
|
298
|
+
const child = renderContractField(key, required, field, omit)
|
|
299
|
+
if (child !== null) children.append(child)
|
|
300
|
+
})
|
|
301
|
+
if (children.childElementCount > 0) row.append(children)
|
|
302
|
+
} else if (field._tag === "Union") {
|
|
303
|
+
const alternatives = createElement("div", "input-contract-alternatives")
|
|
304
|
+
field.alternatives.forEach((alternative, index) => {
|
|
305
|
+
const item = renderContractField(`Option ${index + 1}`, true, alternative, omit)
|
|
306
|
+
if (item !== null) alternatives.append(item)
|
|
307
|
+
})
|
|
308
|
+
row.append(alternatives)
|
|
309
|
+
} else if (field._tag === "Unsupported") {
|
|
310
|
+
row.append(createElement("p", "input-unsupported", field.reason))
|
|
311
|
+
}
|
|
312
|
+
return row
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const renderInputContract = (
|
|
316
|
+
title: string,
|
|
317
|
+
schema: InputSchema,
|
|
318
|
+
omit: ReadonlyArray<string> = []
|
|
319
|
+
): HTMLElement => {
|
|
320
|
+
const section = createElement("section", "inspector-section input-contract")
|
|
321
|
+
section.append(inspectionSection(title))
|
|
322
|
+
const projected = projectInputSchema(schema)
|
|
323
|
+
const fields = createElement("div", "input-contract-fields")
|
|
324
|
+
const omitted = new Set(omit)
|
|
325
|
+
if (projected._tag === "Object") {
|
|
326
|
+
projected.fields.forEach(({ key, required, field }) => {
|
|
327
|
+
const child = renderContractField(key, required, field, omitted)
|
|
328
|
+
if (child !== null) fields.append(child)
|
|
329
|
+
})
|
|
330
|
+
} else {
|
|
331
|
+
const child = renderContractField("value", true, projected, omitted)
|
|
332
|
+
if (child !== null) fields.append(child)
|
|
333
|
+
}
|
|
334
|
+
if (fields.childElementCount === 0) {
|
|
335
|
+
fields.append(createElement("p", "section-empty", "No additional data fields."))
|
|
336
|
+
}
|
|
337
|
+
section.append(fields)
|
|
338
|
+
return section
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const contractSummary = (schema: InputSchema | null): string | null => {
|
|
342
|
+
if (schema === null) return null
|
|
343
|
+
const field = projectInputSchema(schema)
|
|
344
|
+
if (field._tag !== "Object") return inputType(field)
|
|
345
|
+
const fields = field.fields.filter(({ key }) => key !== "_tag")
|
|
346
|
+
if (fields.length === 0) return null
|
|
347
|
+
return fields.slice(0, 3).map(({ key, required, field }) => `${key}${required ? "" : "?"}: ${inputType(field)}`).join(
|
|
348
|
+
" · "
|
|
349
|
+
) + (fields.length > 3 ? ` · +${fields.length - 3}` : "")
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const triggerName = (trigger: Trigger): string => {
|
|
353
|
+
switch (trigger.type) {
|
|
354
|
+
case "event":
|
|
355
|
+
return trigger.event
|
|
356
|
+
case "always":
|
|
357
|
+
return "Always"
|
|
358
|
+
case "done":
|
|
359
|
+
return "Completion"
|
|
360
|
+
case "choice":
|
|
361
|
+
return "Choice"
|
|
362
|
+
case "invoke":
|
|
363
|
+
return `${trigger.id} · ${trigger.outcome}`
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const decisionLabel = (decision: MachineWalkthrough.Decision): string => {
|
|
368
|
+
switch (decision) {
|
|
369
|
+
case "conditional-branch":
|
|
370
|
+
return "Choose branch"
|
|
371
|
+
case "declinable-transition":
|
|
372
|
+
return "Assume accepted"
|
|
373
|
+
case "automatic-trigger":
|
|
374
|
+
return "Advance automatic trigger"
|
|
375
|
+
case "invoke-outcome":
|
|
376
|
+
return "Choose invoke outcome"
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const unavailableLabel = (reason: MachineWalkthrough.UnavailableReason): string =>
|
|
381
|
+
reason === "history-unavailable"
|
|
382
|
+
? "No history has been recorded for this target yet"
|
|
383
|
+
: "The target is resolved only at runtime"
|
|
384
|
+
|
|
385
|
+
const activeTopology = (paths: ReadonlyArray<string>): string => {
|
|
386
|
+
const leaves = paths.filter((path) => !paths.some((candidate) => candidate.startsWith(`${path}.`)))
|
|
387
|
+
return leaves.map((path) => path.split(".").at(-1) ?? path).join(" + ") || "none"
|
|
174
388
|
}
|
|
175
389
|
|
|
176
390
|
export const renderVisualizer = (
|
|
177
391
|
root: HTMLElement,
|
|
392
|
+
_machineKey: string,
|
|
178
393
|
visualization: VisualizationDocument,
|
|
179
394
|
diagnostics: ReadonlyArray<Diagnostic> = []
|
|
180
395
|
): void => {
|
|
181
396
|
const model = makeVisualizerModel(visualization)
|
|
182
|
-
const
|
|
183
|
-
const
|
|
184
|
-
const
|
|
185
|
-
const
|
|
186
|
-
const
|
|
397
|
+
const transitionsById = new Map(visualization.transitions.map((transition) => [transition.id, transition]))
|
|
398
|
+
const eventSchemas = new Map(visualization.inputs.events.map(({ event, schema }) => [event, schema]))
|
|
399
|
+
const relatedFrom = new Set<string>()
|
|
400
|
+
const relatedTo = new Set<string>()
|
|
401
|
+
const incomingTransitions = new Set<string>()
|
|
402
|
+
const outgoingTransitions = new Set<string>()
|
|
187
403
|
let selectedPath: string | undefined
|
|
188
|
-
let
|
|
189
|
-
let
|
|
404
|
+
let selectedTransition: string | undefined
|
|
405
|
+
let selectedFrame: MachineWalkthrough.Frame | undefined
|
|
406
|
+
let walkthrough: MachineWalkthrough.Session | undefined
|
|
407
|
+
let chartView: ChartView | undefined
|
|
190
408
|
|
|
191
|
-
const activePaths = (): ReadonlyArray<string> =>
|
|
192
|
-
|
|
409
|
+
const activePaths = (): ReadonlyArray<string> =>
|
|
410
|
+
walkthrough === undefined ? model.activePaths : MachineWalkthrough.current(walkthrough).after.activePaths
|
|
411
|
+
const availableChoices = (): ReadonlyArray<MachineWalkthrough.Choice> =>
|
|
412
|
+
walkthrough === undefined ? [] : MachineWalkthrough.choices(walkthrough)
|
|
193
413
|
|
|
194
414
|
const shell = createElement("main", "app-shell")
|
|
195
415
|
const workspace = createElement("section", "workspace")
|
|
196
|
-
const
|
|
197
|
-
|
|
416
|
+
const chartPanel = createElement("section", "chart-panel")
|
|
417
|
+
chartPanel.setAttribute("aria-label", `${model.machineId} topology`)
|
|
198
418
|
const inspector = createElement("aside", "inspector")
|
|
199
419
|
inspector.setAttribute("aria-live", "polite")
|
|
420
|
+
inspector.setAttribute("aria-label", "Selection details")
|
|
421
|
+
inspector.hidden = true
|
|
422
|
+
const inspectorClose = createElement("button", "inspector-close", "Close")
|
|
423
|
+
inspectorClose.type = "button"
|
|
424
|
+
inspectorClose.setAttribute("aria-label", "Close details")
|
|
425
|
+
const inspectorContent = createElement("div", "inspector-content")
|
|
426
|
+
inspector.append(inspectorClose, inspectorContent)
|
|
427
|
+
const choicePicker = createElement("section", "transition-picker")
|
|
428
|
+
choicePicker.setAttribute("role", "dialog")
|
|
429
|
+
choicePicker.setAttribute("aria-label", "Available transitions")
|
|
430
|
+
choicePicker.hidden = true
|
|
431
|
+
const choicePickerHeader = createElement("div", "transition-picker-header")
|
|
432
|
+
const choicePickerTitle = createElement("strong")
|
|
433
|
+
const choicePickerClose = createElement("button", "transition-picker-close", "Close")
|
|
434
|
+
choicePickerClose.type = "button"
|
|
435
|
+
const choicePickerContent = createElement("div", "transition-picker-content")
|
|
436
|
+
choicePickerHeader.append(choicePickerTitle, choicePickerClose)
|
|
437
|
+
choicePicker.append(choicePickerHeader, choicePickerContent)
|
|
200
438
|
|
|
201
439
|
const clearButton = createElement("button", "toolbar-button", "Clear selection")
|
|
202
440
|
clearButton.type = "button"
|
|
203
441
|
clearButton.disabled = true
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
collapseButton.type = "button"
|
|
442
|
+
const detailsButton = createElement("button", "toolbar-button", "View details")
|
|
443
|
+
detailsButton.type = "button"
|
|
444
|
+
detailsButton.disabled = true
|
|
208
445
|
const revealActiveButton = createElement("button", "toolbar-button", "Reveal active")
|
|
209
446
|
revealActiveButton.type = "button"
|
|
210
447
|
revealActiveButton.disabled = model.activePaths.length === 0
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
448
|
+
const walkthroughButton = createElement("button", "toolbar-button", "Start simulation")
|
|
449
|
+
walkthroughButton.type = "button"
|
|
450
|
+
walkthroughButton.disabled = model.roots.length === 0
|
|
451
|
+
|
|
452
|
+
const hideInspector = (): void => {
|
|
453
|
+
inspectorContent.replaceChildren()
|
|
454
|
+
inspector.hidden = true
|
|
455
|
+
detailsButton.textContent = "View details"
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const showInspector = (): void => {
|
|
459
|
+
inspector.hidden = false
|
|
460
|
+
detailsButton.textContent = "Hide details"
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const closeChoicePicker = (): void => {
|
|
464
|
+
choicePicker.hidden = true
|
|
465
|
+
choicePickerContent.replaceChildren()
|
|
229
466
|
}
|
|
230
467
|
|
|
231
468
|
const renderInspection = (inspection: StateInspection): void => {
|
|
232
|
-
|
|
469
|
+
inspectorContent.replaceChildren()
|
|
470
|
+
showInspector()
|
|
233
471
|
const header = createElement("header", "inspector-header")
|
|
234
472
|
const breadcrumbs = createElement("nav", "breadcrumbs")
|
|
235
473
|
breadcrumbs.setAttribute("aria-label", "State path")
|
|
@@ -237,17 +475,20 @@ export const renderVisualizer = (
|
|
|
237
475
|
if (index > 0) breadcrumbs.append(createElement("span", "breadcrumb-separator", "/"))
|
|
238
476
|
breadcrumbs.append(stateLink(item.path, item.label, navigateToState))
|
|
239
477
|
})
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
478
|
+
const titleRow = createElement("div", "inspector-title-row")
|
|
479
|
+
const title = createElement("div", "inspector-state-title")
|
|
480
|
+
title.append(
|
|
481
|
+
stateStatus(activePaths().includes(inspection.state.path), inspection.initial),
|
|
482
|
+
createElement("h2", undefined, inspection.label)
|
|
483
|
+
)
|
|
484
|
+
titleRow.append(title, badge(inspection.state.type, "state"))
|
|
485
|
+
header.append(breadcrumbs, titleRow)
|
|
245
486
|
header.append(metadata([
|
|
246
487
|
["Path", inspection.state.path],
|
|
247
|
-
["Parent", inspection.state.parent
|
|
248
|
-
["Children", String(inspection.state.children.length)],
|
|
249
|
-
["Initial child", inspection.state.initial
|
|
250
|
-
["History", inspection.state.history
|
|
488
|
+
["Parent", inspection.state.parent],
|
|
489
|
+
["Children", inspection.state.children.length > 0 ? String(inspection.state.children.length) : null],
|
|
490
|
+
["Initial child", inspection.state.initial],
|
|
491
|
+
["History", inspection.state.history]
|
|
251
492
|
]))
|
|
252
493
|
if (inspection.state.description !== null || inspection.state.documentation !== null) {
|
|
253
494
|
const annotations = createElement("div", "state-annotations")
|
|
@@ -259,91 +500,200 @@ export const renderVisualizer = (
|
|
|
259
500
|
}
|
|
260
501
|
header.append(annotations)
|
|
261
502
|
}
|
|
262
|
-
|
|
503
|
+
inspectorContent.append(header)
|
|
263
504
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
505
|
+
if (inspection.outgoing.length > 0) {
|
|
506
|
+
const transitions = createElement("section", "inspector-section")
|
|
507
|
+
transitions.append(inspectionSection("Transitions", inspection.outgoing.length))
|
|
508
|
+
inspection.outgoing.forEach((transition) =>
|
|
509
|
+
transitions.append(renderTransition(visualization, transition, navigateToState))
|
|
510
|
+
)
|
|
511
|
+
inspectorContent.append(transitions)
|
|
270
512
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
incoming.append(inspectionSection("Entered by", inspection.incoming.length))
|
|
275
|
-
if (inspection.incoming.length === 0) {
|
|
276
|
-
incoming.append(createElement("p", "section-empty", "No transitions target this state."))
|
|
277
|
-
} else {
|
|
513
|
+
if (inspection.incoming.length > 0) {
|
|
514
|
+
const incoming = createElement("section", "inspector-section")
|
|
515
|
+
incoming.append(inspectionSection("Entered by", inspection.incoming.length))
|
|
278
516
|
inspection.incoming.forEach((transition) =>
|
|
279
|
-
incoming.append(renderIncomingTransition(transition, navigateToState))
|
|
517
|
+
incoming.append(renderIncomingTransition(visualization, transition, navigateToState))
|
|
280
518
|
)
|
|
519
|
+
inspectorContent.append(incoming)
|
|
281
520
|
}
|
|
282
|
-
inspector.append(incoming)
|
|
283
|
-
|
|
284
521
|
if (inspection.activities.length > 0) {
|
|
285
522
|
const activities = createElement("section", "inspector-section")
|
|
286
|
-
activities.append(inspectionSection("
|
|
287
|
-
inspection.activities.forEach((activity) => activities.append(renderActivity(activity)))
|
|
288
|
-
|
|
523
|
+
activities.append(inspectionSection("Invoked", inspection.activities.length))
|
|
524
|
+
inspection.activities.forEach((activity) => activities.append(renderActivity(activity, navigateToState)))
|
|
525
|
+
inspectorContent.append(activities)
|
|
289
526
|
}
|
|
290
527
|
}
|
|
291
528
|
|
|
292
|
-
const
|
|
293
|
-
|
|
529
|
+
const renderTransitionInspection = (transition: VisualizationTransition): void => {
|
|
530
|
+
inspectorContent.replaceChildren()
|
|
531
|
+
showInspector()
|
|
294
532
|
const header = createElement("header", "inspector-header")
|
|
533
|
+
const titleRow = createElement("div", "inspector-title-row")
|
|
534
|
+
const flags = createElement("div", "card-flags")
|
|
535
|
+
flags.append(badge(transition.trigger.type, "trigger"))
|
|
536
|
+
if (transition.reenter) flags.append(badge("reenter"))
|
|
537
|
+
if (transition.acceptance === "declinable") flags.append(badge("declinable"))
|
|
538
|
+
titleRow.append(createElement("h2", undefined, triggerLabel(transition)), flags)
|
|
539
|
+
header.append(titleRow)
|
|
540
|
+
header.append(metadata([
|
|
541
|
+
["Source", transition.source],
|
|
542
|
+
["Branches", transition.branches.length > 1 ? String(transition.branches.length) : null],
|
|
543
|
+
["Acceptance", transition.acceptance === "declinable" ? transition.acceptance : null],
|
|
544
|
+
["Reenter", transition.reenter ? "yes" : null]
|
|
545
|
+
]))
|
|
546
|
+
inspectorContent.append(header)
|
|
547
|
+
if (transition.trigger.type === "event") {
|
|
548
|
+
const schema = eventSchemas.get(transition.trigger.event)
|
|
549
|
+
if (schema !== undefined) inspectorContent.append(renderInputContract("Data contract", schema, ["_tag"]))
|
|
550
|
+
}
|
|
551
|
+
if (transition.branches.length > 0) {
|
|
552
|
+
const details = createElement("section", "inspector-section")
|
|
553
|
+
details.append(inspectionSection("Branches", transition.branches.length))
|
|
554
|
+
const branches = createElement("div", "inspection-card branch-list")
|
|
555
|
+
transition.branches.forEach((branch) =>
|
|
556
|
+
branches.append(renderBranch(visualization, transition.source, branch, navigateToState))
|
|
557
|
+
)
|
|
558
|
+
details.append(branches)
|
|
559
|
+
inspectorContent.append(details)
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const renderPathGroup = (label: string, paths: ReadonlyArray<string>): HTMLElement => {
|
|
564
|
+
const group = createElement("div", "trace-path-group")
|
|
565
|
+
group.append(createElement("span", "trace-label", label))
|
|
566
|
+
const values = createElement("div", "trace-paths")
|
|
567
|
+
paths.forEach((path) => values.append(stateLink(path, path, navigateToState)))
|
|
568
|
+
if (paths.length === 0) values.append(createElement("span", "section-empty", "none"))
|
|
569
|
+
group.append(values)
|
|
570
|
+
return group
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const renderWalkthroughTrace = (frame: MachineWalkthrough.Frame): void => {
|
|
574
|
+
inspectorContent.replaceChildren()
|
|
575
|
+
showInspector()
|
|
576
|
+
const header = createElement("header", "inspector-header trace-header")
|
|
295
577
|
const eyebrow = createElement("div", "inspector-eyebrow")
|
|
296
|
-
eyebrow.append(badge("
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
578
|
+
eyebrow.append(badge("walkthrough", "active"))
|
|
579
|
+
const title = frame.choice === null
|
|
580
|
+
? "Initial configuration"
|
|
581
|
+
: frame.choice.title ?? triggerName(frame.choice.trigger)
|
|
582
|
+
header.append(eyebrow, createElement("h2", undefined, title))
|
|
300
583
|
header.append(metadata([
|
|
301
|
-
["
|
|
302
|
-
[
|
|
584
|
+
["Step", String(frame.step)],
|
|
585
|
+
[
|
|
586
|
+
"Result",
|
|
587
|
+
frame.changed
|
|
588
|
+
? `${activeTopology(frame.before.activePaths)} → ${activeTopology(frame.after.activePaths)}`
|
|
589
|
+
: "Topology unchanged"
|
|
590
|
+
]
|
|
303
591
|
]))
|
|
304
|
-
|
|
592
|
+
inspectorContent.append(header)
|
|
305
593
|
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
594
|
+
const topology = createElement("section", "inspector-section trace-topology")
|
|
595
|
+
topology.append(
|
|
596
|
+
inspectionSection("Topology"),
|
|
597
|
+
renderPathGroup("Before", frame.before.activePaths),
|
|
598
|
+
renderPathGroup("Exit", frame.exitPaths),
|
|
599
|
+
renderPathGroup("Entry", frame.entryPaths),
|
|
600
|
+
renderPathGroup("After", frame.after.activePaths)
|
|
310
601
|
)
|
|
311
|
-
|
|
602
|
+
inspectorContent.append(topology)
|
|
603
|
+
|
|
604
|
+
if (frame.choice === null) {
|
|
605
|
+
if (visualization.inputs.machine !== null) {
|
|
606
|
+
inspectorContent.append(renderInputContract("Machine input contract", visualization.inputs.machine))
|
|
607
|
+
}
|
|
608
|
+
return
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const choice = frame.choice
|
|
612
|
+
const transition = transitionsById.get(choice.transitionId)
|
|
613
|
+
const branch = transition?.branches.find(({ id }) => id === choice.branchId)
|
|
614
|
+
const api = branch === undefined ? undefined : branchTargetApi(visualization, choice.source, branch)
|
|
615
|
+
const selection = createElement("section", "inspector-section")
|
|
616
|
+
selection.append(inspectionSection("Selected branch"))
|
|
617
|
+
const card = createElement("article", "inspection-card trace-card")
|
|
618
|
+
const cardHeader = createElement("div", "card-header")
|
|
619
|
+
const cardTitle = createElement("div", "card-title")
|
|
620
|
+
cardTitle.append(createElement("strong", undefined, triggerName(choice.trigger)))
|
|
621
|
+
const flags = createElement("div", "card-flags")
|
|
622
|
+
choice.decisions.forEach((decision) => flags.append(badge(decisionLabel(decision), "condition")))
|
|
623
|
+
cardHeader.append(cardTitle, flags)
|
|
624
|
+
card.append(cardHeader)
|
|
625
|
+
card.append(metadata([
|
|
626
|
+
["Title", choice.title],
|
|
627
|
+
["Source", stateLink(choice.source, choice.source, navigateToState)],
|
|
628
|
+
["Target", choice.target === null ? "No target" : stateLink(choice.target, choice.target, navigateToState)],
|
|
629
|
+
["API", api]
|
|
630
|
+
]))
|
|
631
|
+
if (choice.updates.length > 0) card.append(renderPathGroup("Updates", choice.updates))
|
|
632
|
+
selection.append(card)
|
|
633
|
+
inspectorContent.append(selection)
|
|
634
|
+
if (choice.input !== null) inspectorContent.append(renderInputContract("Data contract", choice.input, ["_tag"]))
|
|
312
635
|
}
|
|
313
636
|
|
|
314
637
|
const clearRelations = (): void => {
|
|
315
|
-
|
|
316
|
-
|
|
638
|
+
relatedFrom.clear()
|
|
639
|
+
relatedTo.clear()
|
|
640
|
+
incomingTransitions.clear()
|
|
641
|
+
outgoingTransitions.clear()
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const markFrame = (frame: MachineWalkthrough.Frame): void => {
|
|
645
|
+
clearRelations()
|
|
646
|
+
if (frame.choice === null) return
|
|
647
|
+
relatedFrom.add(frame.choice.source)
|
|
648
|
+
outgoingTransitions.add(frame.choice.transitionId)
|
|
649
|
+
if (frame.choice.target !== null) relatedTo.add(frame.choice.target)
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const chartPresentation = (): ChartPresentation => {
|
|
653
|
+
const choices = availableChoices()
|
|
654
|
+
const usable = choices.filter(({ unavailableReason }) => unavailableReason === null)
|
|
655
|
+
return {
|
|
656
|
+
simulationMode: walkthrough !== undefined,
|
|
657
|
+
activePaths: activePaths(),
|
|
658
|
+
selectedState: selectedPath ?? null,
|
|
659
|
+
selectedTransition: selectedTransition ?? null,
|
|
660
|
+
fromPaths: [...relatedFrom],
|
|
661
|
+
toPaths: [...relatedTo],
|
|
662
|
+
incomingTransitionIds: [...incomingTransitions],
|
|
663
|
+
outgoingTransitionIds: [...outgoingTransitions],
|
|
664
|
+
availableBranchIds: usable.map(({ branchId }) => branchId),
|
|
665
|
+
unavailableBranchIds: choices.filter(({ unavailableReason }) => unavailableReason !== null).map((
|
|
666
|
+
{ branchId }
|
|
667
|
+
) => branchId)
|
|
317
668
|
}
|
|
318
|
-
relatedPaths.clear()
|
|
319
669
|
}
|
|
320
670
|
|
|
671
|
+
const updateChartPresentation = (): void => chartView?.update(chartPresentation())
|
|
672
|
+
|
|
321
673
|
const clearSelection = (): void => {
|
|
322
|
-
if (
|
|
323
|
-
|
|
324
|
-
|
|
674
|
+
if (walkthrough !== undefined) {
|
|
675
|
+
closeChoicePicker()
|
|
676
|
+
return
|
|
325
677
|
}
|
|
326
|
-
if (selectedEvent !== undefined) eventButtons.get(selectedEvent)?.classList.remove("is-selected")
|
|
327
678
|
clearRelations()
|
|
328
679
|
selectedPath = undefined
|
|
329
|
-
|
|
680
|
+
selectedTransition = undefined
|
|
681
|
+
selectedFrame = undefined
|
|
330
682
|
clearButton.disabled = true
|
|
331
|
-
|
|
683
|
+
detailsButton.disabled = true
|
|
684
|
+
hideInspector()
|
|
685
|
+
updateChartPresentation()
|
|
332
686
|
}
|
|
333
687
|
|
|
334
|
-
const markTransitions = (
|
|
688
|
+
const markTransitions = (
|
|
689
|
+
transitions: ReadonlyArray<VisualizationTransition>,
|
|
690
|
+
includeSource = true
|
|
691
|
+
): void => {
|
|
335
692
|
for (const transition of transitions) {
|
|
336
|
-
|
|
337
|
-
|
|
693
|
+
if (includeSource) relatedFrom.add(transition.source)
|
|
694
|
+
outgoingTransitions.add(transition.id)
|
|
338
695
|
for (const branch of transition.branches) {
|
|
339
|
-
if (branch.target !== null)
|
|
340
|
-
relatedPaths.add(branch.target)
|
|
341
|
-
nodes.get(branch.target)?.classList.add("is-related-target")
|
|
342
|
-
}
|
|
343
|
-
for (const update of branch.updates) {
|
|
344
|
-
relatedPaths.add(update)
|
|
345
|
-
nodes.get(update)?.classList.add("is-related-update")
|
|
346
|
-
}
|
|
696
|
+
if (branch.target !== null) relatedTo.add(branch.target)
|
|
347
697
|
}
|
|
348
698
|
}
|
|
349
699
|
}
|
|
@@ -351,137 +701,269 @@ export const renderVisualizer = (
|
|
|
351
701
|
const markRelatedStates = (inspection: StateInspection): void => {
|
|
352
702
|
clearRelations()
|
|
353
703
|
for (const incoming of inspection.incoming) {
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
}
|
|
357
|
-
markTransitions(inspection.outgoing)
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
const expandAncestors = (inspection: StateInspection): void => {
|
|
361
|
-
for (const ancestor of inspection.breadcrumbs.slice(0, -1)) {
|
|
362
|
-
const row = rows.get(ancestor.path)
|
|
363
|
-
const children = nodes.get(ancestor.path)?.querySelector<HTMLElement>(":scope > .topology-children")
|
|
364
|
-
if (row === undefined || children === null || children === undefined) continue
|
|
365
|
-
row.setAttribute("aria-expanded", "true")
|
|
366
|
-
children.hidden = false
|
|
367
|
-
const disclosure = row.querySelector<HTMLElement>(".state-disclosure")
|
|
368
|
-
if (disclosure !== null) disclosure.textContent = "▾"
|
|
704
|
+
relatedFrom.add(incoming.transition.source)
|
|
705
|
+
incomingTransitions.add(incoming.transition.id)
|
|
369
706
|
}
|
|
707
|
+
markTransitions(inspection.outgoing, false)
|
|
370
708
|
}
|
|
371
709
|
|
|
372
710
|
const selectState = (path: string, focus: boolean): void => {
|
|
711
|
+
if (walkthrough !== undefined) return
|
|
373
712
|
const inspection = model.inspectState(path)
|
|
374
713
|
if (inspection === undefined) return
|
|
375
|
-
expandAncestors(inspection)
|
|
376
|
-
if (selectedPath !== undefined) {
|
|
377
|
-
nodes.get(selectedPath)?.classList.remove("is-selected")
|
|
378
|
-
rows.get(selectedPath)?.setAttribute("aria-selected", "false")
|
|
379
|
-
}
|
|
380
|
-
if (selectedEvent !== undefined) eventButtons.get(selectedEvent)?.classList.remove("is-selected")
|
|
381
714
|
selectedPath = path
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
rows.get(path)?.setAttribute("aria-selected", "true")
|
|
715
|
+
selectedTransition = undefined
|
|
716
|
+
selectedFrame = undefined
|
|
385
717
|
markRelatedStates(inspection)
|
|
386
718
|
clearButton.disabled = false
|
|
387
|
-
|
|
388
|
-
if (
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
}
|
|
719
|
+
detailsButton.disabled = false
|
|
720
|
+
if (!inspector.hidden) renderInspection(inspection)
|
|
721
|
+
updateChartPresentation()
|
|
722
|
+
if (focus) chartView?.focusState(path)
|
|
392
723
|
}
|
|
393
724
|
|
|
394
725
|
function navigateToState(path: string): void {
|
|
395
726
|
selectState(path, true)
|
|
396
727
|
}
|
|
397
728
|
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
rows.get(selectedPath)?.setAttribute("aria-selected", "false")
|
|
403
|
-
}
|
|
404
|
-
if (selectedEvent !== undefined) eventButtons.get(selectedEvent)?.classList.remove("is-selected")
|
|
729
|
+
const selectTransition = (transitionId: string): void => {
|
|
730
|
+
if (walkthrough !== undefined) return
|
|
731
|
+
const transition = transitionsById.get(transitionId)
|
|
732
|
+
if (transition === undefined) return
|
|
405
733
|
selectedPath = undefined
|
|
406
|
-
|
|
407
|
-
|
|
734
|
+
selectedTransition = transitionId
|
|
735
|
+
selectedFrame = undefined
|
|
408
736
|
clearRelations()
|
|
409
|
-
markTransitions(
|
|
737
|
+
markTransitions([transition])
|
|
410
738
|
clearButton.disabled = false
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
const node = row.closest<HTMLElement>(".topology-node")
|
|
416
|
-
const children = node?.querySelector<HTMLElement>(":scope > .topology-children")
|
|
417
|
-
if (children === null || children === undefined) return
|
|
418
|
-
row.setAttribute("aria-expanded", String(expanded))
|
|
419
|
-
children.hidden = !expanded
|
|
420
|
-
const disclosure = row.querySelector<HTMLElement>(".state-disclosure")
|
|
421
|
-
if (disclosure !== null) disclosure.textContent = expanded ? "▾" : "▸"
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
const renderNode = (node: TopologyNode, depth: number): HTMLElement => {
|
|
425
|
-
const container = createElement("div", "topology-node")
|
|
426
|
-
container.dataset.statePath = node.path
|
|
427
|
-
nodes.set(node.path, container)
|
|
428
|
-
|
|
429
|
-
const row = createElement("button", "state-row")
|
|
430
|
-
row.type = "button"
|
|
431
|
-
row.tabIndex = -1
|
|
432
|
-
row.setAttribute("role", "treeitem")
|
|
433
|
-
row.setAttribute("aria-level", String(depth + 1))
|
|
434
|
-
row.setAttribute("aria-selected", "false")
|
|
435
|
-
row.style.setProperty("--depth", String(depth))
|
|
436
|
-
row.dataset.statePath = node.path
|
|
437
|
-
rows.set(node.path, row)
|
|
438
|
-
|
|
439
|
-
const disclosure = createElement("span", "state-disclosure", node.children.length === 0 ? "" : "▾")
|
|
440
|
-
const status = createElement("span", `state-status${node.active ? " is-active" : ""}`)
|
|
441
|
-
status.setAttribute("aria-label", node.active ? "active" : "inactive")
|
|
442
|
-
statuses.set(node.path, status)
|
|
443
|
-
const label = createElement("span", "state-label", node.label)
|
|
444
|
-
const markers = createElement("span", "state-markers")
|
|
445
|
-
if (node.initial) markers.append(badge("initial", "initial"))
|
|
446
|
-
if (node.type !== "atomic") markers.append(badge(node.type, "state"))
|
|
447
|
-
if (node.transitionCount > 0) markers.append(badge(`${node.transitionCount}t`, "count"))
|
|
448
|
-
if (node.activityCount > 0) markers.append(badge(`${node.activityCount}a`, "count"))
|
|
449
|
-
row.append(disclosure, status, label, markers)
|
|
450
|
-
container.append(row)
|
|
451
|
-
row.addEventListener("focus", () => {
|
|
452
|
-
rows.forEach((candidate) => candidate.tabIndex = candidate === row ? 0 : -1)
|
|
453
|
-
})
|
|
739
|
+
detailsButton.disabled = false
|
|
740
|
+
if (!inspector.hidden) renderTransitionInspection(transition)
|
|
741
|
+
updateChartPresentation()
|
|
742
|
+
}
|
|
454
743
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
744
|
+
const openStateDetails = (path: string): void => {
|
|
745
|
+
if (walkthrough !== undefined) return
|
|
746
|
+
selectState(path, false)
|
|
747
|
+
const inspection = model.inspectState(path)
|
|
748
|
+
if (inspection !== undefined) renderInspection(inspection)
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const openTransitionDetails = (transitionId: string): void => {
|
|
752
|
+
if (walkthrough !== undefined) return
|
|
753
|
+
selectTransition(transitionId)
|
|
754
|
+
const transition = transitionsById.get(transitionId)
|
|
755
|
+
if (transition !== undefined) renderTransitionInspection(transition)
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const walkthroughDock = createElement("section", "walkthrough-dock")
|
|
759
|
+
walkthroughDock.hidden = true
|
|
760
|
+
const walkthroughHeading = createElement("div", "walkthrough-heading")
|
|
761
|
+
const walkthroughIdentity = createElement("div", "walkthrough-identity")
|
|
762
|
+
const walkthroughHint = createElement("span", "walkthrough-hint")
|
|
763
|
+
const walkthroughStatus = createElement("span", "walkthrough-status")
|
|
764
|
+
const walkthroughTimeline = createElement("div", "walkthrough-timeline")
|
|
765
|
+
walkthroughTimeline.setAttribute("aria-label", "Simulation timeline")
|
|
766
|
+
walkthroughIdentity.append(createElement("strong", undefined, "Simulation"), walkthroughHint)
|
|
767
|
+
walkthroughHeading.append(walkthroughIdentity, walkthroughStatus)
|
|
768
|
+
walkthroughDock.append(walkthroughHeading, walkthroughTimeline)
|
|
769
|
+
|
|
770
|
+
const selectFrame = (frame: MachineWalkthrough.Frame): void => {
|
|
771
|
+
selectedPath = undefined
|
|
772
|
+
selectedTransition = undefined
|
|
773
|
+
selectedFrame = frame
|
|
774
|
+
markFrame(frame)
|
|
775
|
+
clearButton.disabled = false
|
|
776
|
+
detailsButton.disabled = false
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const choiceRoute = (choice: MachineWalkthrough.Choice): string =>
|
|
780
|
+
`${choice.source} → ${choice.target ?? "no target"}`
|
|
781
|
+
|
|
782
|
+
const takeChoice = (choice: MachineWalkthrough.Choice): void => {
|
|
783
|
+
if (walkthrough === undefined || choice.unavailableReason !== null) return
|
|
784
|
+
const result = MachineWalkthrough.take(walkthrough, choice.id)
|
|
785
|
+
if (Result.isFailure(result)) return
|
|
786
|
+
walkthrough = result.success
|
|
787
|
+
selectFrame(MachineWalkthrough.current(walkthrough))
|
|
788
|
+
closeChoicePicker()
|
|
789
|
+
updateWalkthroughUi()
|
|
790
|
+
chartView?.revealStates(activePaths())
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const renderPickerChoice = (choice: MachineWalkthrough.Choice): HTMLButtonElement => {
|
|
794
|
+
const button = createElement(
|
|
795
|
+
"button",
|
|
796
|
+
`transition-picker-choice${choice.unavailableReason === null ? "" : " is-unavailable"}`
|
|
797
|
+
)
|
|
798
|
+
button.type = "button"
|
|
799
|
+
button.disabled = choice.unavailableReason !== null
|
|
800
|
+
const main = createElement("span", "transition-picker-choice-main")
|
|
801
|
+
main.append(createElement("strong", undefined, triggerName(choice.trigger)))
|
|
802
|
+
if (choice.title !== null) main.append(createElement("span", "transition-picker-choice-title", choice.title))
|
|
803
|
+
button.append(main, createElement("span", "transition-picker-choice-route", choiceRoute(choice)))
|
|
804
|
+
const contract = contractSummary(choice.input)
|
|
805
|
+
if (contract !== null) button.append(createElement("span", "transition-picker-choice-contract", contract))
|
|
806
|
+
if (choice.decisions.length > 0) {
|
|
807
|
+
button.append(createElement(
|
|
808
|
+
"span",
|
|
809
|
+
"transition-picker-choice-decision",
|
|
810
|
+
choice.decisions.map(decisionLabel).join(" · ")
|
|
811
|
+
))
|
|
812
|
+
}
|
|
813
|
+
if (choice.unavailableReason !== null) {
|
|
814
|
+
button.title = unavailableLabel(choice.unavailableReason)
|
|
815
|
+
button.append(createElement(
|
|
816
|
+
"span",
|
|
817
|
+
"transition-picker-choice-unavailable",
|
|
818
|
+
unavailableLabel(choice.unavailableReason)
|
|
819
|
+
))
|
|
466
820
|
} else {
|
|
467
|
-
|
|
821
|
+
button.title = choiceRoute(choice)
|
|
822
|
+
button.addEventListener("click", () => takeChoice(choice))
|
|
823
|
+
}
|
|
824
|
+
return button
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const showChoicePicker = (
|
|
828
|
+
title: string,
|
|
829
|
+
choices: ReadonlyArray<MachineWalkthrough.Choice>,
|
|
830
|
+
anchor: ChartInteractionAnchor
|
|
831
|
+
): void => {
|
|
832
|
+
if (choices.length === 1 && choices[0]?.unavailableReason === null) {
|
|
833
|
+
takeChoice(choices[0])
|
|
834
|
+
return
|
|
835
|
+
}
|
|
836
|
+
if (choices.length === 0) {
|
|
837
|
+
closeChoicePicker()
|
|
838
|
+
return
|
|
839
|
+
}
|
|
840
|
+
choicePickerTitle.textContent = title
|
|
841
|
+
choicePickerContent.replaceChildren(...choices.map(renderPickerChoice))
|
|
842
|
+
choicePicker.style.left = `${anchor.x + 12}px`
|
|
843
|
+
choicePicker.style.top = `${anchor.y + 12}px`
|
|
844
|
+
choicePicker.hidden = false
|
|
845
|
+
requestAnimationFrame(() => {
|
|
846
|
+
const bounds = choicePicker.getBoundingClientRect()
|
|
847
|
+
choicePicker.style.left = `${Math.max(10, Math.min(anchor.x + 12, window.innerWidth - bounds.width - 10))}px`
|
|
848
|
+
choicePicker.style.top = `${Math.max(58, Math.min(anchor.y + 12, window.innerHeight - bounds.height - 10))}px`
|
|
849
|
+
})
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const handleStateClick = (path: string, _anchor: ChartInteractionAnchor): void => {
|
|
853
|
+
if (walkthrough === undefined) {
|
|
854
|
+
selectState(path, false)
|
|
855
|
+
return
|
|
856
|
+
}
|
|
857
|
+
closeChoicePicker()
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const handleTransitionClick = (
|
|
861
|
+
transitionId: string,
|
|
862
|
+
branchIds: ReadonlyArray<string>,
|
|
863
|
+
anchor: ChartInteractionAnchor
|
|
864
|
+
): void => {
|
|
865
|
+
if (walkthrough === undefined) {
|
|
866
|
+
selectTransition(transitionId)
|
|
867
|
+
return
|
|
468
868
|
}
|
|
469
|
-
|
|
869
|
+
const transition = transitionsById.get(transitionId)
|
|
870
|
+
if (transition === undefined) return
|
|
871
|
+
showChoicePicker(
|
|
872
|
+
triggerLabel(transition),
|
|
873
|
+
MachineWalkthrough.choices(walkthrough).filter((choice) => branchIds.includes(choice.branchId)),
|
|
874
|
+
anchor
|
|
875
|
+
)
|
|
470
876
|
}
|
|
471
877
|
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
878
|
+
const renderWalkthroughDock = (): void => {
|
|
879
|
+
walkthroughDock.hidden = walkthrough === undefined
|
|
880
|
+
chartPanel.classList.toggle("has-walkthrough", walkthrough !== undefined)
|
|
881
|
+
if (walkthrough === undefined) {
|
|
882
|
+
walkthroughTimeline.replaceChildren()
|
|
883
|
+
return
|
|
884
|
+
}
|
|
885
|
+
const cursor = MachineWalkthrough.cursor(walkthrough)
|
|
886
|
+
const timeline = MachineWalkthrough.timeline(walkthrough)
|
|
887
|
+
const choices = MachineWalkthrough.choices(walkthrough)
|
|
888
|
+
walkthroughStatus.textContent = `Step ${cursor} · ${activeTopology(activePaths())}`
|
|
889
|
+
walkthroughHint.textContent = choices.length === 0
|
|
890
|
+
? "No outgoing transitions"
|
|
891
|
+
: "Click a highlighted transition to advance"
|
|
892
|
+
walkthroughTimeline.replaceChildren()
|
|
893
|
+
timeline.forEach((frame) => {
|
|
894
|
+
const label = frame.choice === null ? "Initial" : frame.choice.title ?? triggerName(frame.choice.trigger)
|
|
895
|
+
const button = createElement(
|
|
896
|
+
"button",
|
|
897
|
+
`walkthrough-step${frame.step === cursor ? " is-current" : ""}`,
|
|
898
|
+
`${frame.step} · ${label}`
|
|
899
|
+
)
|
|
900
|
+
button.type = "button"
|
|
901
|
+
button.addEventListener("click", () => {
|
|
902
|
+
if (walkthrough === undefined) return
|
|
903
|
+
const result = MachineWalkthrough.seek(walkthrough, frame.step)
|
|
904
|
+
if (Result.isFailure(result)) return
|
|
905
|
+
walkthrough = result.success
|
|
906
|
+
selectFrame(MachineWalkthrough.current(walkthrough))
|
|
907
|
+
closeChoicePicker()
|
|
908
|
+
updateWalkthroughUi()
|
|
909
|
+
chartView?.revealStates(activePaths())
|
|
910
|
+
})
|
|
911
|
+
walkthroughTimeline.append(button)
|
|
475
912
|
})
|
|
476
913
|
}
|
|
477
914
|
|
|
478
915
|
clearButton.addEventListener("click", clearSelection)
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
916
|
+
inspectorClose.addEventListener("click", hideInspector)
|
|
917
|
+
choicePickerClose.addEventListener("click", closeChoicePicker)
|
|
918
|
+
detailsButton.addEventListener("click", () => {
|
|
919
|
+
if (!inspector.hidden) {
|
|
920
|
+
hideInspector()
|
|
921
|
+
} else if (selectedFrame !== undefined) {
|
|
922
|
+
renderWalkthroughTrace(selectedFrame)
|
|
923
|
+
} else if (selectedPath !== undefined) {
|
|
924
|
+
const inspection = model.inspectState(selectedPath)
|
|
925
|
+
if (inspection !== undefined) renderInspection(inspection)
|
|
926
|
+
} else if (selectedTransition !== undefined) {
|
|
927
|
+
const transition = transitionsById.get(selectedTransition)
|
|
928
|
+
if (transition !== undefined) renderTransitionInspection(transition)
|
|
929
|
+
}
|
|
484
930
|
})
|
|
931
|
+
revealActiveButton.addEventListener("click", () => chartView?.revealStates(activePaths()))
|
|
932
|
+
|
|
933
|
+
const zoomOutButton = createElement("button", "toolbar-button zoom-button", "−")
|
|
934
|
+
zoomOutButton.type = "button"
|
|
935
|
+
zoomOutButton.setAttribute("aria-label", "Zoom out")
|
|
936
|
+
const zoomResetButton = createElement("button", "toolbar-button zoom-value", "100%")
|
|
937
|
+
zoomResetButton.type = "button"
|
|
938
|
+
zoomResetButton.setAttribute("aria-label", "Reset zoom")
|
|
939
|
+
const zoomInButton = createElement("button", "toolbar-button zoom-button", "+")
|
|
940
|
+
zoomInButton.type = "button"
|
|
941
|
+
zoomInButton.setAttribute("aria-label", "Zoom in")
|
|
942
|
+
const zoomFitButton = createElement("button", "toolbar-button zoom-button", "Fit")
|
|
943
|
+
zoomFitButton.type = "button"
|
|
944
|
+
zoomFitButton.setAttribute("aria-label", "Fit chart")
|
|
945
|
+
const zoomButtons = [zoomOutButton, zoomResetButton, zoomInButton, zoomFitButton]
|
|
946
|
+
zoomButtons.forEach((button) => button.disabled = true)
|
|
947
|
+
|
|
948
|
+
const updateZoomControls = (): void => {
|
|
949
|
+
const zoom = chartView?.getZoom()
|
|
950
|
+
zoomResetButton.textContent = `${Math.round((zoom ?? 1) * 100)}%`
|
|
951
|
+
zoomOutButton.disabled = zoom === undefined || zoom <= minimumChartZoom
|
|
952
|
+
zoomResetButton.disabled = zoom === undefined || zoom === 1
|
|
953
|
+
zoomInButton.disabled = zoom === undefined || zoom >= maximumChartZoom
|
|
954
|
+
zoomFitButton.disabled = zoom === undefined
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const changeZoom = (next: (view: ChartView) => number): void => {
|
|
958
|
+
if (chartView === undefined) return
|
|
959
|
+
next(chartView)
|
|
960
|
+
updateZoomControls()
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
zoomOutButton.addEventListener("click", () => changeZoom((view) => view.setZoom(view.getZoom() - 0.1)))
|
|
964
|
+
zoomResetButton.addEventListener("click", () => changeZoom((view) => view.setZoom(1)))
|
|
965
|
+
zoomInButton.addEventListener("click", () => changeZoom((view) => view.setZoom(view.getZoom() + 0.1)))
|
|
966
|
+
zoomFitButton.addEventListener("click", () => changeZoom((view) => view.fit()))
|
|
485
967
|
|
|
486
968
|
const toolbar = createElement("div", "toolbar")
|
|
487
969
|
const runtime = createElement("div", "runtime-summary")
|
|
@@ -489,154 +971,69 @@ export const renderVisualizer = (
|
|
|
489
971
|
const runtimeText = createElement("span")
|
|
490
972
|
runtime.append(runtimeDot, runtimeText)
|
|
491
973
|
const toolbarActions = createElement("div", "toolbar-actions")
|
|
492
|
-
toolbarActions.append(clearButton,
|
|
974
|
+
toolbarActions.append(clearButton, detailsButton, walkthroughButton, revealActiveButton)
|
|
975
|
+
const zoomControls = createElement("div", "zoom-controls")
|
|
976
|
+
zoomControls.setAttribute("role", "group")
|
|
977
|
+
zoomControls.setAttribute("aria-label", "Chart zoom")
|
|
978
|
+
zoomControls.append(zoomOutButton, zoomResetButton, zoomInButton, zoomFitButton)
|
|
493
979
|
toolbar.append(runtime, toolbarActions)
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
simulationFeedback.setAttribute("role", "status")
|
|
501
|
-
tree.append(events, simulationFeedback)
|
|
502
|
-
|
|
503
|
-
const renderEventButtons = (): void => {
|
|
504
|
-
events.replaceChildren(createElement("span", "enabled-events-label", "Enabled"))
|
|
505
|
-
eventButtons.clear()
|
|
506
|
-
const candidates = candidateEvents()
|
|
507
|
-
if (candidates.length === 0) {
|
|
508
|
-
events.append(createElement("span", "enabled-events-empty", "none"))
|
|
509
|
-
return
|
|
510
|
-
}
|
|
511
|
-
candidates.forEach((event) => {
|
|
512
|
-
const button = createElement("button", `event-button${event === selectedEvent ? " is-selected" : ""}`, event)
|
|
513
|
-
button.type = "button"
|
|
514
|
-
button.addEventListener("click", () => {
|
|
515
|
-
if (simulation !== undefined) {
|
|
516
|
-
const result = MachineSimulator.send(simulation, event)
|
|
517
|
-
if (result._tag === "Applied") simulation = { document: visualization, snapshot: result.session }
|
|
518
|
-
simulationFeedback.textContent = simulationResultMessage(result)
|
|
519
|
-
simulationFeedback.dataset.status = result._tag.toLowerCase()
|
|
520
|
-
updateSimulationUi()
|
|
521
|
-
}
|
|
522
|
-
selectEvent(event)
|
|
523
|
-
})
|
|
524
|
-
eventButtons.set(event, button)
|
|
525
|
-
events.append(button)
|
|
526
|
-
})
|
|
527
|
-
}
|
|
980
|
+
const chart = createElement("div", "topology-chart")
|
|
981
|
+
chart.setAttribute("role", "region")
|
|
982
|
+
chart.setAttribute("aria-label", `${model.machineId} states`)
|
|
983
|
+
const chartHost = createElement("div", "chart-host")
|
|
984
|
+
chartHost.append(createElement("div", "chart-loading", "Computing layout…"))
|
|
985
|
+
chart.append(chartHost)
|
|
528
986
|
|
|
529
|
-
const
|
|
987
|
+
const updateWalkthroughUi = (): void => {
|
|
530
988
|
const active = new Set(activePaths())
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
status.setAttribute("aria-label", isActive ? "active" : "inactive")
|
|
535
|
-
})
|
|
536
|
-
const hasRuntimeState = simulation !== undefined || model.hasSnapshot
|
|
989
|
+
const simulating = walkthrough !== undefined
|
|
990
|
+
updateChartPresentation()
|
|
991
|
+
const hasRuntimeState = simulating || model.hasSnapshot
|
|
537
992
|
runtimeDot.classList.toggle("has-snapshot", hasRuntimeState)
|
|
538
|
-
runtimeText.textContent =
|
|
539
|
-
?
|
|
993
|
+
runtimeText.textContent = walkthrough !== undefined
|
|
994
|
+
? `Simulation · ${active.size} active · step ${MachineWalkthrough.cursor(walkthrough)}`
|
|
540
995
|
: diagnostics.length > 0
|
|
541
996
|
? "Partial"
|
|
542
997
|
: model.hasSnapshot
|
|
543
998
|
? `${active.size} active`
|
|
544
999
|
: "Structure only"
|
|
545
|
-
|
|
1000
|
+
walkthroughButton.textContent = simulating ? "Exit simulation" : "Start simulation"
|
|
1001
|
+
walkthroughButton.disabled = model.roots.length === 0
|
|
546
1002
|
revealActiveButton.disabled = active.size === 0
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
if (
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
1003
|
+
clearButton.hidden = simulating
|
|
1004
|
+
detailsButton.hidden = simulating
|
|
1005
|
+
chartPanel.classList.toggle("is-simulating", simulating)
|
|
1006
|
+
if (simulating) hideInspector()
|
|
1007
|
+
renderWalkthroughDock()
|
|
1008
|
+
if (!simulating && !inspector.hidden) {
|
|
1009
|
+
if (selectedFrame !== undefined) {
|
|
1010
|
+
renderWalkthroughTrace(selectedFrame)
|
|
1011
|
+
} else if (selectedPath !== undefined) {
|
|
1012
|
+
const inspection = model.inspectState(selectedPath)
|
|
1013
|
+
if (inspection !== undefined) renderInspection(inspection)
|
|
1014
|
+
} else if (selectedTransition !== undefined) {
|
|
1015
|
+
const transition = transitionsById.get(selectedTransition)
|
|
1016
|
+
if (transition !== undefined) renderTransitionInspection(transition)
|
|
1017
|
+
}
|
|
555
1018
|
}
|
|
556
1019
|
}
|
|
557
1020
|
|
|
558
|
-
|
|
559
|
-
if (
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
1021
|
+
walkthroughButton.addEventListener("click", () => {
|
|
1022
|
+
if (walkthrough === undefined) {
|
|
1023
|
+
walkthrough = MachineWalkthrough.start(visualization)
|
|
1024
|
+
selectFrame(MachineWalkthrough.current(walkthrough))
|
|
1025
|
+
hideInspector()
|
|
1026
|
+
closeChoicePicker()
|
|
1027
|
+
requestAnimationFrame(() => chartView?.revealStates(activePaths()))
|
|
563
1028
|
} else {
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
1029
|
+
walkthrough = undefined
|
|
1030
|
+
closeChoicePicker()
|
|
1031
|
+
clearSelection()
|
|
567
1032
|
}
|
|
568
|
-
|
|
1033
|
+
updateWalkthroughUi()
|
|
569
1034
|
})
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
empty.append(
|
|
573
|
-
createElement("strong", undefined, "No states yet"),
|
|
574
|
-
createElement("span", undefined, "The topology will appear as the machine definition becomes available.")
|
|
575
|
-
)
|
|
576
|
-
tree.append(empty)
|
|
577
|
-
} else {
|
|
578
|
-
model.roots.forEach((node) => tree.append(renderNode(node, 0)))
|
|
579
|
-
}
|
|
580
|
-
updateSimulationUi()
|
|
581
|
-
rows.values().next().value?.setAttribute("tabindex", "0")
|
|
582
|
-
tree.addEventListener("keydown", (event) => {
|
|
583
|
-
const current = event.target instanceof HTMLElement ? event.target.closest<HTMLElement>(".state-row") : null
|
|
584
|
-
if (current === null) return
|
|
585
|
-
const visible = [...rows.values()].filter((row) => row.getClientRects().length > 0)
|
|
586
|
-
const index = visible.indexOf(current)
|
|
587
|
-
const focus = (row: HTMLElement | undefined): void => {
|
|
588
|
-
if (row === undefined) return
|
|
589
|
-
event.preventDefault()
|
|
590
|
-
row.focus()
|
|
591
|
-
}
|
|
592
|
-
switch (event.key) {
|
|
593
|
-
case "ArrowDown":
|
|
594
|
-
focus(visible[index + 1])
|
|
595
|
-
break
|
|
596
|
-
case "ArrowUp":
|
|
597
|
-
focus(visible[index - 1])
|
|
598
|
-
break
|
|
599
|
-
case "Home":
|
|
600
|
-
focus(visible[0])
|
|
601
|
-
break
|
|
602
|
-
case "End":
|
|
603
|
-
focus(visible.at(-1))
|
|
604
|
-
break
|
|
605
|
-
case "ArrowRight": {
|
|
606
|
-
if (current.getAttribute("aria-expanded") === "false") {
|
|
607
|
-
event.preventDefault()
|
|
608
|
-
setExpanded(current, true)
|
|
609
|
-
} else {
|
|
610
|
-
const child = current.closest<HTMLElement>(".topology-node")
|
|
611
|
-
?.querySelector<HTMLElement>(":scope > .topology-children > .topology-node > .state-row")
|
|
612
|
-
focus(child ?? undefined)
|
|
613
|
-
}
|
|
614
|
-
break
|
|
615
|
-
}
|
|
616
|
-
case "ArrowLeft": {
|
|
617
|
-
if (current.getAttribute("aria-expanded") === "true") {
|
|
618
|
-
event.preventDefault()
|
|
619
|
-
setExpanded(current, false)
|
|
620
|
-
} else {
|
|
621
|
-
const parent = current.closest<HTMLElement>(".topology-children")
|
|
622
|
-
?.closest<HTMLElement>(".topology-node")
|
|
623
|
-
?.querySelector<HTMLElement>(":scope > .state-row")
|
|
624
|
-
focus(parent ?? undefined)
|
|
625
|
-
}
|
|
626
|
-
break
|
|
627
|
-
}
|
|
628
|
-
case "Enter":
|
|
629
|
-
case " ":
|
|
630
|
-
event.preventDefault()
|
|
631
|
-
current.click()
|
|
632
|
-
break
|
|
633
|
-
case "Escape":
|
|
634
|
-
event.preventDefault()
|
|
635
|
-
clearSelection()
|
|
636
|
-
break
|
|
637
|
-
}
|
|
638
|
-
})
|
|
639
|
-
treePanel.append(toolbar)
|
|
1035
|
+
|
|
1036
|
+
chartPanel.append(toolbar)
|
|
640
1037
|
if (diagnostics.length > 0) {
|
|
641
1038
|
const diagnosticList = createElement("div", "diagnostics")
|
|
642
1039
|
diagnosticList.setAttribute("role", "status")
|
|
@@ -648,12 +1045,44 @@ export const renderVisualizer = (
|
|
|
648
1045
|
}
|
|
649
1046
|
diagnosticList.append(item)
|
|
650
1047
|
})
|
|
651
|
-
|
|
1048
|
+
chartPanel.append(diagnosticList)
|
|
652
1049
|
}
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
workspace.append(treePanel, inspector)
|
|
1050
|
+
chartPanel.append(chart, walkthroughDock, zoomControls)
|
|
1051
|
+
hideInspector()
|
|
1052
|
+
workspace.append(chartPanel, inspector, choicePicker)
|
|
657
1053
|
shell.append(workspace)
|
|
658
1054
|
root.replaceChildren(shell)
|
|
1055
|
+
|
|
1056
|
+
if (model.roots.length === 0) {
|
|
1057
|
+
const empty = createElement("div", "topology-empty")
|
|
1058
|
+
empty.append(
|
|
1059
|
+
createElement("strong", undefined, "No states yet"),
|
|
1060
|
+
createElement("span", undefined, "The topology will appear as the machine definition becomes available.")
|
|
1061
|
+
)
|
|
1062
|
+
chartHost.replaceChildren(empty)
|
|
1063
|
+
} else {
|
|
1064
|
+
void Effect.runPromise(renderChart(chartHost, visualization, {
|
|
1065
|
+
selectState: handleStateClick,
|
|
1066
|
+
openStateDetails,
|
|
1067
|
+
selectTransition: handleTransitionClick,
|
|
1068
|
+
openTransitionDetails,
|
|
1069
|
+
clearSelection,
|
|
1070
|
+
zoomChanged: updateZoomControls
|
|
1071
|
+
})).then(
|
|
1072
|
+
(view) => {
|
|
1073
|
+
chartView = view
|
|
1074
|
+
updateChartPresentation()
|
|
1075
|
+
updateZoomControls()
|
|
1076
|
+
},
|
|
1077
|
+
(cause) => {
|
|
1078
|
+
const failure = createElement("div", "chart-layout-error")
|
|
1079
|
+
failure.append(
|
|
1080
|
+
createElement("strong", undefined, "The chart could not be laid out"),
|
|
1081
|
+
createElement("span", undefined, cause instanceof Error ? cause.message : String(cause))
|
|
1082
|
+
)
|
|
1083
|
+
chartHost.replaceChildren(failure)
|
|
1084
|
+
}
|
|
1085
|
+
)
|
|
1086
|
+
}
|
|
1087
|
+
updateWalkthroughUi()
|
|
659
1088
|
}
|