@typeonce/effect-machine-devtools 0.24.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +4 -0
- package/README.md +13 -13
- package/dist/DevToolsProtocol.d.ts +66 -630
- package/dist/DevToolsProtocol.d.ts.map +1 -1
- package/dist/DevToolsProtocol.js +0 -190
- package/dist/DevToolsProtocol.js.map +1 -1
- package/dist/MachineDocument.d.ts +38 -18
- package/dist/MachineDocument.d.ts.map +1 -1
- package/dist/MachineDocument.js +14 -12
- package/dist/MachineDocument.js.map +1 -1
- package/dist/MachineRegistry.d.ts +22 -2
- package/dist/MachineRegistry.d.ts.map +1 -1
- package/dist/MachineWalkthrough.d.ts +171 -0
- package/dist/MachineWalkthrough.d.ts.map +1 -0
- package/dist/MachineWalkthrough.js +98 -0
- package/dist/MachineWalkthrough.js.map +1 -0
- package/dist/ProjectInspector.d.ts +0 -2
- package/dist/ProjectInspector.d.ts.map +1 -1
- package/dist/ProjectInspector.js.map +1 -1
- package/dist/client/assets/index-BsorPSDS.css +1 -0
- package/dist/client/assets/index-eiptehRd.js +37 -0
- package/dist/client/index.html +4 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/devServer.d.ts.map +1 -1
- package/dist/internal/devServer.js +4 -64
- package/dist/internal/devServer.js.map +1 -1
- package/dist/internal/evaluationWorker.d.ts.map +1 -1
- package/dist/internal/evaluationWorker.js +7 -263
- package/dist/internal/evaluationWorker.js.map +1 -1
- package/dist/internal/machineDocument.d.ts.map +1 -1
- package/dist/internal/machineDocument.js +3 -1
- package/dist/internal/machineDocument.js.map +1 -1
- package/dist/internal/machineWalkthrough.d.ts +26 -0
- package/dist/internal/machineWalkthrough.d.ts.map +1 -0
- package/dist/internal/machineWalkthrough.js +256 -0
- package/dist/internal/machineWalkthrough.js.map +1 -0
- package/dist/internal/projectInspector.d.ts.map +1 -1
- package/dist/internal/projectInspector.js +1 -11
- package/dist/internal/projectInspector.js.map +1 -1
- package/index.html +2 -2
- package/package.json +8 -7
- package/src/DevToolsProtocol.ts +0 -285
- package/src/MachineDocument.ts +21 -19
- package/src/MachineWalkthrough.ts +199 -0
- package/src/ProjectInspector.ts +0 -5
- package/src/index.ts +2 -2
- package/src/internal/browser/chart-layout-policy.ts +206 -0
- package/src/internal/browser/chart-layout.ts +686 -0
- package/src/internal/browser/chart-model.ts +244 -0
- package/src/internal/browser/chart-renderer.ts +774 -0
- package/src/internal/browser/example-machine.ts +15 -6
- package/src/internal/browser/input-form.ts +1 -418
- package/src/internal/browser/invoke-outcomes-example.ts +231 -0
- package/src/internal/browser/parallel-completion-example.ts +199 -0
- package/src/internal/browser/planner-example.ts +2 -0
- package/src/internal/browser/protocol-events-example.ts +200 -0
- package/src/internal/browser/styles.css +974 -342
- package/src/internal/browser/transition-semantics-example.ts +245 -0
- package/src/internal/browser/visualizer-analysis.ts +52 -0
- package/src/internal/browser/visualizer-app.ts +874 -706
- package/src/internal/browser/visualizer-model.ts +104 -0
- package/src/internal/devServer.ts +4 -82
- package/src/internal/evaluationWorker.ts +7 -371
- package/src/internal/machineDocument.ts +3 -1
- package/src/internal/machineWalkthrough.ts +355 -0
- package/src/internal/projectInspector.ts +3 -31
- package/dist/MachineSimulator.d.ts +0 -169
- package/dist/MachineSimulator.d.ts.map +0 -1
- package/dist/MachineSimulator.js +0 -98
- package/dist/MachineSimulator.js.map +0 -1
- package/dist/client/assets/index-B49Tjawc.js +0 -14
- package/dist/client/assets/index-BKH0cOG2.css +0 -1
- package/dist/internal/machineSimulator.d.ts +0 -5
- package/dist/internal/machineSimulator.d.ts.map +0 -1
- package/dist/internal/machineSimulator.js +0 -156
- package/dist/internal/machineSimulator.js.map +0 -1
- package/src/MachineSimulator.ts +0 -154
- package/src/internal/browser/simulation-client.ts +0 -20
- package/src/internal/machineSimulator.ts +0 -199
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect"
|
|
2
|
+
import type { MachineDocument as VisualizationDocument } from "../../MachineDocument.js"
|
|
3
|
+
import {
|
|
4
|
+
type ChartLayoutError,
|
|
5
|
+
type ChartPoint,
|
|
6
|
+
type LaidOutChart,
|
|
7
|
+
type LaidOutChartNode,
|
|
8
|
+
layoutChart,
|
|
9
|
+
maxVisibleActivities,
|
|
10
|
+
maxVisibleFields
|
|
11
|
+
} from "./chart-layout.js"
|
|
12
|
+
import { makeChartModel } from "./chart-model.js"
|
|
13
|
+
|
|
14
|
+
export interface ChartHandlers {
|
|
15
|
+
readonly selectState: (path: string, anchor: ChartInteractionAnchor) => void
|
|
16
|
+
readonly openStateDetails: (path: string) => void
|
|
17
|
+
readonly selectTransition: (
|
|
18
|
+
transitionId: string,
|
|
19
|
+
branchIds: ReadonlyArray<string>,
|
|
20
|
+
anchor: ChartInteractionAnchor
|
|
21
|
+
) => void
|
|
22
|
+
readonly openTransitionDetails: (transitionId: string) => void
|
|
23
|
+
readonly clearSelection: () => void
|
|
24
|
+
readonly zoomChanged: (zoom: number) => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ChartPresentation {
|
|
28
|
+
readonly simulationMode: boolean
|
|
29
|
+
readonly activePaths: ReadonlyArray<string>
|
|
30
|
+
readonly selectedState: string | null
|
|
31
|
+
readonly selectedTransition: string | null
|
|
32
|
+
readonly fromPaths: ReadonlyArray<string>
|
|
33
|
+
readonly toPaths: ReadonlyArray<string>
|
|
34
|
+
readonly incomingTransitionIds: ReadonlyArray<string>
|
|
35
|
+
readonly outgoingTransitionIds: ReadonlyArray<string>
|
|
36
|
+
readonly availableBranchIds: ReadonlyArray<string>
|
|
37
|
+
readonly unavailableBranchIds: ReadonlyArray<string>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ChartView {
|
|
41
|
+
readonly update: (presentation: ChartPresentation) => void
|
|
42
|
+
readonly focusState: (path: string) => void
|
|
43
|
+
readonly revealState: (path: string) => void
|
|
44
|
+
readonly revealStates: (paths: ReadonlyArray<string>) => void
|
|
45
|
+
readonly getZoom: () => number
|
|
46
|
+
readonly setZoom: (zoom: number, anchor?: ChartZoomAnchor) => number
|
|
47
|
+
readonly fit: () => number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ChartZoomAnchor {
|
|
51
|
+
readonly x: number
|
|
52
|
+
readonly y: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ChartInteractionAnchor {
|
|
56
|
+
readonly x: number
|
|
57
|
+
readonly y: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const minimumChartZoom = 0.2
|
|
61
|
+
export const maximumChartZoom = 1.6
|
|
62
|
+
export const chartPanThreshold = 5
|
|
63
|
+
|
|
64
|
+
const clampZoom = (zoom: number): number => Math.min(maximumChartZoom, Math.max(minimumChartZoom, zoom))
|
|
65
|
+
|
|
66
|
+
export const chartZoomScrollPosition = (
|
|
67
|
+
currentZoom: number,
|
|
68
|
+
nextZoom: number,
|
|
69
|
+
scrollLeft: number,
|
|
70
|
+
scrollTop: number,
|
|
71
|
+
anchor: ChartZoomAnchor
|
|
72
|
+
): ChartZoomAnchor => ({
|
|
73
|
+
x: Math.max(0, (scrollLeft + anchor.x) / currentZoom * nextZoom - anchor.x),
|
|
74
|
+
y: Math.max(0, (scrollTop + anchor.y) / currentZoom * nextZoom - anchor.y)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
export const chartWheelZoom = (
|
|
78
|
+
currentZoom: number,
|
|
79
|
+
deltaY: number,
|
|
80
|
+
deltaMode: number,
|
|
81
|
+
viewportHeight: number
|
|
82
|
+
): number => {
|
|
83
|
+
const normalizedDelta = deltaY * (deltaMode === 1 ? 16 : deltaMode === 2 ? viewportHeight : 1)
|
|
84
|
+
return clampZoom(currentZoom * Math.exp(-normalizedDelta * 0.0025))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const isChartPan = (
|
|
88
|
+
start: ChartZoomAnchor,
|
|
89
|
+
current: ChartZoomAnchor,
|
|
90
|
+
threshold = chartPanThreshold
|
|
91
|
+
): boolean => Math.hypot(current.x - start.x, current.y - start.y) >= threshold
|
|
92
|
+
|
|
93
|
+
const element = <Tag extends keyof HTMLElementTagNameMap>(
|
|
94
|
+
tag: Tag,
|
|
95
|
+
className?: string,
|
|
96
|
+
text?: string
|
|
97
|
+
): HTMLElementTagNameMap[Tag] => {
|
|
98
|
+
const node = document.createElement(tag)
|
|
99
|
+
if (className !== undefined) node.className = className
|
|
100
|
+
if (text !== undefined) node.textContent = text
|
|
101
|
+
return node
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const svgElement = <Tag extends keyof SVGElementTagNameMap>(
|
|
105
|
+
tag: Tag,
|
|
106
|
+
className?: string
|
|
107
|
+
): SVGElementTagNameMap[Tag] => {
|
|
108
|
+
const node = document.createElementNS("http://www.w3.org/2000/svg", tag)
|
|
109
|
+
if (className !== undefined) node.setAttribute("class", className)
|
|
110
|
+
return node
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const position = (
|
|
114
|
+
target: HTMLElement,
|
|
115
|
+
bounds: { readonly x: number; readonly y: number; readonly width: number; readonly height: number }
|
|
116
|
+
): void => {
|
|
117
|
+
target.style.left = `${bounds.x}px`
|
|
118
|
+
target.style.top = `${bounds.y}px`
|
|
119
|
+
target.style.width = `${bounds.width}px`
|
|
120
|
+
target.style.height = `${bounds.height}px`
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const statusLabel = (active: boolean, initial: boolean): string => {
|
|
124
|
+
if (active && initial) return "active, initial state"
|
|
125
|
+
if (active) return "active"
|
|
126
|
+
if (initial) return "initial state"
|
|
127
|
+
return "inactive"
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const status = (active: boolean, initial: boolean): HTMLSpanElement => {
|
|
131
|
+
const dot = element(
|
|
132
|
+
"span",
|
|
133
|
+
`chart-state-status${active ? " is-active" : ""}${initial ? " is-initial" : ""}`
|
|
134
|
+
)
|
|
135
|
+
dot.dataset.initial = String(initial)
|
|
136
|
+
dot.setAttribute("aria-label", statusLabel(active, initial))
|
|
137
|
+
return dot
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const more = (count: number): HTMLElement => element("div", "chart-more", `+${count} more`)
|
|
141
|
+
|
|
142
|
+
const stateContent = (layout: LaidOutChartNode, stateStatus: HTMLElement): DocumentFragment => {
|
|
143
|
+
const fragment = document.createDocumentFragment()
|
|
144
|
+
const heading = element("div", "chart-state-heading")
|
|
145
|
+
const identity = element("div", "chart-state-identity")
|
|
146
|
+
identity.append(stateStatus, element("strong", "chart-state-name", layout.node.label))
|
|
147
|
+
heading.append(identity)
|
|
148
|
+
fragment.append(heading)
|
|
149
|
+
|
|
150
|
+
if (layout.node.fields.length > 0) {
|
|
151
|
+
const fields = element("div", "chart-state-section")
|
|
152
|
+
fields.append(element("div", "chart-section-label", "value"))
|
|
153
|
+
layout.node.fields.slice(0, maxVisibleFields).forEach((field) => {
|
|
154
|
+
const row = element("div", "chart-field-row")
|
|
155
|
+
row.append(
|
|
156
|
+
element("span", "chart-field-name", `${field.label}${field.required ? "" : "?"}`),
|
|
157
|
+
element("span", "chart-field-type", field.type)
|
|
158
|
+
)
|
|
159
|
+
fields.append(row)
|
|
160
|
+
})
|
|
161
|
+
if (layout.node.fields.length > maxVisibleFields) {
|
|
162
|
+
fields.append(more(layout.node.fields.length - maxVisibleFields))
|
|
163
|
+
}
|
|
164
|
+
fragment.append(fields)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (layout.node.activities.length > 0) {
|
|
168
|
+
const activities = element("div", "chart-state-section")
|
|
169
|
+
activities.append(element("div", "chart-section-label", "invokes"))
|
|
170
|
+
layout.node.activities.slice(0, maxVisibleActivities).forEach((activity) => {
|
|
171
|
+
const row = element("div", `chart-activity-row chart-activity-${activity.kind}`)
|
|
172
|
+
row.append(
|
|
173
|
+
element("span", "chart-activity-kind", activity.kind),
|
|
174
|
+
element("span", "chart-activity-name", activity.label)
|
|
175
|
+
)
|
|
176
|
+
activities.append(row)
|
|
177
|
+
})
|
|
178
|
+
if (layout.node.activities.length > maxVisibleActivities) {
|
|
179
|
+
activities.append(more(layout.node.activities.length - maxVisibleActivities))
|
|
180
|
+
}
|
|
181
|
+
fragment.append(activities)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return fragment
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const pointAlong = (start: ChartPoint, end: ChartPoint, distance: number): ChartPoint => {
|
|
188
|
+
const length = Math.abs(end.x - start.x) + Math.abs(end.y - start.y)
|
|
189
|
+
if (length === 0) return start
|
|
190
|
+
const ratio = distance / length
|
|
191
|
+
return {
|
|
192
|
+
x: start.x + (end.x - start.x) * ratio,
|
|
193
|
+
y: start.y + (end.y - start.y) * ratio
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export const chartEdgePathData = (points: ReadonlyArray<ChartPoint>, radius = 9): string => {
|
|
198
|
+
const first = points[0]
|
|
199
|
+
if (first === undefined) return ""
|
|
200
|
+
if (points.length === 1) return `M ${first.x} ${first.y}`
|
|
201
|
+
let path = `M ${first.x} ${first.y}`
|
|
202
|
+
for (let index = 1; index < points.length - 1; index++) {
|
|
203
|
+
const previous = points[index - 1]!
|
|
204
|
+
const corner = points[index]!
|
|
205
|
+
const next = points[index + 1]!
|
|
206
|
+
const incoming = Math.abs(corner.x - previous.x) + Math.abs(corner.y - previous.y)
|
|
207
|
+
const outgoing = Math.abs(next.x - corner.x) + Math.abs(next.y - corner.y)
|
|
208
|
+
const cornerRadius = Math.min(radius, incoming / 2, outgoing / 2)
|
|
209
|
+
const before = pointAlong(corner, previous, cornerRadius)
|
|
210
|
+
const after = pointAlong(corner, next, cornerRadius)
|
|
211
|
+
path += ` L ${before.x} ${before.y} Q ${corner.x} ${corner.y} ${after.x} ${after.y}`
|
|
212
|
+
}
|
|
213
|
+
const last = points.at(-1)!
|
|
214
|
+
return `${path} L ${last.x} ${last.y}`
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export interface ChartDirectionCue {
|
|
218
|
+
readonly start: ChartPoint
|
|
219
|
+
readonly end: ChartPoint
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export const chartDirectionCue = (
|
|
223
|
+
points: ReadonlyArray<ChartPoint>,
|
|
224
|
+
minimumRouteLength = 280
|
|
225
|
+
): ChartDirectionCue | null => {
|
|
226
|
+
const segments = points.slice(1).map((end, index) => {
|
|
227
|
+
const start = points[index]!
|
|
228
|
+
return {
|
|
229
|
+
start,
|
|
230
|
+
end,
|
|
231
|
+
length: Math.abs(end.x - start.x) + Math.abs(end.y - start.y)
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
const total = segments.reduce((sum, segment) => sum + segment.length, 0)
|
|
235
|
+
if (total < minimumRouteLength) return null
|
|
236
|
+
let remaining = total * 0.32
|
|
237
|
+
let selected: { readonly start: ChartPoint; readonly end: ChartPoint; readonly distance: number } | undefined
|
|
238
|
+
for (const segment of segments) {
|
|
239
|
+
if (segment.length >= 24 && remaining <= segment.length) {
|
|
240
|
+
selected = {
|
|
241
|
+
start: segment.start,
|
|
242
|
+
end: segment.end,
|
|
243
|
+
distance: Math.min(segment.length - 12, Math.max(12, remaining))
|
|
244
|
+
}
|
|
245
|
+
break
|
|
246
|
+
}
|
|
247
|
+
remaining -= segment.length
|
|
248
|
+
}
|
|
249
|
+
if (selected === undefined) {
|
|
250
|
+
const segment = [...segments].sort((left, right) => right.length - left.length)[0]
|
|
251
|
+
if (segment === undefined || segment.length < 24) return null
|
|
252
|
+
selected = { start: segment.start, end: segment.end, distance: segment.length / 2 }
|
|
253
|
+
}
|
|
254
|
+
const center = pointAlong(selected.start, selected.end, selected.distance)
|
|
255
|
+
const deltaX = selected.end.x - selected.start.x
|
|
256
|
+
const deltaY = selected.end.y - selected.start.y
|
|
257
|
+
const horizontal = Math.abs(deltaX) >= Math.abs(deltaY)
|
|
258
|
+
const directionX = horizontal ? Math.sign(deltaX) : 0
|
|
259
|
+
const directionY = horizontal ? 0 : Math.sign(deltaY)
|
|
260
|
+
return {
|
|
261
|
+
start: { x: center.x - directionX * 5, y: center.y - directionY * 5 },
|
|
262
|
+
end: { x: center.x + directionX * 5, y: center.y + directionY * 5 }
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const setStateClass = (
|
|
267
|
+
elements: ReadonlyMap<string, ReadonlyArray<HTMLElement>>,
|
|
268
|
+
paths: ReadonlyArray<string>,
|
|
269
|
+
className: string
|
|
270
|
+
): void => {
|
|
271
|
+
for (const path of paths) elements.get(path)?.forEach((node) => node.classList.add(className))
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const render = (
|
|
275
|
+
host: HTMLElement,
|
|
276
|
+
layout: LaidOutChart,
|
|
277
|
+
handlers: ChartHandlers
|
|
278
|
+
): ChartView => {
|
|
279
|
+
const viewport = element("div", "chart-viewport")
|
|
280
|
+
const stage = element("div", "chart-stage")
|
|
281
|
+
const canvas = element("div", "chart-canvas")
|
|
282
|
+
stage.style.width = `${layout.width}px`
|
|
283
|
+
stage.style.height = `${layout.height}px`
|
|
284
|
+
canvas.style.width = `${layout.width}px`
|
|
285
|
+
canvas.style.height = `${layout.height}px`
|
|
286
|
+
const regions = element("div", "chart-regions")
|
|
287
|
+
const svg = svgElement("svg", "chart-edges")
|
|
288
|
+
svg.setAttribute("width", String(layout.width))
|
|
289
|
+
svg.setAttribute("height", String(layout.height))
|
|
290
|
+
svg.setAttribute("viewBox", `0 0 ${layout.width} ${layout.height}`)
|
|
291
|
+
svg.setAttribute("aria-hidden", "true")
|
|
292
|
+
const definitions = svgElement("defs")
|
|
293
|
+
const marker = svgElement("marker")
|
|
294
|
+
marker.id = "chart-arrow"
|
|
295
|
+
marker.setAttribute("viewBox", "0 0 12 12")
|
|
296
|
+
marker.setAttribute("refX", "11")
|
|
297
|
+
marker.setAttribute("refY", "6")
|
|
298
|
+
marker.setAttribute("markerWidth", "12")
|
|
299
|
+
marker.setAttribute("markerHeight", "12")
|
|
300
|
+
marker.setAttribute("markerUnits", "userSpaceOnUse")
|
|
301
|
+
marker.setAttribute("orient", "auto-start-reverse")
|
|
302
|
+
const arrow = svgElement("path")
|
|
303
|
+
arrow.setAttribute("d", "M 1 1 L 11 6 L 1 11 z")
|
|
304
|
+
marker.append(arrow)
|
|
305
|
+
const directionMarker = svgElement("marker")
|
|
306
|
+
directionMarker.id = "chart-direction"
|
|
307
|
+
directionMarker.setAttribute("viewBox", "0 0 8 8")
|
|
308
|
+
directionMarker.setAttribute("refX", "7")
|
|
309
|
+
directionMarker.setAttribute("refY", "4")
|
|
310
|
+
directionMarker.setAttribute("markerWidth", "8")
|
|
311
|
+
directionMarker.setAttribute("markerHeight", "8")
|
|
312
|
+
directionMarker.setAttribute("markerUnits", "userSpaceOnUse")
|
|
313
|
+
directionMarker.setAttribute("orient", "auto")
|
|
314
|
+
const directionArrow = svgElement("path")
|
|
315
|
+
directionArrow.setAttribute("d", "M 1 1 L 7 4 L 1 7 z")
|
|
316
|
+
directionMarker.append(directionArrow)
|
|
317
|
+
definitions.append(marker, directionMarker)
|
|
318
|
+
svg.append(definitions)
|
|
319
|
+
const nodesLayer = element("div", "chart-nodes")
|
|
320
|
+
const labelsLayer = element("div", "chart-labels")
|
|
321
|
+
canvas.append(regions, svg, nodesLayer, labelsLayer)
|
|
322
|
+
stage.append(canvas)
|
|
323
|
+
viewport.append(stage)
|
|
324
|
+
host.replaceChildren(viewport)
|
|
325
|
+
|
|
326
|
+
let zoom = 1
|
|
327
|
+
let pan: {
|
|
328
|
+
readonly pointerId: number
|
|
329
|
+
readonly start: ChartZoomAnchor
|
|
330
|
+
readonly scrollLeft: number
|
|
331
|
+
readonly scrollTop: number
|
|
332
|
+
dragging: boolean
|
|
333
|
+
} | undefined
|
|
334
|
+
let suppressClick = false
|
|
335
|
+
let suppressDoubleClickUntil = 0
|
|
336
|
+
|
|
337
|
+
const stateElements = new Map<string, Array<HTMLElement>>()
|
|
338
|
+
const stateControls = new Map<string, HTMLButtonElement>()
|
|
339
|
+
const stateStatuses = new Map<string, HTMLElement>()
|
|
340
|
+
const transitionElements = new Map<string, Array<Element>>()
|
|
341
|
+
const edgeElements = new Map<string, Array<Element>>()
|
|
342
|
+
const transitionControls = new Map<string, Array<HTMLButtonElement>>()
|
|
343
|
+
const chartEdges = new Map(
|
|
344
|
+
layout.edges.flatMap((laidOut) => laidOut.kind === "transition" ? [[laidOut.edge.id, laidOut.edge] as const] : [])
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
const registerStateElement = (path: string, node: HTMLElement): void => {
|
|
348
|
+
const registered = stateElements.get(path) ?? []
|
|
349
|
+
registered.push(node)
|
|
350
|
+
stateElements.set(path, registered)
|
|
351
|
+
}
|
|
352
|
+
const registerTransitionElement = (id: string, node: Element): void => {
|
|
353
|
+
const registered = transitionElements.get(id) ?? []
|
|
354
|
+
registered.push(node)
|
|
355
|
+
transitionElements.set(id, registered)
|
|
356
|
+
}
|
|
357
|
+
const registerEdgeElement = (id: string, node: Element): void => {
|
|
358
|
+
const registered = edgeElements.get(id) ?? []
|
|
359
|
+
registered.push(node)
|
|
360
|
+
edgeElements.set(id, registered)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
for (const regionLayout of layout.regions) {
|
|
364
|
+
const region = element("div", "chart-unconnected-region")
|
|
365
|
+
region.title = "No statically known transition reaches these states. Runtime-resolved targets may still reach them."
|
|
366
|
+
position(region, regionLayout)
|
|
367
|
+
const label = element("div", "chart-unconnected-region-label", "No static path from initial")
|
|
368
|
+
region.append(label)
|
|
369
|
+
regions.append(region)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
for (const laidOut of layout.nodes) {
|
|
373
|
+
if (laidOut.node.children.length > 0) {
|
|
374
|
+
const region = element("div", `chart-compound chart-compound-${laidOut.node.type}`)
|
|
375
|
+
region.dataset.statePath = laidOut.node.path
|
|
376
|
+
position(region, laidOut)
|
|
377
|
+
regions.append(region)
|
|
378
|
+
registerStateElement(laidOut.node.path, region)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const card = element("button", `chart-state chart-state-${laidOut.node.type}`)
|
|
382
|
+
card.type = "button"
|
|
383
|
+
card.dataset.statePath = laidOut.node.path
|
|
384
|
+
card.setAttribute("aria-label", `${laidOut.node.label}, ${laidOut.node.type} state`)
|
|
385
|
+
position(card, {
|
|
386
|
+
x: laidOut.x,
|
|
387
|
+
y: laidOut.y,
|
|
388
|
+
width: laidOut.width,
|
|
389
|
+
height: laidOut.node.children.length > 0 ? laidOut.headerHeight : laidOut.height
|
|
390
|
+
})
|
|
391
|
+
const stateStatus = status(laidOut.node.active, laidOut.node.initial)
|
|
392
|
+
card.append(stateContent(laidOut, stateStatus))
|
|
393
|
+
card.addEventListener(
|
|
394
|
+
"click",
|
|
395
|
+
(event) => handlers.selectState(laidOut.node.path, { x: event.clientX, y: event.clientY })
|
|
396
|
+
)
|
|
397
|
+
card.addEventListener("dblclick", () => handlers.openStateDetails(laidOut.node.path))
|
|
398
|
+
nodesLayer.append(card)
|
|
399
|
+
registerStateElement(laidOut.node.path, card)
|
|
400
|
+
stateControls.set(laidOut.node.path, card)
|
|
401
|
+
stateStatuses.set(laidOut.node.path, stateStatus)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
for (const initial of layout.initials) {
|
|
405
|
+
const dot = element("span", "chart-initial")
|
|
406
|
+
dot.setAttribute("aria-hidden", "true")
|
|
407
|
+
position(dot, initial)
|
|
408
|
+
nodesLayer.append(dot)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
for (const runtime of layout.runtimeTargets) {
|
|
412
|
+
const target = element("span", "chart-runtime-target", runtime.target.label)
|
|
413
|
+
target.setAttribute("aria-hidden", "true")
|
|
414
|
+
position(target, runtime)
|
|
415
|
+
nodesLayer.append(target)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const hoverTransition = (
|
|
419
|
+
transitionId: string,
|
|
420
|
+
source: string,
|
|
421
|
+
target: string | null,
|
|
422
|
+
hovered: boolean
|
|
423
|
+
): void => {
|
|
424
|
+
transitionElements.get(transitionId)?.forEach((node) => node.classList.toggle("is-hovered", hovered))
|
|
425
|
+
stateElements.get(source)?.forEach((node) => node.classList.toggle("is-hover-source", hovered))
|
|
426
|
+
if (target !== null) {
|
|
427
|
+
stateElements.get(target)?.forEach((node) => node.classList.toggle("is-hover-target", hovered))
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
for (const laidOut of layout.edges) {
|
|
432
|
+
const group = svgElement(
|
|
433
|
+
"g",
|
|
434
|
+
`chart-edge-group chart-edge-${laidOut.kind}${
|
|
435
|
+
laidOut.kind === "transition"
|
|
436
|
+
? ` chart-transition-${laidOut.edge.kind} chart-edge-trigger-${laidOut.edge.trigger.type}${
|
|
437
|
+
laidOut.edge.activityKind === null ? "" : ` chart-edge-activity-${laidOut.edge.activityKind}`
|
|
438
|
+
}`
|
|
439
|
+
: ""
|
|
440
|
+
}`
|
|
441
|
+
)
|
|
442
|
+
const casing = svgElement("path", "chart-edge-casing")
|
|
443
|
+
const visible = svgElement("path", "chart-edge-line")
|
|
444
|
+
const route = chartEdgePathData(laidOut.points)
|
|
445
|
+
casing.setAttribute("d", route)
|
|
446
|
+
visible.setAttribute("d", route)
|
|
447
|
+
visible.setAttribute("marker-end", "url(#chart-arrow)")
|
|
448
|
+
const hit = svgElement("path", "chart-edge-hit")
|
|
449
|
+
hit.setAttribute("d", route)
|
|
450
|
+
group.append(casing, visible)
|
|
451
|
+
if (laidOut.kind === "transition") {
|
|
452
|
+
const cue = chartDirectionCue(laidOut.points)
|
|
453
|
+
if (cue !== null) {
|
|
454
|
+
const direction = svgElement("path", "chart-edge-direction")
|
|
455
|
+
direction.setAttribute("d", `M ${cue.start.x} ${cue.start.y} L ${cue.end.x} ${cue.end.y}`)
|
|
456
|
+
direction.setAttribute("marker-end", "url(#chart-direction)")
|
|
457
|
+
group.append(direction)
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
group.append(hit)
|
|
461
|
+
svg.append(group)
|
|
462
|
+
if (laidOut.kind === "initial") continue
|
|
463
|
+
|
|
464
|
+
registerTransitionElement(laidOut.edge.transitionId, group)
|
|
465
|
+
registerEdgeElement(laidOut.edge.id, group)
|
|
466
|
+
const label = element(
|
|
467
|
+
"button",
|
|
468
|
+
`chart-edge-label chart-edge-label-${laidOut.edge.trigger.type}${
|
|
469
|
+
laidOut.edge.activityKind === null ? "" : ` chart-edge-activity-${laidOut.edge.activityKind}`
|
|
470
|
+
}`,
|
|
471
|
+
laidOut.edge.label
|
|
472
|
+
)
|
|
473
|
+
label.type = "button"
|
|
474
|
+
label.dataset.transitionId = laidOut.edge.transitionId
|
|
475
|
+
position(label, {
|
|
476
|
+
x: laidOut.label.x - laidOut.labelWidth / 2,
|
|
477
|
+
y: laidOut.label.y - laidOut.labelHeight / 2,
|
|
478
|
+
width: laidOut.labelWidth,
|
|
479
|
+
height: laidOut.labelHeight
|
|
480
|
+
})
|
|
481
|
+
label.addEventListener(
|
|
482
|
+
"click",
|
|
483
|
+
(event) =>
|
|
484
|
+
handlers.selectTransition(
|
|
485
|
+
laidOut.edge.transitionId,
|
|
486
|
+
laidOut.edge.branchIds,
|
|
487
|
+
{ x: event.clientX, y: event.clientY }
|
|
488
|
+
)
|
|
489
|
+
)
|
|
490
|
+
label.addEventListener("dblclick", () => handlers.openTransitionDetails(laidOut.edge.transitionId))
|
|
491
|
+
label.addEventListener(
|
|
492
|
+
"mouseenter",
|
|
493
|
+
() => hoverTransition(laidOut.edge.transitionId, laidOut.edge.source, laidOut.edge.target, true)
|
|
494
|
+
)
|
|
495
|
+
label.addEventListener(
|
|
496
|
+
"mouseleave",
|
|
497
|
+
() => hoverTransition(laidOut.edge.transitionId, laidOut.edge.source, laidOut.edge.target, false)
|
|
498
|
+
)
|
|
499
|
+
hit.addEventListener(
|
|
500
|
+
"click",
|
|
501
|
+
(event) =>
|
|
502
|
+
handlers.selectTransition(
|
|
503
|
+
laidOut.edge.transitionId,
|
|
504
|
+
laidOut.edge.branchIds,
|
|
505
|
+
{ x: event.clientX, y: event.clientY }
|
|
506
|
+
)
|
|
507
|
+
)
|
|
508
|
+
hit.addEventListener("dblclick", () => handlers.openTransitionDetails(laidOut.edge.transitionId))
|
|
509
|
+
hit.addEventListener(
|
|
510
|
+
"mouseenter",
|
|
511
|
+
() => hoverTransition(laidOut.edge.transitionId, laidOut.edge.source, laidOut.edge.target, true)
|
|
512
|
+
)
|
|
513
|
+
hit.addEventListener(
|
|
514
|
+
"mouseleave",
|
|
515
|
+
() => hoverTransition(laidOut.edge.transitionId, laidOut.edge.source, laidOut.edge.target, false)
|
|
516
|
+
)
|
|
517
|
+
labelsLayer.append(label)
|
|
518
|
+
registerTransitionElement(laidOut.edge.transitionId, label)
|
|
519
|
+
registerEdgeElement(laidOut.edge.id, label)
|
|
520
|
+
const controls = transitionControls.get(laidOut.edge.id) ?? []
|
|
521
|
+
controls.push(label)
|
|
522
|
+
transitionControls.set(laidOut.edge.id, controls)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
viewport.addEventListener("keydown", (event) => {
|
|
526
|
+
if (event.key !== "Escape") return
|
|
527
|
+
event.preventDefault()
|
|
528
|
+
handlers.clearSelection()
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
viewport.addEventListener("pointerdown", (event) => {
|
|
532
|
+
if (!event.isPrimary || event.button !== 0 || pan !== undefined) return
|
|
533
|
+
pan = {
|
|
534
|
+
pointerId: event.pointerId,
|
|
535
|
+
start: { x: event.clientX, y: event.clientY },
|
|
536
|
+
scrollLeft: viewport.scrollLeft,
|
|
537
|
+
scrollTop: viewport.scrollTop,
|
|
538
|
+
dragging: false
|
|
539
|
+
}
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
viewport.addEventListener("pointermove", (event) => {
|
|
543
|
+
if (pan === undefined || event.pointerId !== pan.pointerId) return
|
|
544
|
+
const current = { x: event.clientX, y: event.clientY }
|
|
545
|
+
if (!pan.dragging) {
|
|
546
|
+
if (!isChartPan(pan.start, current)) return
|
|
547
|
+
pan.dragging = true
|
|
548
|
+
viewport.setPointerCapture(event.pointerId)
|
|
549
|
+
viewport.classList.add("is-panning")
|
|
550
|
+
const focused = document.activeElement
|
|
551
|
+
if (focused instanceof HTMLElement && viewport.contains(focused)) focused.blur()
|
|
552
|
+
}
|
|
553
|
+
event.preventDefault()
|
|
554
|
+
viewport.scrollTo({
|
|
555
|
+
left: pan.scrollLeft - (current.x - pan.start.x),
|
|
556
|
+
top: pan.scrollTop - (current.y - pan.start.y)
|
|
557
|
+
})
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
const finishPan = (event: PointerEvent, cancelled: boolean): void => {
|
|
561
|
+
if (pan === undefined || event.pointerId !== pan.pointerId) return
|
|
562
|
+
const dragged = pan.dragging
|
|
563
|
+
if (viewport.hasPointerCapture(event.pointerId)) viewport.releasePointerCapture(event.pointerId)
|
|
564
|
+
viewport.classList.remove("is-panning")
|
|
565
|
+
pan = undefined
|
|
566
|
+
if (!dragged || cancelled) return
|
|
567
|
+
event.preventDefault()
|
|
568
|
+
suppressClick = true
|
|
569
|
+
suppressDoubleClickUntil = performance.now() + 400
|
|
570
|
+
setTimeout(() => {
|
|
571
|
+
suppressClick = false
|
|
572
|
+
}, 0)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
viewport.addEventListener("pointerup", (event) => finishPan(event, false))
|
|
576
|
+
viewport.addEventListener("pointercancel", (event) => finishPan(event, true))
|
|
577
|
+
viewport.addEventListener("click", (event) => {
|
|
578
|
+
if (!suppressClick) return
|
|
579
|
+
suppressClick = false
|
|
580
|
+
event.preventDefault()
|
|
581
|
+
event.stopImmediatePropagation()
|
|
582
|
+
}, true)
|
|
583
|
+
viewport.addEventListener("dblclick", (event) => {
|
|
584
|
+
if (performance.now() > suppressDoubleClickUntil) return
|
|
585
|
+
event.preventDefault()
|
|
586
|
+
event.stopImmediatePropagation()
|
|
587
|
+
}, true)
|
|
588
|
+
|
|
589
|
+
const revealState = (path: string): void => {
|
|
590
|
+
const control = stateControls.get(path)
|
|
591
|
+
if (control === undefined) return
|
|
592
|
+
const left = Number.parseFloat(control.style.left) * zoom
|
|
593
|
+
const top = Number.parseFloat(control.style.top) * zoom
|
|
594
|
+
const width = control.offsetWidth * zoom
|
|
595
|
+
const height = control.offsetHeight * zoom
|
|
596
|
+
viewport.scrollTo({
|
|
597
|
+
left: Math.max(0, left - Math.max(28, (viewport.clientWidth - width) / 2)),
|
|
598
|
+
top: Math.max(0, top - Math.max(28, (viewport.clientHeight - height) / 2))
|
|
599
|
+
})
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const revealStates = (paths: ReadonlyArray<string>): void => {
|
|
603
|
+
const leaves = paths.filter((path) => !paths.some((candidate) => candidate.startsWith(`${path}.`)))
|
|
604
|
+
const bounds = leaves
|
|
605
|
+
.map((path) => layout.nodes.find(({ node }) => node.path === path))
|
|
606
|
+
.filter((node): node is LaidOutChartNode => node !== undefined)
|
|
607
|
+
if (bounds.length === 0) return
|
|
608
|
+
const left = Math.min(...bounds.map(({ x }) => x)) * zoom
|
|
609
|
+
const top = Math.min(...bounds.map(({ y }) => y)) * zoom
|
|
610
|
+
const right = Math.max(...bounds.map(({ x, width }) => x + width)) * zoom
|
|
611
|
+
const bottom = Math.max(...bounds.map(({ y, height }) => y + height)) * zoom
|
|
612
|
+
const margin = 32
|
|
613
|
+
const visible = left >= viewport.scrollLeft + margin &&
|
|
614
|
+
top >= viewport.scrollTop + margin &&
|
|
615
|
+
right <= viewport.scrollLeft + viewport.clientWidth - margin &&
|
|
616
|
+
bottom <= viewport.scrollTop + viewport.clientHeight - margin
|
|
617
|
+
if (visible) return
|
|
618
|
+
viewport.scrollTo({
|
|
619
|
+
left: Math.max(0, (left + right - viewport.clientWidth) / 2),
|
|
620
|
+
top: Math.max(0, (top + bottom - viewport.clientHeight) / 2)
|
|
621
|
+
})
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const setZoom = (requested: number, anchor?: ChartZoomAnchor): number => {
|
|
625
|
+
const next = clampZoom(requested)
|
|
626
|
+
if (next === zoom) return zoom
|
|
627
|
+
const zoomAnchor = anchor ?? { x: viewport.clientWidth / 2, y: viewport.clientHeight / 2 }
|
|
628
|
+
const scroll = chartZoomScrollPosition(
|
|
629
|
+
zoom,
|
|
630
|
+
next,
|
|
631
|
+
viewport.scrollLeft,
|
|
632
|
+
viewport.scrollTop,
|
|
633
|
+
zoomAnchor
|
|
634
|
+
)
|
|
635
|
+
zoom = next
|
|
636
|
+
canvas.style.transform = `scale(${zoom})`
|
|
637
|
+
stage.style.width = `${layout.width * zoom}px`
|
|
638
|
+
stage.style.height = `${layout.height * zoom}px`
|
|
639
|
+
viewport.scrollTo({
|
|
640
|
+
left: scroll.x,
|
|
641
|
+
top: scroll.y
|
|
642
|
+
})
|
|
643
|
+
handlers.zoomChanged(zoom)
|
|
644
|
+
return zoom
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
viewport.addEventListener("wheel", (event) => {
|
|
648
|
+
if (!event.ctrlKey) return
|
|
649
|
+
event.preventDefault()
|
|
650
|
+
const bounds = viewport.getBoundingClientRect()
|
|
651
|
+
setZoom(
|
|
652
|
+
chartWheelZoom(zoom, event.deltaY, event.deltaMode, viewport.clientHeight),
|
|
653
|
+
{ x: event.clientX - bounds.left, y: event.clientY - bounds.top }
|
|
654
|
+
)
|
|
655
|
+
}, { passive: false })
|
|
656
|
+
|
|
657
|
+
const fit = (): number => {
|
|
658
|
+
const availableWidth = Math.max(1, viewport.clientWidth - 48)
|
|
659
|
+
const availableHeight = Math.max(1, viewport.clientHeight - 48)
|
|
660
|
+
const fitted = setZoom(Math.min(1, availableWidth / layout.width, availableHeight / layout.height))
|
|
661
|
+
viewport.scrollTo({ left: 0, top: 0 })
|
|
662
|
+
return fitted
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return {
|
|
666
|
+
update: (presentation) => {
|
|
667
|
+
const active = new Set(presentation.activePaths)
|
|
668
|
+
const availableBranches = new Set(presentation.availableBranchIds)
|
|
669
|
+
const unavailableBranches = new Set(presentation.unavailableBranchIds)
|
|
670
|
+
stateControls.forEach((control) => {
|
|
671
|
+
control.disabled = presentation.simulationMode
|
|
672
|
+
})
|
|
673
|
+
transitionControls.forEach((controls, edgeId) => {
|
|
674
|
+
const edge = chartEdges.get(edgeId)
|
|
675
|
+
const interactive = edge?.branchIds.some((branchId) =>
|
|
676
|
+
availableBranches.has(branchId) || unavailableBranches.has(branchId)
|
|
677
|
+
)
|
|
678
|
+
controls.forEach((control) => {
|
|
679
|
+
control.disabled = presentation.simulationMode && !interactive
|
|
680
|
+
})
|
|
681
|
+
})
|
|
682
|
+
stateElements.forEach((elements) =>
|
|
683
|
+
elements.forEach((node) => {
|
|
684
|
+
node.classList.remove(
|
|
685
|
+
"is-selected",
|
|
686
|
+
"is-related-from",
|
|
687
|
+
"is-related-to"
|
|
688
|
+
)
|
|
689
|
+
})
|
|
690
|
+
)
|
|
691
|
+
transitionElements.forEach((elements) =>
|
|
692
|
+
elements.forEach((node) => {
|
|
693
|
+
node.classList.remove(
|
|
694
|
+
"is-selected",
|
|
695
|
+
"is-incoming",
|
|
696
|
+
"is-outgoing",
|
|
697
|
+
"is-walkthrough-available",
|
|
698
|
+
"is-walkthrough-unavailable"
|
|
699
|
+
)
|
|
700
|
+
})
|
|
701
|
+
)
|
|
702
|
+
stateStatuses.forEach((node, path) => {
|
|
703
|
+
const isActive = active.has(path)
|
|
704
|
+
const isInitial = node.dataset.initial === "true"
|
|
705
|
+
node.classList.toggle("is-active", isActive)
|
|
706
|
+
node.setAttribute("aria-label", statusLabel(isActive, isInitial))
|
|
707
|
+
})
|
|
708
|
+
viewport.classList.toggle("is-simulating", presentation.simulationMode)
|
|
709
|
+
if (presentation.selectedState !== null) {
|
|
710
|
+
stateElements.get(presentation.selectedState)?.forEach((node) => node.classList.add("is-selected"))
|
|
711
|
+
}
|
|
712
|
+
if (presentation.selectedTransition !== null) {
|
|
713
|
+
transitionElements.get(presentation.selectedTransition)?.forEach((node) => node.classList.add("is-selected"))
|
|
714
|
+
}
|
|
715
|
+
setStateClass(stateElements, presentation.fromPaths, "is-related-from")
|
|
716
|
+
setStateClass(stateElements, presentation.toPaths, "is-related-to")
|
|
717
|
+
presentation.incomingTransitionIds.forEach((id) =>
|
|
718
|
+
transitionElements.get(id)?.forEach((node) => node.classList.add("is-incoming"))
|
|
719
|
+
)
|
|
720
|
+
presentation.outgoingTransitionIds.forEach((id) =>
|
|
721
|
+
transitionElements.get(id)?.forEach((node) => node.classList.add("is-outgoing"))
|
|
722
|
+
)
|
|
723
|
+
layout.edges.forEach((laidOut) => {
|
|
724
|
+
if (laidOut.kind !== "transition") return
|
|
725
|
+
const available = laidOut.edge.branchIds.some((branchId) => availableBranches.has(branchId))
|
|
726
|
+
const unavailable = laidOut.edge.branchIds.some((branchId) => unavailableBranches.has(branchId))
|
|
727
|
+
const className = available
|
|
728
|
+
? "is-walkthrough-available"
|
|
729
|
+
: unavailable
|
|
730
|
+
? "is-walkthrough-unavailable"
|
|
731
|
+
: undefined
|
|
732
|
+
if (className !== undefined) {
|
|
733
|
+
edgeElements.get(laidOut.edge.id)?.forEach((node) => node.classList.add(className))
|
|
734
|
+
}
|
|
735
|
+
})
|
|
736
|
+
},
|
|
737
|
+
focusState: (path) => {
|
|
738
|
+
const control = stateControls.get(path)
|
|
739
|
+
control?.focus({ preventScroll: true })
|
|
740
|
+
revealState(path)
|
|
741
|
+
},
|
|
742
|
+
revealState,
|
|
743
|
+
revealStates,
|
|
744
|
+
getZoom: () => zoom,
|
|
745
|
+
setZoom,
|
|
746
|
+
fit
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
export const renderChart = (
|
|
751
|
+
host: HTMLElement,
|
|
752
|
+
document: VisualizationDocument,
|
|
753
|
+
handlers: ChartHandlers
|
|
754
|
+
): Effect.Effect<ChartView, ChartLayoutError> => {
|
|
755
|
+
const statesByPath = new Map(document.states.map((state) => [state.path, state]))
|
|
756
|
+
const visited = new Set<string>()
|
|
757
|
+
let initialFocus = document.initial.target
|
|
758
|
+
while (!visited.has(initialFocus)) {
|
|
759
|
+
visited.add(initialFocus)
|
|
760
|
+
const state = statesByPath.get(initialFocus)
|
|
761
|
+
if (state === undefined) break
|
|
762
|
+
const next = state.type === "parallel" ? state.children[0] : state.initial
|
|
763
|
+
if (next === null || next === undefined) break
|
|
764
|
+
initialFocus = next
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
return layoutChart(makeChartModel(document)).pipe(Effect.map((layout) => {
|
|
768
|
+
const view = render(host, layout, handlers)
|
|
769
|
+
setTimeout(() => {
|
|
770
|
+
if (host.isConnected) view.revealState(initialFocus)
|
|
771
|
+
}, 0)
|
|
772
|
+
return view
|
|
773
|
+
}))
|
|
774
|
+
}
|