@typeonce/effect-machine-devtools 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/NOTICE +4 -0
  2. package/README.md +14 -8
  3. package/dist/DevServer.d.ts +2 -1
  4. package/dist/DevServer.d.ts.map +1 -1
  5. package/dist/DevServer.js.map +1 -1
  6. package/dist/DevToolsProtocol.d.ts +167 -17
  7. package/dist/DevToolsProtocol.d.ts.map +1 -1
  8. package/dist/DevToolsProtocol.js +1 -1
  9. package/dist/MachineDocument.d.ts +98 -2
  10. package/dist/MachineDocument.d.ts.map +1 -1
  11. package/dist/MachineDocument.js +35 -1
  12. package/dist/MachineDocument.js.map +1 -1
  13. package/dist/MachineRegistry.d.ts +56 -6
  14. package/dist/MachineRegistry.d.ts.map +1 -1
  15. package/dist/MachineWalkthrough.d.ts +171 -0
  16. package/dist/MachineWalkthrough.d.ts.map +1 -0
  17. package/dist/MachineWalkthrough.js +98 -0
  18. package/dist/MachineWalkthrough.js.map +1 -0
  19. package/dist/client/assets/index-BTOAhezX.js +37 -0
  20. package/dist/client/assets/index-Cg7xzSkz.css +1 -0
  21. package/dist/client/index.html +4 -4
  22. package/dist/index.d.ts +2 -2
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +2 -2
  25. package/dist/index.js.map +1 -1
  26. package/dist/internal/devServer.d.ts +2 -1
  27. package/dist/internal/devServer.d.ts.map +1 -1
  28. package/dist/internal/devServer.js +4 -3
  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 +28 -9
  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 +65 -2
  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 +15 -9
  42. package/dist/internal/projectInspector.js.map +1 -1
  43. package/index.html +2 -2
  44. package/package.json +4 -3
  45. package/src/DevServer.ts +6 -2
  46. package/src/DevToolsProtocol.ts +1 -1
  47. package/src/MachineDocument.ts +56 -1
  48. package/src/MachineWalkthrough.ts +199 -0
  49. package/src/index.ts +2 -2
  50. package/src/internal/browser/chart-layout.ts +433 -0
  51. package/src/internal/browser/chart-model.ts +234 -0
  52. package/src/internal/browser/chart-renderer.ts +661 -0
  53. package/src/internal/browser/example-machine.ts +15 -6
  54. package/src/internal/browser/input-form.ts +252 -0
  55. package/src/internal/browser/invoke-outcomes-example.ts +231 -0
  56. package/src/internal/browser/parallel-completion-example.ts +199 -0
  57. package/src/internal/browser/planner-example.ts +145 -0
  58. package/src/internal/browser/protocol-events-example.ts +200 -0
  59. package/src/internal/browser/styles.css +959 -185
  60. package/src/internal/browser/transition-semantics-example.ts +236 -0
  61. package/src/internal/browser/visualizer-app.ts +818 -389
  62. package/src/internal/browser/visualizer-model.ts +104 -0
  63. package/src/internal/browser/visualizer.ts +1 -1
  64. package/src/internal/devServer.ts +9 -4
  65. package/src/internal/evaluationWorker.ts +28 -10
  66. package/src/internal/machineDocument.ts +77 -2
  67. package/src/internal/machineWalkthrough.ts +355 -0
  68. package/src/internal/projectInspector.ts +29 -14
  69. package/dist/MachineSimulator.d.ts +0 -169
  70. package/dist/MachineSimulator.d.ts.map +0 -1
  71. package/dist/MachineSimulator.js +0 -98
  72. package/dist/MachineSimulator.js.map +0 -1
  73. package/dist/client/assets/index-BGOhE3Ng.css +0 -1
  74. package/dist/client/assets/index-DiqGhsGR.js +0 -14
  75. package/dist/internal/machineSimulator.d.ts +0 -5
  76. package/dist/internal/machineSimulator.d.ts.map +0 -1
  77. package/dist/internal/machineSimulator.js +0 -156
  78. package/dist/internal/machineSimulator.js.map +0 -1
  79. package/src/MachineSimulator.ts +0 -154
  80. package/src/internal/machineSimulator.ts +0 -199
@@ -0,0 +1,661 @@
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")
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 pathData = (points: ReadonlyArray<ChartPoint>): string => {
188
+ const first = points[0]
189
+ if (first === undefined) return ""
190
+ return points.slice(1).reduce((path, point) => `${path} L ${point.x} ${point.y}`, `M ${first.x} ${first.y}`)
191
+ }
192
+
193
+ const setStateClass = (
194
+ elements: ReadonlyMap<string, ReadonlyArray<HTMLElement>>,
195
+ paths: ReadonlyArray<string>,
196
+ className: string
197
+ ): void => {
198
+ for (const path of paths) elements.get(path)?.forEach((node) => node.classList.add(className))
199
+ }
200
+
201
+ const render = (
202
+ host: HTMLElement,
203
+ layout: LaidOutChart,
204
+ handlers: ChartHandlers
205
+ ): ChartView => {
206
+ const viewport = element("div", "chart-viewport")
207
+ const stage = element("div", "chart-stage")
208
+ const canvas = element("div", "chart-canvas")
209
+ stage.style.width = `${layout.width}px`
210
+ stage.style.height = `${layout.height}px`
211
+ canvas.style.width = `${layout.width}px`
212
+ canvas.style.height = `${layout.height}px`
213
+ const regions = element("div", "chart-regions")
214
+ const svg = svgElement("svg", "chart-edges")
215
+ svg.setAttribute("width", String(layout.width))
216
+ svg.setAttribute("height", String(layout.height))
217
+ svg.setAttribute("viewBox", `0 0 ${layout.width} ${layout.height}`)
218
+ svg.setAttribute("aria-hidden", "true")
219
+ const definitions = svgElement("defs")
220
+ const marker = svgElement("marker")
221
+ marker.id = "chart-arrow"
222
+ marker.setAttribute("viewBox", "0 0 10 10")
223
+ marker.setAttribute("refX", "9")
224
+ marker.setAttribute("refY", "5")
225
+ marker.setAttribute("markerWidth", "7")
226
+ marker.setAttribute("markerHeight", "7")
227
+ marker.setAttribute("orient", "auto-start-reverse")
228
+ const arrow = svgElement("path")
229
+ arrow.setAttribute("d", "M 0 0 L 10 5 L 0 10 z")
230
+ marker.append(arrow)
231
+ definitions.append(marker)
232
+ svg.append(definitions)
233
+ const nodesLayer = element("div", "chart-nodes")
234
+ const labelsLayer = element("div", "chart-labels")
235
+ canvas.append(regions, svg, nodesLayer, labelsLayer)
236
+ stage.append(canvas)
237
+ viewport.append(stage)
238
+ host.replaceChildren(viewport)
239
+
240
+ let zoom = 1
241
+ let pan: {
242
+ readonly pointerId: number
243
+ readonly start: ChartZoomAnchor
244
+ readonly scrollLeft: number
245
+ readonly scrollTop: number
246
+ dragging: boolean
247
+ } | undefined
248
+ let suppressClick = false
249
+ let suppressDoubleClickUntil = 0
250
+
251
+ const stateElements = new Map<string, Array<HTMLElement>>()
252
+ const stateControls = new Map<string, HTMLButtonElement>()
253
+ const stateStatuses = new Map<string, HTMLElement>()
254
+ const transitionElements = new Map<string, Array<Element>>()
255
+ const edgeElements = new Map<string, Array<Element>>()
256
+ const transitionControls = new Map<string, Array<HTMLButtonElement>>()
257
+ const chartEdges = new Map(
258
+ layout.edges.flatMap((laidOut) => laidOut.kind === "transition" ? [[laidOut.edge.id, laidOut.edge] as const] : [])
259
+ )
260
+
261
+ const registerStateElement = (path: string, node: HTMLElement): void => {
262
+ const registered = stateElements.get(path) ?? []
263
+ registered.push(node)
264
+ stateElements.set(path, registered)
265
+ }
266
+ const registerTransitionElement = (id: string, node: Element): void => {
267
+ const registered = transitionElements.get(id) ?? []
268
+ registered.push(node)
269
+ transitionElements.set(id, registered)
270
+ }
271
+ const registerEdgeElement = (id: string, node: Element): void => {
272
+ const registered = edgeElements.get(id) ?? []
273
+ registered.push(node)
274
+ edgeElements.set(id, registered)
275
+ }
276
+
277
+ for (const laidOut of layout.nodes) {
278
+ if (laidOut.node.children.length > 0) {
279
+ const region = element("div", `chart-compound chart-compound-${laidOut.node.type}`)
280
+ region.dataset.statePath = laidOut.node.path
281
+ position(region, laidOut)
282
+ regions.append(region)
283
+ registerStateElement(laidOut.node.path, region)
284
+ }
285
+
286
+ const card = element("button", `chart-state chart-state-${laidOut.node.type}`)
287
+ card.type = "button"
288
+ card.dataset.statePath = laidOut.node.path
289
+ card.setAttribute("aria-label", `${laidOut.node.label}, ${laidOut.node.type} state`)
290
+ position(card, {
291
+ x: laidOut.x,
292
+ y: laidOut.y,
293
+ width: laidOut.width,
294
+ height: laidOut.node.children.length > 0 ? laidOut.headerHeight : laidOut.height
295
+ })
296
+ const stateStatus = status(laidOut.node.active, laidOut.node.initial)
297
+ card.append(stateContent(laidOut, stateStatus))
298
+ card.addEventListener(
299
+ "click",
300
+ (event) => handlers.selectState(laidOut.node.path, { x: event.clientX, y: event.clientY })
301
+ )
302
+ card.addEventListener("dblclick", () => handlers.openStateDetails(laidOut.node.path))
303
+ nodesLayer.append(card)
304
+ registerStateElement(laidOut.node.path, card)
305
+ stateControls.set(laidOut.node.path, card)
306
+ stateStatuses.set(laidOut.node.path, stateStatus)
307
+ }
308
+
309
+ for (const initial of layout.initials) {
310
+ const dot = element("span", "chart-initial")
311
+ dot.setAttribute("aria-hidden", "true")
312
+ position(dot, initial)
313
+ nodesLayer.append(dot)
314
+ }
315
+
316
+ for (const runtime of layout.runtimeTargets) {
317
+ const target = element("span", "chart-runtime-target", runtime.target.label)
318
+ target.setAttribute("aria-hidden", "true")
319
+ position(target, runtime)
320
+ nodesLayer.append(target)
321
+ }
322
+
323
+ const hoverTransition = (
324
+ transitionId: string,
325
+ source: string,
326
+ target: string | null,
327
+ hovered: boolean
328
+ ): void => {
329
+ transitionElements.get(transitionId)?.forEach((node) => node.classList.toggle("is-hovered", hovered))
330
+ stateElements.get(source)?.forEach((node) => node.classList.toggle("is-hover-source", hovered))
331
+ if (target !== null) {
332
+ stateElements.get(target)?.forEach((node) => node.classList.toggle("is-hover-target", hovered))
333
+ }
334
+ }
335
+
336
+ for (const laidOut of layout.edges) {
337
+ const group = svgElement(
338
+ "g",
339
+ `chart-edge-group chart-edge-${laidOut.kind}${
340
+ laidOut.kind === "transition" ? ` chart-transition-${laidOut.edge.kind}` : ""
341
+ }`
342
+ )
343
+ const visible = svgElement("path", "chart-edge-line")
344
+ const route = pathData(laidOut.points)
345
+ visible.setAttribute("d", route)
346
+ visible.setAttribute("marker-end", "url(#chart-arrow)")
347
+ const hit = svgElement("path", "chart-edge-hit")
348
+ hit.setAttribute("d", route)
349
+ group.append(visible, hit)
350
+ svg.append(group)
351
+ if (laidOut.kind === "initial") continue
352
+
353
+ registerTransitionElement(laidOut.edge.transitionId, group)
354
+ registerEdgeElement(laidOut.edge.id, group)
355
+ const label = element(
356
+ "button",
357
+ `chart-edge-label chart-edge-label-${laidOut.edge.trigger.type}`,
358
+ laidOut.edge.label
359
+ )
360
+ label.type = "button"
361
+ label.dataset.transitionId = laidOut.edge.transitionId
362
+ position(label, {
363
+ x: laidOut.label.x - laidOut.labelWidth / 2,
364
+ y: laidOut.label.y - laidOut.labelHeight / 2,
365
+ width: laidOut.labelWidth,
366
+ height: laidOut.labelHeight
367
+ })
368
+ label.addEventListener(
369
+ "click",
370
+ (event) =>
371
+ handlers.selectTransition(
372
+ laidOut.edge.transitionId,
373
+ laidOut.edge.branchIds,
374
+ { x: event.clientX, y: event.clientY }
375
+ )
376
+ )
377
+ label.addEventListener("dblclick", () => handlers.openTransitionDetails(laidOut.edge.transitionId))
378
+ label.addEventListener(
379
+ "mouseenter",
380
+ () => hoverTransition(laidOut.edge.transitionId, laidOut.edge.source, laidOut.edge.target, true)
381
+ )
382
+ label.addEventListener(
383
+ "mouseleave",
384
+ () => hoverTransition(laidOut.edge.transitionId, laidOut.edge.source, laidOut.edge.target, false)
385
+ )
386
+ hit.addEventListener(
387
+ "click",
388
+ (event) =>
389
+ handlers.selectTransition(
390
+ laidOut.edge.transitionId,
391
+ laidOut.edge.branchIds,
392
+ { x: event.clientX, y: event.clientY }
393
+ )
394
+ )
395
+ hit.addEventListener("dblclick", () => handlers.openTransitionDetails(laidOut.edge.transitionId))
396
+ hit.addEventListener(
397
+ "mouseenter",
398
+ () => hoverTransition(laidOut.edge.transitionId, laidOut.edge.source, laidOut.edge.target, true)
399
+ )
400
+ hit.addEventListener(
401
+ "mouseleave",
402
+ () => hoverTransition(laidOut.edge.transitionId, laidOut.edge.source, laidOut.edge.target, false)
403
+ )
404
+ labelsLayer.append(label)
405
+ registerTransitionElement(laidOut.edge.transitionId, label)
406
+ registerEdgeElement(laidOut.edge.id, label)
407
+ const controls = transitionControls.get(laidOut.edge.id) ?? []
408
+ controls.push(label)
409
+ transitionControls.set(laidOut.edge.id, controls)
410
+ }
411
+
412
+ viewport.addEventListener("keydown", (event) => {
413
+ if (event.key !== "Escape") return
414
+ event.preventDefault()
415
+ handlers.clearSelection()
416
+ })
417
+
418
+ viewport.addEventListener("pointerdown", (event) => {
419
+ if (!event.isPrimary || event.button !== 0 || pan !== undefined) return
420
+ pan = {
421
+ pointerId: event.pointerId,
422
+ start: { x: event.clientX, y: event.clientY },
423
+ scrollLeft: viewport.scrollLeft,
424
+ scrollTop: viewport.scrollTop,
425
+ dragging: false
426
+ }
427
+ })
428
+
429
+ viewport.addEventListener("pointermove", (event) => {
430
+ if (pan === undefined || event.pointerId !== pan.pointerId) return
431
+ const current = { x: event.clientX, y: event.clientY }
432
+ if (!pan.dragging) {
433
+ if (!isChartPan(pan.start, current)) return
434
+ pan.dragging = true
435
+ viewport.setPointerCapture(event.pointerId)
436
+ viewport.classList.add("is-panning")
437
+ const focused = document.activeElement
438
+ if (focused instanceof HTMLElement && viewport.contains(focused)) focused.blur()
439
+ }
440
+ event.preventDefault()
441
+ viewport.scrollTo({
442
+ left: pan.scrollLeft - (current.x - pan.start.x),
443
+ top: pan.scrollTop - (current.y - pan.start.y)
444
+ })
445
+ })
446
+
447
+ const finishPan = (event: PointerEvent, cancelled: boolean): void => {
448
+ if (pan === undefined || event.pointerId !== pan.pointerId) return
449
+ const dragged = pan.dragging
450
+ if (viewport.hasPointerCapture(event.pointerId)) viewport.releasePointerCapture(event.pointerId)
451
+ viewport.classList.remove("is-panning")
452
+ pan = undefined
453
+ if (!dragged || cancelled) return
454
+ event.preventDefault()
455
+ suppressClick = true
456
+ suppressDoubleClickUntil = performance.now() + 400
457
+ setTimeout(() => {
458
+ suppressClick = false
459
+ }, 0)
460
+ }
461
+
462
+ viewport.addEventListener("pointerup", (event) => finishPan(event, false))
463
+ viewport.addEventListener("pointercancel", (event) => finishPan(event, true))
464
+ viewport.addEventListener("click", (event) => {
465
+ if (!suppressClick) return
466
+ suppressClick = false
467
+ event.preventDefault()
468
+ event.stopImmediatePropagation()
469
+ }, true)
470
+ viewport.addEventListener("dblclick", (event) => {
471
+ if (performance.now() > suppressDoubleClickUntil) return
472
+ event.preventDefault()
473
+ event.stopImmediatePropagation()
474
+ }, true)
475
+
476
+ const revealState = (path: string): void => {
477
+ const control = stateControls.get(path)
478
+ if (control === undefined) return
479
+ const left = Number.parseFloat(control.style.left) * zoom
480
+ const top = Number.parseFloat(control.style.top) * zoom
481
+ const width = control.offsetWidth * zoom
482
+ const height = control.offsetHeight * zoom
483
+ viewport.scrollTo({
484
+ left: Math.max(0, left - Math.max(28, (viewport.clientWidth - width) / 2)),
485
+ top: Math.max(0, top - Math.max(28, (viewport.clientHeight - height) / 2))
486
+ })
487
+ }
488
+
489
+ const revealStates = (paths: ReadonlyArray<string>): void => {
490
+ const leaves = paths.filter((path) => !paths.some((candidate) => candidate.startsWith(`${path}.`)))
491
+ const bounds = leaves
492
+ .map((path) => layout.nodes.find(({ node }) => node.path === path))
493
+ .filter((node): node is LaidOutChartNode => node !== undefined)
494
+ if (bounds.length === 0) return
495
+ const left = Math.min(...bounds.map(({ x }) => x)) * zoom
496
+ const top = Math.min(...bounds.map(({ y }) => y)) * zoom
497
+ const right = Math.max(...bounds.map(({ x, width }) => x + width)) * zoom
498
+ const bottom = Math.max(...bounds.map(({ y, height }) => y + height)) * zoom
499
+ const margin = 32
500
+ const visible = left >= viewport.scrollLeft + margin &&
501
+ top >= viewport.scrollTop + margin &&
502
+ right <= viewport.scrollLeft + viewport.clientWidth - margin &&
503
+ bottom <= viewport.scrollTop + viewport.clientHeight - margin
504
+ if (visible) return
505
+ viewport.scrollTo({
506
+ left: Math.max(0, (left + right - viewport.clientWidth) / 2),
507
+ top: Math.max(0, (top + bottom - viewport.clientHeight) / 2)
508
+ })
509
+ }
510
+
511
+ const setZoom = (requested: number, anchor?: ChartZoomAnchor): number => {
512
+ const next = clampZoom(requested)
513
+ if (next === zoom) return zoom
514
+ const zoomAnchor = anchor ?? { x: viewport.clientWidth / 2, y: viewport.clientHeight / 2 }
515
+ const scroll = chartZoomScrollPosition(
516
+ zoom,
517
+ next,
518
+ viewport.scrollLeft,
519
+ viewport.scrollTop,
520
+ zoomAnchor
521
+ )
522
+ zoom = next
523
+ canvas.style.transform = `scale(${zoom})`
524
+ stage.style.width = `${layout.width * zoom}px`
525
+ stage.style.height = `${layout.height * zoom}px`
526
+ viewport.scrollTo({
527
+ left: scroll.x,
528
+ top: scroll.y
529
+ })
530
+ handlers.zoomChanged(zoom)
531
+ return zoom
532
+ }
533
+
534
+ viewport.addEventListener("wheel", (event) => {
535
+ if (!event.ctrlKey) return
536
+ event.preventDefault()
537
+ const bounds = viewport.getBoundingClientRect()
538
+ setZoom(
539
+ chartWheelZoom(zoom, event.deltaY, event.deltaMode, viewport.clientHeight),
540
+ { x: event.clientX - bounds.left, y: event.clientY - bounds.top }
541
+ )
542
+ }, { passive: false })
543
+
544
+ const fit = (): number => {
545
+ const availableWidth = Math.max(1, viewport.clientWidth - 48)
546
+ const availableHeight = Math.max(1, viewport.clientHeight - 48)
547
+ const fitted = setZoom(Math.min(1, availableWidth / layout.width, availableHeight / layout.height))
548
+ viewport.scrollTo({ left: 0, top: 0 })
549
+ return fitted
550
+ }
551
+
552
+ return {
553
+ update: (presentation) => {
554
+ const active = new Set(presentation.activePaths)
555
+ const availableBranches = new Set(presentation.availableBranchIds)
556
+ const unavailableBranches = new Set(presentation.unavailableBranchIds)
557
+ stateControls.forEach((control) => {
558
+ control.disabled = presentation.simulationMode
559
+ })
560
+ transitionControls.forEach((controls, edgeId) => {
561
+ const edge = chartEdges.get(edgeId)
562
+ const interactive = edge?.branchIds.some((branchId) =>
563
+ availableBranches.has(branchId) || unavailableBranches.has(branchId)
564
+ )
565
+ controls.forEach((control) => {
566
+ control.disabled = presentation.simulationMode && !interactive
567
+ })
568
+ })
569
+ stateElements.forEach((elements) =>
570
+ elements.forEach((node) => {
571
+ node.classList.remove(
572
+ "is-selected",
573
+ "is-related-from",
574
+ "is-related-to"
575
+ )
576
+ })
577
+ )
578
+ transitionElements.forEach((elements) =>
579
+ elements.forEach((node) => {
580
+ node.classList.remove(
581
+ "is-selected",
582
+ "is-incoming",
583
+ "is-outgoing",
584
+ "is-walkthrough-available",
585
+ "is-walkthrough-unavailable"
586
+ )
587
+ })
588
+ )
589
+ stateStatuses.forEach((node, path) => {
590
+ const isActive = active.has(path)
591
+ const isInitial = node.dataset.initial === "true"
592
+ node.classList.toggle("is-active", isActive)
593
+ node.setAttribute("aria-label", statusLabel(isActive, isInitial))
594
+ })
595
+ viewport.classList.toggle("is-simulating", presentation.simulationMode)
596
+ if (presentation.selectedState !== null) {
597
+ stateElements.get(presentation.selectedState)?.forEach((node) => node.classList.add("is-selected"))
598
+ }
599
+ if (presentation.selectedTransition !== null) {
600
+ transitionElements.get(presentation.selectedTransition)?.forEach((node) => node.classList.add("is-selected"))
601
+ }
602
+ setStateClass(stateElements, presentation.fromPaths, "is-related-from")
603
+ setStateClass(stateElements, presentation.toPaths, "is-related-to")
604
+ presentation.incomingTransitionIds.forEach((id) =>
605
+ transitionElements.get(id)?.forEach((node) => node.classList.add("is-incoming"))
606
+ )
607
+ presentation.outgoingTransitionIds.forEach((id) =>
608
+ transitionElements.get(id)?.forEach((node) => node.classList.add("is-outgoing"))
609
+ )
610
+ layout.edges.forEach((laidOut) => {
611
+ if (laidOut.kind !== "transition") return
612
+ const available = laidOut.edge.branchIds.some((branchId) => availableBranches.has(branchId))
613
+ const unavailable = laidOut.edge.branchIds.some((branchId) => unavailableBranches.has(branchId))
614
+ const className = available
615
+ ? "is-walkthrough-available"
616
+ : unavailable
617
+ ? "is-walkthrough-unavailable"
618
+ : undefined
619
+ if (className !== undefined) {
620
+ edgeElements.get(laidOut.edge.id)?.forEach((node) => node.classList.add(className))
621
+ }
622
+ })
623
+ },
624
+ focusState: (path) => {
625
+ const control = stateControls.get(path)
626
+ control?.focus({ preventScroll: true })
627
+ revealState(path)
628
+ },
629
+ revealState,
630
+ revealStates,
631
+ getZoom: () => zoom,
632
+ setZoom,
633
+ fit
634
+ }
635
+ }
636
+
637
+ export const renderChart = (
638
+ host: HTMLElement,
639
+ document: VisualizationDocument,
640
+ handlers: ChartHandlers
641
+ ): Effect.Effect<ChartView, ChartLayoutError> => {
642
+ const statesByPath = new Map(document.states.map((state) => [state.path, state]))
643
+ const visited = new Set<string>()
644
+ let initialFocus = document.initial.target
645
+ while (!visited.has(initialFocus)) {
646
+ visited.add(initialFocus)
647
+ const state = statesByPath.get(initialFocus)
648
+ if (state === undefined) break
649
+ const next = state.type === "parallel" ? state.children[0] : state.initial
650
+ if (next === null || next === undefined) break
651
+ initialFocus = next
652
+ }
653
+
654
+ return layoutChart(makeChartModel(document)).pipe(Effect.map((layout) => {
655
+ const view = render(host, layout, handlers)
656
+ setTimeout(() => {
657
+ if (host.isConnected) view.revealState(initialFocus)
658
+ }, 0)
659
+ return view
660
+ }))
661
+ }
@@ -3,8 +3,14 @@ import { Schema } from "effect"
3
3
 
4
4
  // Shared by the live browser UI and its project-inspection fixture.
5
5
 
6
- class Application extends Schema.TaggedClass<Application>("Application")("Application", {}) {}
7
- class Workflow extends Schema.TaggedClass<Workflow>("Workflow")("Workflow", {}) {}
6
+ class Application extends Schema.TaggedClass<Application>("Application")("Application", {
7
+ workspace: Schema.String,
8
+ revision: Schema.Number
9
+ }) {}
10
+ class Workflow extends Schema.TaggedClass<Workflow>("Workflow")("Workflow", {
11
+ document: Schema.String,
12
+ unsavedChanges: Schema.Number
13
+ }) {}
8
14
  class Idle extends Schema.TaggedClass<Idle>("Idle")("Idle", {}) {}
9
15
  class Running extends Schema.TaggedClass<Running>("Running")("Running", {}) {}
10
16
  class Editing extends Schema.TaggedClass<Editing>("Editing")("Editing", {}) {}
@@ -59,11 +65,11 @@ const States = Machine.states({
59
65
 
60
66
  export const snapshot = {
61
67
  path: "application" as const,
62
- value: new Application({}),
68
+ value: new Application({ workspace: "effect-machine", revision: 7 }),
63
69
  states: {
64
70
  workflow: {
65
71
  path: "application.workflow" as const,
66
- value: new Workflow({}),
72
+ value: new Workflow({ document: "Machine.ts", unsavedChanges: 2 }),
67
73
  state: { path: "application.workflow.idle" as const, value: new Idle({}) }
68
74
  },
69
75
  connection: {
@@ -103,9 +109,12 @@ export const machine = Machine.make({
103
109
  target.decoded(
104
110
  new Running({}),
105
111
  (running) => running.editing.decoded(new Editing({}))
106
- ).update(owner.decoded(new Workflow({})))
112
+ ).update(owner.decoded(new Workflow({ document: "Machine.ts", unsavedChanges: 3 })))
107
113
  ),
108
- Refresh: (to) => to.local.update(({ owner }) => owner.decoded(new Workflow({})))
114
+ Refresh: (to) =>
115
+ to.local.update(({ owner }) =>
116
+ owner.decoded(new Workflow({ document: "Machine.ts", unsavedChanges: 0 }))
117
+ )
109
118
  }
110
119
  },
111
120
  running: {