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