@zenfg/inspector 0.1.0-beta.1

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 (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/dist/FrameGraphInspector.d.ts +153 -0
  4. package/dist/FrameGraphInspector.d.ts.map +1 -0
  5. package/dist/FrameGraphInspector.js +580 -0
  6. package/dist/FrameGraphInspector.js.map +1 -0
  7. package/dist/debugCaptureModel.d.ts +166 -0
  8. package/dist/debugCaptureModel.d.ts.map +1 -0
  9. package/dist/debugCaptureModel.js +364 -0
  10. package/dist/debugCaptureModel.js.map +1 -0
  11. package/dist/index.d.ts +17 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +15 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/panelAliasAnalysis.d.ts +20 -0
  16. package/dist/panelAliasAnalysis.d.ts.map +1 -0
  17. package/dist/panelAliasAnalysis.js +113 -0
  18. package/dist/panelAliasAnalysis.js.map +1 -0
  19. package/dist/panelCytoscapeGraphRenderer.d.ts +71 -0
  20. package/dist/panelCytoscapeGraphRenderer.d.ts.map +1 -0
  21. package/dist/panelCytoscapeGraphRenderer.js +559 -0
  22. package/dist/panelCytoscapeGraphRenderer.js.map +1 -0
  23. package/dist/panelDiagnosticsView.d.ts +22 -0
  24. package/dist/panelDiagnosticsView.d.ts.map +1 -0
  25. package/dist/panelDiagnosticsView.js +123 -0
  26. package/dist/panelDiagnosticsView.js.map +1 -0
  27. package/dist/panelDomHelpers.d.ts +26 -0
  28. package/dist/panelDomHelpers.d.ts.map +1 -0
  29. package/dist/panelDomHelpers.js +129 -0
  30. package/dist/panelDomHelpers.js.map +1 -0
  31. package/dist/panelGraphLayout.d.ts +28 -0
  32. package/dist/panelGraphLayout.d.ts.map +1 -0
  33. package/dist/panelGraphLayout.js +223 -0
  34. package/dist/panelGraphLayout.js.map +1 -0
  35. package/dist/panelGraphRenderer.d.ts +20 -0
  36. package/dist/panelGraphRenderer.d.ts.map +1 -0
  37. package/dist/panelGraphRenderer.js +2 -0
  38. package/dist/panelGraphRenderer.js.map +1 -0
  39. package/dist/panelGraphScene.d.ts +98 -0
  40. package/dist/panelGraphScene.d.ts.map +1 -0
  41. package/dist/panelGraphScene.js +559 -0
  42. package/dist/panelGraphScene.js.map +1 -0
  43. package/dist/panelGraphView.d.ts +9 -0
  44. package/dist/panelGraphView.d.ts.map +1 -0
  45. package/dist/panelGraphView.js +88 -0
  46. package/dist/panelGraphView.js.map +1 -0
  47. package/dist/panelGraphVisuals.d.ts +38 -0
  48. package/dist/panelGraphVisuals.d.ts.map +1 -0
  49. package/dist/panelGraphVisuals.js +330 -0
  50. package/dist/panelGraphVisuals.js.map +1 -0
  51. package/dist/panelIcons.d.ts +4 -0
  52. package/dist/panelIcons.d.ts.map +1 -0
  53. package/dist/panelIcons.js +43 -0
  54. package/dist/panelIcons.js.map +1 -0
  55. package/dist/panelInspectorView.d.ts +33 -0
  56. package/dist/panelInspectorView.d.ts.map +1 -0
  57. package/dist/panelInspectorView.js +357 -0
  58. package/dist/panelInspectorView.js.map +1 -0
  59. package/dist/panelMemoryView.d.ts +21 -0
  60. package/dist/panelMemoryView.d.ts.map +1 -0
  61. package/dist/panelMemoryView.js +134 -0
  62. package/dist/panelMemoryView.js.map +1 -0
  63. package/dist/panelPassesView.d.ts +31 -0
  64. package/dist/panelPassesView.d.ts.map +1 -0
  65. package/dist/panelPassesView.js +183 -0
  66. package/dist/panelPassesView.js.map +1 -0
  67. package/dist/panelResourcesView.d.ts +19 -0
  68. package/dist/panelResourcesView.d.ts.map +1 -0
  69. package/dist/panelResourcesView.js +89 -0
  70. package/dist/panelResourcesView.js.map +1 -0
  71. package/dist/panelSelection.d.ts +5 -0
  72. package/dist/panelSelection.d.ts.map +1 -0
  73. package/dist/panelSelection.js +88 -0
  74. package/dist/panelSelection.js.map +1 -0
  75. package/dist/panelTypes.d.ts +40 -0
  76. package/dist/panelTypes.d.ts.map +1 -0
  77. package/dist/panelTypes.js +2 -0
  78. package/dist/panelTypes.js.map +1 -0
  79. package/dist/panelVisualTheme.d.ts +76 -0
  80. package/dist/panelVisualTheme.d.ts.map +1 -0
  81. package/dist/panelVisualTheme.js +39 -0
  82. package/dist/panelVisualTheme.js.map +1 -0
  83. package/dist/panelWorkbenchHelpers.d.ts +34 -0
  84. package/dist/panelWorkbenchHelpers.d.ts.map +1 -0
  85. package/dist/panelWorkbenchHelpers.js +162 -0
  86. package/dist/panelWorkbenchHelpers.js.map +1 -0
  87. package/dist/panelWorkbenchView.d.ts +77 -0
  88. package/dist/panelWorkbenchView.d.ts.map +1 -0
  89. package/dist/panelWorkbenchView.js +409 -0
  90. package/dist/panelWorkbenchView.js.map +1 -0
  91. package/dist/query.d.ts +11 -0
  92. package/dist/query.d.ts.map +1 -0
  93. package/dist/query.js +21 -0
  94. package/dist/query.js.map +1 -0
  95. package/dist/styles.d.ts +8 -0
  96. package/dist/styles.d.ts.map +1 -0
  97. package/dist/styles.js +1073 -0
  98. package/dist/styles.js.map +1 -0
  99. package/package.json +29 -0
  100. package/src/FrameGraphInspector.ts +632 -0
  101. package/src/debugCaptureModel.ts +583 -0
  102. package/src/index.ts +21 -0
  103. package/src/panelAliasAnalysis.ts +164 -0
  104. package/src/panelCytoscapeGraphRenderer.ts +617 -0
  105. package/src/panelDiagnosticsView.ts +160 -0
  106. package/src/panelDomHelpers.ts +151 -0
  107. package/src/panelGraphLayout.ts +281 -0
  108. package/src/panelGraphRenderer.ts +21 -0
  109. package/src/panelGraphScene.ts +787 -0
  110. package/src/panelGraphView.ts +108 -0
  111. package/src/panelGraphVisuals.ts +364 -0
  112. package/src/panelIcons.ts +56 -0
  113. package/src/panelInspectorView.ts +377 -0
  114. package/src/panelMemoryView.ts +154 -0
  115. package/src/panelPassesView.ts +222 -0
  116. package/src/panelResourcesView.ts +115 -0
  117. package/src/panelSelection.ts +99 -0
  118. package/src/panelTypes.ts +28 -0
  119. package/src/panelVisualTheme.ts +39 -0
  120. package/src/panelWorkbenchHelpers.ts +216 -0
  121. package/src/panelWorkbenchView.ts +465 -0
  122. package/src/query.ts +22 -0
  123. package/src/styles.ts +1076 -0
@@ -0,0 +1,632 @@
1
+ import {
2
+ FrameGraphSnapshotValidationError,
3
+ decodeFrameGraphSnapshot,
4
+ parseFrameGraphSnapshot,
5
+ stringifyFrameGraphSnapshot,
6
+ type FrameGraphSnapshot,
7
+ } from '@zenfg/snapshot';
8
+ import {
9
+ createDebugViewModel,
10
+ type FrameGraphDebugSnapshotSource,
11
+ type FrameGraphDebugViewModel,
12
+ } from './debugCaptureModel.ts';
13
+ import { createToolbarButton } from './panelDomHelpers.ts';
14
+ import { createPanelIcon } from './panelIcons.ts';
15
+ import {
16
+ destroyGraph,
17
+ fitGraph,
18
+ } from './panelGraphView.ts';
19
+ import { graphGroupElementId } from './panelGraphScene.ts';
20
+ import { selectionExists } from './panelSelection.ts';
21
+ import type { GraphViewState, Selection } from './panelTypes.ts';
22
+ import { ensureFrameGraphInspectorStyles } from './styles.ts';
23
+ import { sameSelection, type WorkbenchCallbacks } from './panelWorkbenchHelpers.ts';
24
+ import { FrameGraphDebugWorkbench } from './panelWorkbenchView.ts';
25
+
26
+ /** Construction and safety limits for an embedded {@link FrameGraphInspector}. */
27
+ export type FrameGraphInspectorOptions = {
28
+ /**
29
+ * Product label shown in the workbench command bar. Pass `false` to hide
30
+ * visible branding while retaining the inspector's accessible name.
31
+ *
32
+ * @defaultValue `"ZenFG Inspector"`
33
+ */
34
+ branding?: string | false;
35
+ /**
36
+ * Produces a live snapshot when capture is requested. The inspector awaits
37
+ * promises and displays thrown or rejected errors in its status area.
38
+ */
39
+ captureSnapshot?: () => FrameGraphSnapshot | undefined | Promise<FrameGraphSnapshot | undefined>;
40
+ /**
41
+ * Maximum accepted import size in bytes.
42
+ *
43
+ * @defaultValue `67108864` (64 MiB)
44
+ */
45
+ maxImportBytes?: number;
46
+ /**
47
+ * Maximum graph nodes plus edges accepted by automatic layout.
48
+ *
49
+ * @defaultValue `5000`
50
+ */
51
+ maxGraphElements?: number;
52
+ };
53
+
54
+ const DEFAULT_MAX_IMPORT_BYTES = 64 * 1024 * 1024;
55
+ const DEFAULT_MAX_GRAPH_ELEMENTS = 5_000;
56
+ const DEFAULT_BRANDING = 'ZenFG Inspector';
57
+
58
+ /**
59
+ * Browser lifecycle controller for one embeddable FrameGraph inspector.
60
+ *
61
+ * @remarks The constructor creates, but does not append, {@link dom}. Use
62
+ * {@link mountFrameGraphInspector} for the common append-and-return workflow.
63
+ * Async capture, import, and clipboard failures are reported in the UI. Call
64
+ * {@link destroy} when the controller is no longer needed.
65
+ *
66
+ * @example
67
+ * ```ts
68
+ * const inspector = new FrameGraphInspector({ captureSnapshot: capture });
69
+ * document.body.append(inspector.dom);
70
+ * // Later:
71
+ * inspector.destroy();
72
+ * ```
73
+ */
74
+ export class FrameGraphInspector {
75
+ /** Root element owned by this inspector instance. */
76
+ readonly dom = document.createElement('section');
77
+
78
+ private readonly body = document.createElement('div');
79
+ private readonly content = document.createElement('div');
80
+ private readonly dropOverlay = document.createElement('div');
81
+ private readonly passesGraphModeButton: HTMLButtonElement;
82
+ private readonly resourcesGraphModeButton: HTMLButtonElement;
83
+ private readonly groupsButton: HTMLButtonElement;
84
+ private readonly collapseGroupsButton: HTMLButtonElement;
85
+ private readonly graphView: GraphViewState;
86
+ private readonly workbench: FrameGraphDebugWorkbench;
87
+ private captureSnapshotCallback: FrameGraphInspectorOptions['captureSnapshot'];
88
+ private protocolSnapshot: FrameGraphSnapshot | undefined;
89
+ private operationRevision = 0;
90
+ private viewModel: FrameGraphDebugViewModel | undefined;
91
+ private selected: Selection | undefined;
92
+ private hovered: Selection | undefined;
93
+ private capturing = false;
94
+ private importing = false;
95
+ private copying = false;
96
+ private copied = false;
97
+ private initialAutoCaptureAttempted = false;
98
+ private statusMessage: string | undefined;
99
+ private statusTone: 'neutral' | 'error' = 'neutral';
100
+ private destroyed = false;
101
+ private copyFeedbackTimeout: number | undefined;
102
+ private readonly maxImportBytes: number;
103
+ private dragDepth = 0;
104
+ private readonly handleDragEnter = (event: DragEvent): void => {
105
+ if (!isFileDrag(event)) return;
106
+ event.preventDefault();
107
+ this.dragDepth += 1;
108
+ this.setDropActive(true);
109
+ };
110
+ private readonly handleDragOver = (event: DragEvent): void => {
111
+ if (!isFileDrag(event)) return;
112
+ event.preventDefault();
113
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
114
+ this.setDropActive(true);
115
+ };
116
+ private readonly handleDragLeave = (event: DragEvent): void => {
117
+ if (!isFileDrag(event) && this.dragDepth === 0) return;
118
+ this.dragDepth = Math.max(0, this.dragDepth - 1);
119
+ if (this.dragDepth === 0) this.setDropActive(false);
120
+ };
121
+ private readonly handleDragEnd = (): void => this.clearDropState();
122
+ private readonly handleDrop = (event: DragEvent): void => {
123
+ const file = event.dataTransfer?.files?.[0];
124
+ if (!file) return;
125
+ event.preventDefault();
126
+ this.clearDropState();
127
+ void this.importSnapshot(file);
128
+ };
129
+
130
+ /**
131
+ * Creates a detached inspector UI.
132
+ *
133
+ * @throws If a configured numeric limit is not a non-negative safe integer,
134
+ * or if browser DOM globals are unavailable.
135
+ */
136
+ constructor(options: FrameGraphInspectorOptions = {}) {
137
+ ensureFrameGraphInspectorStyles();
138
+ this.captureSnapshotCallback = options.captureSnapshot;
139
+ this.maxImportBytes = normalizeMaxImportBytes(options.maxImportBytes);
140
+ const branding = options.branding ?? DEFAULT_BRANDING;
141
+ this.dom.className = 'zenfg-inspector';
142
+ this.dom.id = createInspectorId();
143
+ this.dom.setAttribute('role', 'region');
144
+ this.dom.setAttribute('aria-label', branding === false ? DEFAULT_BRANDING : branding);
145
+ this.body.className = 'zenfg-inspector-body';
146
+ this.content.className = 'zenfg-inspector-content';
147
+ this.dropOverlay.className = 'zenfg-inspector-drop-overlay';
148
+ this.dropOverlay.hidden = true;
149
+ this.dropOverlay.setAttribute('aria-hidden', 'true');
150
+ const dropTitle = document.createElement('strong');
151
+ dropTitle.textContent = 'Drop snapshot to open';
152
+ const dropDetail = document.createElement('span');
153
+ dropDetail.textContent = 'The first file will be imported locally.';
154
+ this.dropOverlay.append(dropTitle, dropDetail);
155
+
156
+ const graphLegend = document.createElement('div');
157
+ graphLegend.className = 'zenfg-inspector-graph-legend';
158
+ graphLegend.setAttribute('aria-label', 'Graph legend');
159
+ this.graphView = {
160
+ host: document.createElement('div'),
161
+ toolbar: document.createElement('div'),
162
+ legend: graphLegend,
163
+ layoutElementBudget: normalizeLimit(options.maxGraphElements, DEFAULT_MAX_GRAPH_ELEMENTS, 'maxGraphElements'),
164
+ graphMode: 'passes',
165
+ groupsEnabled: true,
166
+ expandedGroupPaths: new Set(),
167
+ fitOnNextRender: true,
168
+ };
169
+ this.graphView.host.className = 'zenfg-inspector-graph';
170
+ this.graphView.toolbar.className = 'zenfg-inspector-graph-toolbar';
171
+ this.graphView.toolbar.setAttribute('role', 'toolbar');
172
+ this.graphView.toolbar.setAttribute('aria-label', 'Frame graph view controls');
173
+
174
+ this.passesGraphModeButton = createToolbarButton('Pass dependency', 'Show pass dependency graph', () => this.setGraphMode('passes'));
175
+ this.resourcesGraphModeButton = createToolbarButton('Resource access', 'Show resource access graph', () => this.setGraphMode('resources'));
176
+ this.groupsButton = createToolbarButton('Groups', 'Toggle diagnostic group projection', () => this.toggleGroups());
177
+ this.collapseGroupsButton = createToolbarButton('Collapse All', 'Collapse every diagnostic group', () => this.collapseAllGroups());
178
+ this.passesGraphModeButton.classList.add('zenfg-inspector-mode-button');
179
+ this.resourcesGraphModeButton.classList.add('zenfg-inspector-mode-button');
180
+
181
+ const modeControls = document.createElement('div');
182
+ modeControls.className = 'zenfg-inspector-graph-mode-controls';
183
+ modeControls.setAttribute('role', 'group');
184
+ modeControls.setAttribute('aria-label', 'Graph mode');
185
+ modeControls.append(this.passesGraphModeButton, this.resourcesGraphModeButton);
186
+ const actionControls = document.createElement('div');
187
+ actionControls.className = 'zenfg-inspector-graph-action-controls';
188
+ actionControls.append(
189
+ this.groupsButton,
190
+ this.collapseGroupsButton,
191
+ createGraphIconButton('fit', 'Fit graph to view', () => fitGraph(this.graphView)),
192
+ );
193
+ this.graphView.toolbar.append(modeControls, graphLegend, actionControls);
194
+
195
+ const callbacks: WorkbenchCallbacks = {
196
+ onSelect: (selection) => this.handleSelect(selection),
197
+ onHover: (selection) => this.handleHover(selection),
198
+ onGroupToggle: (pathKey) => this.toggleGroup(pathKey),
199
+ isGroupExpanded: (pathKey) => this.graphView.expandedGroupPaths.has(pathKey),
200
+ };
201
+ this.workbench = new FrameGraphDebugWorkbench(this.graphView, callbacks, {
202
+ onCapture: () => { void this.captureSnapshot(); },
203
+ onImport: (file) => { void this.importSnapshot(file); },
204
+ onDownload: () => this.downloadSnapshot(),
205
+ onCopyJson: () => { void this.copySnapshotJson(); },
206
+ }, {
207
+ branding,
208
+ idPrefix: this.dom.id,
209
+ });
210
+ this.content.append(this.workbench.root);
211
+ this.body.appendChild(this.content);
212
+ this.dom.append(this.body, this.dropOverlay);
213
+ this.dom.addEventListener('dragenter', this.handleDragEnter);
214
+ this.dom.addEventListener('dragover', this.handleDragOver);
215
+ this.dom.addEventListener('dragleave', this.handleDragLeave);
216
+ this.dom.addEventListener('dragend', this.handleDragEnd);
217
+ this.dom.addEventListener('drop', this.handleDrop);
218
+ this.updateCaptureActions();
219
+ this.updateGraphModeButtonState();
220
+ this.showEmptyState();
221
+ queueMicrotask(() => this.maybeAutoCapture());
222
+ }
223
+
224
+ /**
225
+ * Replaces or removes the live-capture provider.
226
+ *
227
+ * @remarks Installing a provider may immediately begin an asynchronous
228
+ * capture when no snapshot is displayed.
229
+ */
230
+ setCaptureSnapshotProvider(provider: FrameGraphInspectorOptions['captureSnapshot']): void {
231
+ const changed = this.captureSnapshotCallback !== provider;
232
+ this.captureSnapshotCallback = provider;
233
+ if (changed && !this.viewModel) this.initialAutoCaptureAttempted = false;
234
+ if (!provider && !this.viewModel) this.showEmptyState();
235
+ this.updateCaptureActions();
236
+ this.maybeAutoCapture();
237
+ }
238
+
239
+ /**
240
+ * Requests and displays a live snapshot from the configured provider.
241
+ *
242
+ * @remarks Concurrent requests are coalesced. Provider errors, invalid
243
+ * snapshots, and an unavailable provider are displayed in the inspector and
244
+ * do not reject the returned promise.
245
+ */
246
+ async captureSnapshot(): Promise<void> {
247
+ if (this.destroyed || this.capturing) return;
248
+ if (!this.captureSnapshotCallback) {
249
+ this.reportCaptureIssue('Waiting for a FrameGraph capture source.');
250
+ return;
251
+ }
252
+ const revision = ++this.operationRevision;
253
+ this.importing = false;
254
+ this.capturing = true;
255
+ this.statusMessage = undefined;
256
+ this.statusTone = 'neutral';
257
+ if (!this.viewModel) this.workbench.showEmptyState('capturing', 'Capturing the next rendered frame…');
258
+ this.updateCaptureActions();
259
+ try {
260
+ const snapshot = await this.captureSnapshotCallback();
261
+ if (this.destroyed || revision !== this.operationRevision) return;
262
+ if (!snapshot) {
263
+ this.reportCaptureIssue('No snapshot was produced. Capture again when rendering is active.');
264
+ return;
265
+ }
266
+ const decoded = decodeFrameGraphSnapshot(snapshot);
267
+ if (!decoded.ok) {
268
+ this.reportCaptureIssue(`Failed to capture FrameGraph: ${formatIssues(decoded.issues)}`);
269
+ return;
270
+ }
271
+ this.applySnapshot(decoded.snapshot, { kind: 'live', label: 'Live Capture' });
272
+ }
273
+ catch (error) {
274
+ if (!this.destroyed && revision === this.operationRevision) {
275
+ this.reportCaptureIssue(`Failed to capture FrameGraph: ${error instanceof Error ? error.message : String(error)}`);
276
+ }
277
+ }
278
+ finally {
279
+ if (revision === this.operationRevision) this.capturing = false;
280
+ if (!this.destroyed && revision === this.operationRevision) this.updateCaptureActions();
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Validates and synchronously displays a programmatic Snapshot 1.0 value.
286
+ *
287
+ * @throws {@link FrameGraphSnapshotValidationError} if `snapshot` is invalid.
288
+ */
289
+ setSnapshot(snapshot: FrameGraphSnapshot): void {
290
+ if (this.destroyed) return;
291
+ this.operationRevision += 1;
292
+ this.capturing = false;
293
+ this.importing = false;
294
+ const decoded = decodeFrameGraphSnapshot(snapshot);
295
+ if (!decoded.ok) throw new FrameGraphSnapshotValidationError(decoded.issues);
296
+ this.applySnapshot(decoded.snapshot, { kind: 'programmatic', label: 'Programmatic' });
297
+ }
298
+
299
+ /** Returns the currently displayed canonical snapshot, if any. */
300
+ getSnapshot(): FrameGraphSnapshot | undefined {
301
+ return this.protocolSnapshot;
302
+ }
303
+
304
+ /**
305
+ * Reads, migrates, validates, and displays a snapshot JSON file.
306
+ *
307
+ * @remarks Files above `maxImportBytes`, read failures, invalid JSON, and
308
+ * validation failures are displayed in the UI and do not reject the promise.
309
+ */
310
+ async importSnapshot(file: File): Promise<void> {
311
+ if (this.destroyed) return;
312
+ const revision = ++this.operationRevision;
313
+ this.capturing = false;
314
+ this.importing = false;
315
+ if (file.size > this.maxImportBytes) {
316
+ this.reportCaptureIssue(`Import exceeds the ${formatImportLimit(this.maxImportBytes)} limit.`);
317
+ return;
318
+ }
319
+ this.importing = true;
320
+ this.statusMessage = undefined;
321
+ this.statusTone = 'neutral';
322
+ this.updateCaptureActions();
323
+ try {
324
+ const text = await file.text();
325
+ if (this.destroyed || revision !== this.operationRevision) return;
326
+ const decoded = parseFrameGraphSnapshot(text);
327
+ if (!decoded.ok) {
328
+ this.reportCaptureIssue(`Failed to import snapshot: ${formatIssues(decoded.issues)}`);
329
+ return;
330
+ }
331
+ this.applySnapshot(decoded.snapshot, {
332
+ kind: 'file',
333
+ label: file.name || 'Imported JSON',
334
+ });
335
+ const sourceFormat = decoded.snapshot.capture.migration?.sourceFormat;
336
+ if (sourceFormat) {
337
+ const sourceLabel = sourceFormat === 'legacy-v0' ? 'Legacy V0' : 'Legacy Candidate V1';
338
+ this.statusMessage = decoded.migrated
339
+ ? `Imported ${sourceLabel} and migrated it to ZenFG Snapshot V1.`
340
+ : `Imported Snapshot V1 with ${sourceLabel} migration provenance.`;
341
+ this.statusTone = 'neutral';
342
+ }
343
+ }
344
+ catch (error) {
345
+ if (!this.destroyed && revision === this.operationRevision) {
346
+ this.reportCaptureIssue(`Failed to import snapshot: ${error instanceof Error ? error.message : String(error)}`);
347
+ }
348
+ }
349
+ finally {
350
+ if (revision === this.operationRevision) this.importing = false;
351
+ if (!this.destroyed && revision === this.operationRevision) this.updateCaptureActions();
352
+ }
353
+ }
354
+
355
+ /** Downloads the displayed snapshot as pretty-printed canonical JSON. */
356
+ downloadSnapshot(): void {
357
+ if (this.destroyed || !this.protocolSnapshot) return;
358
+ const json = stringifyFrameGraphSnapshot(this.protocolSnapshot, { pretty: true });
359
+ const url = URL.createObjectURL(new Blob([json], { type: 'application/json;charset=utf-8' }));
360
+ const anchor = document.createElement('a');
361
+ anchor.href = url;
362
+ anchor.download = `frame-graph-${this.protocolSnapshot.capture.frameIndex}.fgsnapshot.json`;
363
+ anchor.click();
364
+ URL.revokeObjectURL(url);
365
+ }
366
+
367
+ /**
368
+ * Copies the displayed snapshot as pretty-printed canonical JSON.
369
+ *
370
+ * @remarks Clipboard failures are shown in the UI and do not reject the
371
+ * returned promise. A legacy `document.execCommand` fallback is used when the
372
+ * async Clipboard API is unavailable.
373
+ */
374
+ async copySnapshotJson(): Promise<void> {
375
+ if (this.destroyed || this.copying || !this.protocolSnapshot) return;
376
+ const snapshot = this.protocolSnapshot;
377
+ const revision = this.operationRevision;
378
+ this.copying = true;
379
+ this.copied = false;
380
+ this.statusMessage = undefined;
381
+ this.statusTone = 'neutral';
382
+ this.updateCaptureActions();
383
+ try {
384
+ await writeTextToClipboard(stringifyFrameGraphSnapshot(snapshot, { pretty: true }));
385
+ if (this.destroyed || revision !== this.operationRevision) return;
386
+ this.copied = true;
387
+ if (this.copyFeedbackTimeout !== undefined) window.clearTimeout(this.copyFeedbackTimeout);
388
+ this.copyFeedbackTimeout = window.setTimeout(() => {
389
+ this.copied = false;
390
+ this.copyFeedbackTimeout = undefined;
391
+ this.updateCaptureActions();
392
+ }, 1200);
393
+ }
394
+ catch (error) {
395
+ if (!this.destroyed && revision === this.operationRevision) {
396
+ this.statusMessage = `Failed to copy snapshot: ${error instanceof Error ? error.message : String(error)}`;
397
+ }
398
+ console.warn('Failed to copy FrameGraph Snapshot.', error);
399
+ }
400
+ finally {
401
+ this.copying = false;
402
+ if (!this.destroyed) this.updateCaptureActions();
403
+ }
404
+ }
405
+
406
+ private applySnapshot(snapshot: FrameGraphSnapshot, source: FrameGraphDebugSnapshotSource): void {
407
+ const viewModel = createDebugViewModel(snapshot, source);
408
+ this.copied = false;
409
+ if (this.copyFeedbackTimeout !== undefined) {
410
+ window.clearTimeout(this.copyFeedbackTimeout);
411
+ this.copyFeedbackTimeout = undefined;
412
+ }
413
+ this.protocolSnapshot = snapshot;
414
+ this.viewModel = viewModel;
415
+ this.graphView.fitOnNextRender = true;
416
+ this.hovered = undefined;
417
+ this.initialAutoCaptureAttempted = true;
418
+ this.statusMessage = undefined;
419
+ this.statusTone = 'neutral';
420
+ if (!this.selected || !selectionExists(viewModel, this.selected)) {
421
+ this.selected = viewModel.nodes[0]
422
+ ? { kind: 'node', id: viewModel.nodes[0].id }
423
+ : undefined;
424
+ }
425
+ this.workbench.setSnapshot(viewModel, this.selected);
426
+ this.updateCaptureActions();
427
+ this.updateGraphModeButtonState();
428
+ }
429
+
430
+ /**
431
+ * Idempotently cancels pending UI results, releases graph resources, and
432
+ * removes the inspector workbench. Do not reuse the controller afterward.
433
+ */
434
+ destroy(): void {
435
+ if (this.destroyed) return;
436
+ this.destroyed = true;
437
+ this.operationRevision += 1;
438
+ if (this.copyFeedbackTimeout !== undefined) window.clearTimeout(this.copyFeedbackTimeout);
439
+ this.clearDropState();
440
+ this.dom.removeEventListener('dragenter', this.handleDragEnter);
441
+ this.dom.removeEventListener('dragover', this.handleDragOver);
442
+ this.dom.removeEventListener('dragleave', this.handleDragLeave);
443
+ this.dom.removeEventListener('dragend', this.handleDragEnd);
444
+ this.dom.removeEventListener('drop', this.handleDrop);
445
+ destroyGraph(this.graphView);
446
+ this.dom.remove();
447
+ }
448
+
449
+ private updateCaptureActions(): void {
450
+ this.workbench.setSnapshotActionState({
451
+ providerAvailable: Boolean(this.captureSnapshotCallback),
452
+ hasCapture: Boolean(this.viewModel),
453
+ capturing: this.capturing,
454
+ importing: this.importing,
455
+ copying: this.copying,
456
+ copied: this.copied,
457
+ message: this.viewModel ? this.statusMessage : undefined,
458
+ messageTone: this.statusTone,
459
+ });
460
+ }
461
+
462
+ private maybeAutoCapture(): void {
463
+ if (
464
+ this.destroyed
465
+ || this.viewModel
466
+ || this.initialAutoCaptureAttempted
467
+ || this.capturing
468
+ || this.importing
469
+ || !this.captureSnapshotCallback
470
+ ) return;
471
+ this.initialAutoCaptureAttempted = true;
472
+ void this.captureSnapshot();
473
+ }
474
+ private handleSelect(selection: Selection): void {
475
+ this.selected = selection;
476
+ this.workbench.setSelection(selection);
477
+ }
478
+
479
+ private handleHover(selection: Selection | undefined): void {
480
+ if (!this.viewModel || sameSelection(this.hovered, selection)) return;
481
+ this.hovered = selection;
482
+ this.workbench.setHovered(selection);
483
+ }
484
+
485
+ private setGraphMode(mode: GraphViewState['graphMode']): void {
486
+ if (this.graphView.graphMode === mode) return;
487
+ this.graphView.graphMode = mode;
488
+ this.graphView.fitOnNextRender = true;
489
+ this.updateGraphModeButtonState();
490
+ this.workbench.refreshGraphStructure();
491
+ }
492
+
493
+ private updateGraphModeButtonState(): void {
494
+ const passesActive = this.graphView.graphMode === 'passes';
495
+ const hasGroups = (this.viewModel?.debugGroups.length ?? 0) > 0;
496
+ this.passesGraphModeButton.classList.toggle('active', passesActive);
497
+ this.resourcesGraphModeButton.classList.toggle('active', !passesActive);
498
+ this.passesGraphModeButton.setAttribute('aria-pressed', passesActive ? 'true' : 'false');
499
+ this.resourcesGraphModeButton.setAttribute('aria-pressed', passesActive ? 'false' : 'true');
500
+ this.groupsButton.disabled = !passesActive || !hasGroups;
501
+ this.groupsButton.classList.toggle('active', passesActive && hasGroups && this.graphView.groupsEnabled);
502
+ this.groupsButton.setAttribute('aria-pressed', passesActive && hasGroups && this.graphView.groupsEnabled ? 'true' : 'false');
503
+ const hasExpanded = this.viewModel?.debugGroups.some((group) => this.graphView.expandedGroupPaths.has(group.pathKey)) ?? false;
504
+ this.collapseGroupsButton.disabled = !passesActive || !hasGroups || !this.graphView.groupsEnabled || !hasExpanded;
505
+ }
506
+
507
+ private toggleGroups(): void {
508
+ if (this.graphView.graphMode !== 'passes' || !this.viewModel?.debugGroups.length) return;
509
+ this.graphView.groupsEnabled = !this.graphView.groupsEnabled;
510
+ this.graphView.fitOnNextRender = true;
511
+ this.updateGraphModeButtonState();
512
+ this.workbench.refreshGraphStructure();
513
+ }
514
+
515
+ private toggleGroup(pathKey: string): void {
516
+ if (this.graphView.expandedGroupPaths.has(pathKey)) this.graphView.expandedGroupPaths.delete(pathKey);
517
+ else this.graphView.expandedGroupPaths.add(pathKey);
518
+ this.graphView.anchorElementIdOnNextRender = graphGroupElementId(pathKey);
519
+ this.updateGraphModeButtonState();
520
+ this.workbench.refreshGraphStructure();
521
+ }
522
+
523
+ private collapseAllGroups(): void {
524
+ if (this.graphView.expandedGroupPaths.size === 0) return;
525
+ this.graphView.expandedGroupPaths.clear();
526
+ this.graphView.fitOnNextRender = true;
527
+ this.updateGraphModeButtonState();
528
+ this.workbench.refreshGraphStructure();
529
+ }
530
+
531
+ private showEmptyState(): void {
532
+ const providerAvailable = Boolean(this.captureSnapshotCallback);
533
+ this.workbench.showEmptyState(
534
+ 'empty',
535
+ providerAvailable
536
+ ? 'No capture yet. Use Capture to request the next rendered frame.'
537
+ : 'Drop a ZenFG Snapshot here or choose Import.',
538
+ providerAvailable ? undefined : 'Files are processed locally in your browser.',
539
+ );
540
+ }
541
+
542
+ private setDropActive(active: boolean): void {
543
+ if (this.destroyed) return;
544
+ this.dropOverlay.hidden = !active;
545
+ this.dom.classList.toggle('drop-active', active);
546
+ }
547
+
548
+ private clearDropState(): void {
549
+ this.dragDepth = 0;
550
+ this.dropOverlay.hidden = true;
551
+ this.dom.classList.remove('drop-active');
552
+ }
553
+
554
+ private reportCaptureIssue(message: string): void {
555
+ this.statusMessage = message;
556
+ this.statusTone = 'error';
557
+ if (!this.viewModel) this.workbench.showEmptyState('error', message);
558
+ this.updateCaptureActions();
559
+ }
560
+ }
561
+
562
+ /**
563
+ * Creates an inspector, appends its root element to `host`, and returns its
564
+ * lifecycle controller.
565
+ *
566
+ * @throws If inspector construction fails; see {@link FrameGraphInspector}.
567
+ */
568
+ export function mountFrameGraphInspector(host: HTMLElement, options?: FrameGraphInspectorOptions): FrameGraphInspector {
569
+ const inspector = new FrameGraphInspector(options);
570
+ host.appendChild(inspector.dom);
571
+ return inspector;
572
+ }
573
+
574
+ function createGraphIconButton(icon: 'fit', title: string, onClick: () => void): HTMLButtonElement {
575
+ const button = createToolbarButton('', title, onClick);
576
+ button.classList.add('zenfg-inspector-icon-button');
577
+ button.appendChild(createPanelIcon(icon));
578
+ return button;
579
+ }
580
+
581
+ async function writeTextToClipboard(text: string): Promise<void> {
582
+ if (navigator.clipboard?.writeText) {
583
+ await navigator.clipboard.writeText(text);
584
+ return;
585
+ }
586
+ const textArea = document.createElement('textarea');
587
+ textArea.value = text;
588
+ textArea.setAttribute('readonly', 'true');
589
+ textArea.style.position = 'fixed';
590
+ textArea.style.left = '-9999px';
591
+ textArea.style.top = '0';
592
+ document.body.appendChild(textArea);
593
+ textArea.select();
594
+ try {
595
+ if (!document.execCommand('copy')) throw new Error('document.execCommand("copy") returned false.');
596
+ }
597
+ finally {
598
+ textArea.remove();
599
+ }
600
+ }
601
+
602
+ function normalizeMaxImportBytes(value: number | undefined): number {
603
+ return normalizeLimit(value, DEFAULT_MAX_IMPORT_BYTES, 'maxImportBytes');
604
+ }
605
+
606
+ function normalizeLimit(value: number | undefined, fallback: number, name: string): number {
607
+ if (value === undefined) return fallback;
608
+ if (!Number.isSafeInteger(value) || value < 0) {
609
+ throw new Error(`${name} must be a non-negative safe integer.`);
610
+ }
611
+ return value;
612
+ }
613
+
614
+ function formatImportLimit(bytes: number): string {
615
+ return bytes >= 1024 * 1024 && bytes % (1024 * 1024) === 0
616
+ ? `${bytes / (1024 * 1024)} MiB`
617
+ : `${bytes} bytes`;
618
+ }
619
+
620
+ function formatIssues(issues: readonly { readonly path: string; readonly message: string }[]): string {
621
+ return issues.slice(0, 3).map((issue) => `${issue.path || '/'}: ${issue.message}`).join('; ');
622
+ }
623
+
624
+ function isFileDrag(event: DragEvent): boolean {
625
+ return Array.from(event.dataTransfer?.types ?? []).includes('Files');
626
+ }
627
+
628
+ let nextInspectorId = 0;
629
+ function createInspectorId(): string {
630
+ nextInspectorId += 1;
631
+ return `zenfg-inspector-${nextInspectorId}`;
632
+ }