@typeonce/effect-machine-devtools 0.24.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +4 -0
- package/README.md +11 -11
- 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-BTOAhezX.js +37 -0
- package/dist/client/assets/index-Cg7xzSkz.css +1 -0
- package/dist/client/index.html +4 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/devServer.d.ts.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 +4 -3
- 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.ts +433 -0
- package/src/internal/browser/chart-model.ts +234 -0
- package/src/internal/browser/chart-renderer.ts +661 -0
- package/src/internal/browser/example-machine.ts +15 -6
- package/src/internal/browser/input-form.ts +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 +795 -341
- package/src/internal/browser/transition-semantics-example.ts +236 -0
- package/src/internal/browser/visualizer-app.ts +781 -713
- 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,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
|
-
|
|
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) =>
|
|
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: {
|