@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.
Files changed (82) hide show
  1. package/NOTICE +4 -0
  2. package/README.md +13 -13
  3. package/dist/DevToolsProtocol.d.ts +66 -630
  4. package/dist/DevToolsProtocol.d.ts.map +1 -1
  5. package/dist/DevToolsProtocol.js +0 -190
  6. package/dist/DevToolsProtocol.js.map +1 -1
  7. package/dist/MachineDocument.d.ts +38 -18
  8. package/dist/MachineDocument.d.ts.map +1 -1
  9. package/dist/MachineDocument.js +14 -12
  10. package/dist/MachineDocument.js.map +1 -1
  11. package/dist/MachineRegistry.d.ts +22 -2
  12. package/dist/MachineRegistry.d.ts.map +1 -1
  13. package/dist/MachineWalkthrough.d.ts +171 -0
  14. package/dist/MachineWalkthrough.d.ts.map +1 -0
  15. package/dist/MachineWalkthrough.js +98 -0
  16. package/dist/MachineWalkthrough.js.map +1 -0
  17. package/dist/ProjectInspector.d.ts +0 -2
  18. package/dist/ProjectInspector.d.ts.map +1 -1
  19. package/dist/ProjectInspector.js.map +1 -1
  20. package/dist/client/assets/index-BsorPSDS.css +1 -0
  21. package/dist/client/assets/index-eiptehRd.js +37 -0
  22. package/dist/client/index.html +4 -4
  23. package/dist/index.d.ts +2 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +2 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/internal/devServer.d.ts.map +1 -1
  28. package/dist/internal/devServer.js +4 -64
  29. package/dist/internal/devServer.js.map +1 -1
  30. package/dist/internal/evaluationWorker.d.ts.map +1 -1
  31. package/dist/internal/evaluationWorker.js +7 -263
  32. package/dist/internal/evaluationWorker.js.map +1 -1
  33. package/dist/internal/machineDocument.d.ts.map +1 -1
  34. package/dist/internal/machineDocument.js +3 -1
  35. package/dist/internal/machineDocument.js.map +1 -1
  36. package/dist/internal/machineWalkthrough.d.ts +26 -0
  37. package/dist/internal/machineWalkthrough.d.ts.map +1 -0
  38. package/dist/internal/machineWalkthrough.js +256 -0
  39. package/dist/internal/machineWalkthrough.js.map +1 -0
  40. package/dist/internal/projectInspector.d.ts.map +1 -1
  41. package/dist/internal/projectInspector.js +1 -11
  42. package/dist/internal/projectInspector.js.map +1 -1
  43. package/index.html +2 -2
  44. package/package.json +8 -7
  45. package/src/DevToolsProtocol.ts +0 -285
  46. package/src/MachineDocument.ts +21 -19
  47. package/src/MachineWalkthrough.ts +199 -0
  48. package/src/ProjectInspector.ts +0 -5
  49. package/src/index.ts +2 -2
  50. package/src/internal/browser/chart-layout-policy.ts +206 -0
  51. package/src/internal/browser/chart-layout.ts +686 -0
  52. package/src/internal/browser/chart-model.ts +244 -0
  53. package/src/internal/browser/chart-renderer.ts +774 -0
  54. package/src/internal/browser/example-machine.ts +15 -6
  55. package/src/internal/browser/input-form.ts +1 -418
  56. package/src/internal/browser/invoke-outcomes-example.ts +231 -0
  57. package/src/internal/browser/parallel-completion-example.ts +199 -0
  58. package/src/internal/browser/planner-example.ts +2 -0
  59. package/src/internal/browser/protocol-events-example.ts +200 -0
  60. package/src/internal/browser/styles.css +974 -342
  61. package/src/internal/browser/transition-semantics-example.ts +245 -0
  62. package/src/internal/browser/visualizer-analysis.ts +52 -0
  63. package/src/internal/browser/visualizer-app.ts +874 -706
  64. package/src/internal/browser/visualizer-model.ts +104 -0
  65. package/src/internal/devServer.ts +4 -82
  66. package/src/internal/evaluationWorker.ts +7 -371
  67. package/src/internal/machineDocument.ts +3 -1
  68. package/src/internal/machineWalkthrough.ts +355 -0
  69. package/src/internal/projectInspector.ts +3 -31
  70. package/dist/MachineSimulator.d.ts +0 -169
  71. package/dist/MachineSimulator.d.ts.map +0 -1
  72. package/dist/MachineSimulator.js +0 -98
  73. package/dist/MachineSimulator.js.map +0 -1
  74. package/dist/client/assets/index-B49Tjawc.js +0 -14
  75. package/dist/client/assets/index-BKH0cOG2.css +0 -1
  76. package/dist/internal/machineSimulator.d.ts +0 -5
  77. package/dist/internal/machineSimulator.d.ts.map +0 -1
  78. package/dist/internal/machineSimulator.js +0 -156
  79. package/dist/internal/machineSimulator.js.map +0 -1
  80. package/src/MachineSimulator.ts +0 -154
  81. package/src/internal/browser/simulation-client.ts +0 -20
  82. 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
+ }