@zenfg/inspector 0.1.0-beta.3 → 0.1.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 (172) hide show
  1. package/GUIDE.md +182 -0
  2. package/README.md +61 -89
  3. package/THEMING.md +124 -0
  4. package/dist/FrameGraphInspector.d.ts +23 -4
  5. package/dist/FrameGraphInspector.d.ts.map +1 -1
  6. package/dist/FrameGraphInspector.js +137 -29
  7. package/dist/FrameGraphInspector.js.map +1 -1
  8. package/dist/capture.d.ts +5 -0
  9. package/dist/capture.d.ts.map +1 -0
  10. package/dist/capture.js +2 -0
  11. package/dist/capture.js.map +1 -0
  12. package/dist/debugCaptureModel.d.ts +19 -0
  13. package/dist/debugCaptureModel.d.ts.map +1 -1
  14. package/dist/debugCaptureModel.js +36 -2
  15. package/dist/debugCaptureModel.js.map +1 -1
  16. package/dist/index.d.ts +2 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/panelAliasAnalysis.d.ts +1 -0
  20. package/dist/panelAliasAnalysis.d.ts.map +1 -1
  21. package/dist/panelAliasAnalysis.js +23 -54
  22. package/dist/panelAliasAnalysis.js.map +1 -1
  23. package/dist/panelCytoscapeGraphRenderer.d.ts +15 -2
  24. package/dist/panelCytoscapeGraphRenderer.d.ts.map +1 -1
  25. package/dist/panelCytoscapeGraphRenderer.js +145 -23
  26. package/dist/panelCytoscapeGraphRenderer.js.map +1 -1
  27. package/dist/panelDetailLayout.d.ts +27 -0
  28. package/dist/panelDetailLayout.d.ts.map +1 -0
  29. package/dist/panelDetailLayout.js +174 -0
  30. package/dist/panelDetailLayout.js.map +1 -0
  31. package/dist/panelDetailStyles.d.ts +2 -0
  32. package/dist/panelDetailStyles.d.ts.map +1 -0
  33. package/dist/panelDetailStyles.js +57 -0
  34. package/dist/panelDetailStyles.js.map +1 -0
  35. package/dist/panelDiagnosticsView.d.ts +10 -1
  36. package/dist/panelDiagnosticsView.d.ts.map +1 -1
  37. package/dist/panelDiagnosticsView.js +166 -57
  38. package/dist/panelDiagnosticsView.js.map +1 -1
  39. package/dist/panelDomHelpers.d.ts.map +1 -1
  40. package/dist/panelDomHelpers.js +6 -5
  41. package/dist/panelDomHelpers.js.map +1 -1
  42. package/dist/panelGraphLayout.d.ts +3 -2
  43. package/dist/panelGraphLayout.d.ts.map +1 -1
  44. package/dist/panelGraphLayout.js +6 -5
  45. package/dist/panelGraphLayout.js.map +1 -1
  46. package/dist/panelGraphRenderer.d.ts +10 -0
  47. package/dist/panelGraphRenderer.d.ts.map +1 -1
  48. package/dist/panelGraphRenderer.js.map +1 -1
  49. package/dist/panelGraphScene.d.ts +1 -0
  50. package/dist/panelGraphScene.d.ts.map +1 -1
  51. package/dist/panelGraphScene.js +69 -32
  52. package/dist/panelGraphScene.js.map +1 -1
  53. package/dist/panelGraphSearch.d.ts +16 -0
  54. package/dist/panelGraphSearch.d.ts.map +1 -0
  55. package/dist/panelGraphSearch.js +93 -0
  56. package/dist/panelGraphSearch.js.map +1 -0
  57. package/dist/panelGraphView.d.ts +82 -0
  58. package/dist/panelGraphView.d.ts.map +1 -1
  59. package/dist/panelGraphView.js +15 -4
  60. package/dist/panelGraphView.js.map +1 -1
  61. package/dist/panelGraphVisuals.d.ts +14 -6
  62. package/dist/panelGraphVisuals.d.ts.map +1 -1
  63. package/dist/panelGraphVisuals.js +126 -51
  64. package/dist/panelGraphVisuals.js.map +1 -1
  65. package/dist/panelInspectorView.d.ts +8 -0
  66. package/dist/panelInspectorView.d.ts.map +1 -1
  67. package/dist/panelInspectorView.js +242 -48
  68. package/dist/panelInspectorView.js.map +1 -1
  69. package/dist/panelListStyles.d.ts +3 -0
  70. package/dist/panelListStyles.d.ts.map +1 -0
  71. package/dist/panelListStyles.js +155 -0
  72. package/dist/panelListStyles.js.map +1 -0
  73. package/dist/panelMemoryStyles.d.ts +3 -0
  74. package/dist/panelMemoryStyles.d.ts.map +1 -0
  75. package/dist/panelMemoryStyles.js +42 -0
  76. package/dist/panelMemoryStyles.js.map +1 -0
  77. package/dist/panelMemoryView.d.ts +13 -1
  78. package/dist/panelMemoryView.d.ts.map +1 -1
  79. package/dist/panelMemoryView.js +161 -41
  80. package/dist/panelMemoryView.js.map +1 -1
  81. package/dist/panelPassesView.d.ts +15 -0
  82. package/dist/panelPassesView.d.ts.map +1 -1
  83. package/dist/panelPassesView.js +202 -72
  84. package/dist/panelPassesView.js.map +1 -1
  85. package/dist/panelRawView.d.ts +15 -0
  86. package/dist/panelRawView.d.ts.map +1 -0
  87. package/dist/panelRawView.js +105 -0
  88. package/dist/panelRawView.js.map +1 -0
  89. package/dist/panelResourcesView.d.ts +8 -0
  90. package/dist/panelResourcesView.d.ts.map +1 -1
  91. package/dist/panelResourcesView.js +62 -31
  92. package/dist/panelResourcesView.js.map +1 -1
  93. package/dist/panelSelection.d.ts +9 -1
  94. package/dist/panelSelection.d.ts.map +1 -1
  95. package/dist/panelSelection.js +34 -4
  96. package/dist/panelSelection.js.map +1 -1
  97. package/dist/panelTheme.d.ts +13 -0
  98. package/dist/panelTheme.d.ts.map +1 -0
  99. package/dist/panelTheme.js +104 -0
  100. package/dist/panelTheme.js.map +1 -0
  101. package/dist/panelTypes.d.ts +10 -1
  102. package/dist/panelTypes.d.ts.map +1 -1
  103. package/dist/panelTypes.js.map +1 -1
  104. package/dist/panelVisualTheme.d.ts +147 -50
  105. package/dist/panelVisualTheme.d.ts.map +1 -1
  106. package/dist/panelVisualTheme.js +34 -44
  107. package/dist/panelVisualTheme.js.map +1 -1
  108. package/dist/panelWorkbenchHelpers.d.ts +12 -1
  109. package/dist/panelWorkbenchHelpers.d.ts.map +1 -1
  110. package/dist/panelWorkbenchHelpers.js +62 -1
  111. package/dist/panelWorkbenchHelpers.js.map +1 -1
  112. package/dist/panelWorkbenchStyles.d.ts +2 -0
  113. package/dist/panelWorkbenchStyles.d.ts.map +1 -0
  114. package/dist/panelWorkbenchStyles.js +101 -0
  115. package/dist/panelWorkbenchStyles.js.map +1 -0
  116. package/dist/panelWorkbenchView.d.ts +16 -0
  117. package/dist/panelWorkbenchView.d.ts.map +1 -1
  118. package/dist/panelWorkbenchView.js +228 -50
  119. package/dist/panelWorkbenchView.js.map +1 -1
  120. package/dist/query.js.map +1 -1
  121. package/dist/styles.d.ts.map +1 -1
  122. package/dist/styles.js +147 -195
  123. package/dist/styles.js.map +1 -1
  124. package/dist/theme.d.ts +22 -0
  125. package/dist/theme.d.ts.map +1 -0
  126. package/dist/theme.js +10 -0
  127. package/dist/theme.js.map +1 -0
  128. package/dist/themeDefinitions.d.ts +251 -0
  129. package/dist/themeDefinitions.d.ts.map +1 -0
  130. package/dist/themeDefinitions.js +96 -0
  131. package/dist/themeDefinitions.js.map +1 -0
  132. package/dist/themePalette.d.ts +66 -0
  133. package/dist/themePalette.d.ts.map +1 -0
  134. package/dist/themePalette.js +37 -0
  135. package/dist/themePalette.js.map +1 -0
  136. package/dist/themes.css +165 -0
  137. package/package.json +44 -10
  138. package/src/FrameGraphInspector.ts +130 -31
  139. package/src/capture.ts +4 -0
  140. package/src/debugCaptureModel.ts +53 -2
  141. package/src/index.ts +6 -2
  142. package/src/panelAliasAnalysis.ts +24 -70
  143. package/src/panelCytoscapeGraphRenderer.ts +140 -23
  144. package/src/panelDetailLayout.ts +155 -0
  145. package/src/panelDetailStyles.ts +56 -0
  146. package/src/panelDiagnosticsView.ts +166 -85
  147. package/src/panelDomHelpers.ts +4 -5
  148. package/src/panelGraphLayout.ts +6 -5
  149. package/src/panelGraphRenderer.ts +7 -0
  150. package/src/panelGraphScene.ts +68 -30
  151. package/src/panelGraphSearch.ts +96 -0
  152. package/src/panelGraphView.ts +14 -4
  153. package/src/panelGraphVisuals.ts +122 -52
  154. package/src/panelInspectorView.ts +227 -48
  155. package/src/panelListStyles.ts +154 -0
  156. package/src/panelMemoryStyles.ts +41 -0
  157. package/src/panelMemoryView.ts +178 -53
  158. package/src/panelPassesView.ts +190 -137
  159. package/src/panelRawView.ts +99 -0
  160. package/src/panelResourcesView.ts +69 -56
  161. package/src/panelSelection.ts +37 -5
  162. package/src/panelTheme.ts +85 -0
  163. package/src/panelTypes.ts +7 -1
  164. package/src/panelVisualTheme.ts +38 -49
  165. package/src/panelWorkbenchHelpers.ts +60 -2
  166. package/src/panelWorkbenchStyles.ts +100 -0
  167. package/src/panelWorkbenchView.ts +201 -52
  168. package/src/query.ts +11 -11
  169. package/src/styles.ts +148 -195
  170. package/src/theme.ts +24 -0
  171. package/src/themeDefinitions.ts +103 -0
  172. package/src/themePalette.ts +44 -0
@@ -1,3 +1,6 @@
1
+ import type { FrameGraphCaptureRequest } from './capture.ts';
2
+ import type { InspectorTheme } from './theme.ts';
3
+ import { InspectorThemeController, resolveGraphTheme } from './panelTheme.ts';
1
4
  import {
2
5
  FrameGraphSnapshotValidationError,
3
6
  decodeFrameGraphSnapshot,
@@ -11,20 +14,23 @@ import {
11
14
  type FrameGraphDebugViewModel,
12
15
  } from './debugCaptureModel.ts';
13
16
  import { createToolbarButton } from './panelDomHelpers.ts';
14
- import { createPanelIcon } from './panelIcons.ts';
15
17
  import {
16
18
  destroyGraph,
17
19
  fitGraph,
20
+ resolveGraphScene,
21
+ renderGraphLegend,
18
22
  } from './panelGraphView.ts';
19
- import { graphGroupElementId } from './panelGraphScene.ts';
20
- import { selectionExists } from './panelSelection.ts';
21
- import type { GraphViewState, Selection } from './panelTypes.ts';
23
+ import { graphGroupElementId, selectionKey } from './panelGraphScene.ts';
24
+ import { resolveNodeSelection, selectionExists } from './panelSelection.ts';
25
+ import type { GraphViewState, Selection, WorkbenchTab } from './panelTypes.ts';
22
26
  import { ensureFrameGraphInspectorStyles } from './styles.ts';
23
27
  import { sameSelection, type WorkbenchCallbacks } from './panelWorkbenchHelpers.ts';
24
28
  import { FrameGraphDebugWorkbench } from './panelWorkbenchView.ts';
25
29
 
26
30
  /** Construction and safety limits for an embedded {@link FrameGraphInspector}. */
27
31
  export type FrameGraphInspectorOptions = {
32
+ /** Optional CSS theme. Without one, host variables inherit over the Storm defaults. */
33
+ theme?: InspectorTheme;
28
34
  /**
29
35
  * Product label shown in the workbench command bar. Pass `false` to hide
30
36
  * visible branding while retaining the inspector's accessible name.
@@ -36,7 +42,7 @@ export type FrameGraphInspectorOptions = {
36
42
  * Produces a live snapshot when capture is requested. The inspector awaits
37
43
  * promises and displays thrown or rejected errors in its status area.
38
44
  */
39
- captureSnapshot?: () => FrameGraphSnapshot | undefined | Promise<FrameGraphSnapshot | undefined>;
45
+ captureSnapshot?: (request: FrameGraphCaptureRequest) => FrameGraphSnapshot | undefined | Promise<FrameGraphSnapshot | undefined>;
40
46
  /**
41
47
  * Maximum accepted import size in bytes.
42
48
  *
@@ -74,11 +80,13 @@ const DEFAULT_BRANDING = 'ZenFG Inspector';
74
80
  export class FrameGraphInspector {
75
81
  /** Root element owned by this inspector instance. */
76
82
  readonly dom = document.createElement('section');
83
+ private readonly themeController = new InspectorThemeController(this.dom);
77
84
 
78
85
  private readonly body = document.createElement('div');
79
86
  private readonly content = document.createElement('div');
80
87
  private readonly dropOverlay = document.createElement('div');
81
88
  private readonly groupsButton: HTMLButtonElement;
89
+ private readonly declarationsButton: HTMLButtonElement;
82
90
  private readonly collapseGroupsButton: HTMLButtonElement;
83
91
  private readonly graphView: GraphViewState;
84
92
  private readonly workbench: FrameGraphDebugWorkbench;
@@ -99,6 +107,7 @@ export class FrameGraphInspector {
99
107
  private copyFeedbackTimeout: number | undefined;
100
108
  private readonly maxImportBytes: number;
101
109
  private dragDepth = 0;
110
+ private revealRevision = 0;
102
111
  private readonly handleDragEnter = (event: DragEvent): void => {
103
112
  if (!isFileDrag(event)) return;
104
113
  event.preventDefault();
@@ -158,8 +167,10 @@ export class FrameGraphInspector {
158
167
  host: document.createElement('div'),
159
168
  toolbar: document.createElement('div'),
160
169
  legend: graphLegend,
170
+ refreshTheme: () => this.refreshTheme(),
161
171
  layoutElementBudget: normalizeLimit(options.maxGraphElements, DEFAULT_MAX_GRAPH_ELEMENTS, 'maxGraphElements'),
162
172
  groupsEnabled: true,
173
+ showResourceDeclarations: true,
163
174
  expandedGroupPaths: new Set(),
164
175
  fitOnNextRender: true,
165
176
  };
@@ -168,23 +179,27 @@ export class FrameGraphInspector {
168
179
  this.graphView.toolbar.setAttribute('role', 'toolbar');
169
180
  this.graphView.toolbar.setAttribute('aria-label', 'Frame graph view controls');
170
181
 
182
+ this.declarationsButton = createToolbarButton('Declarations', 'Show resource declaration nodes', () => this.toggleDeclarations());
171
183
  this.groupsButton = createToolbarButton('Groups', 'Toggle diagnostic group projection', () => this.toggleGroups());
172
184
  this.collapseGroupsButton = createToolbarButton('Collapse All', 'Collapse every diagnostic group', () => this.collapseAllGroups());
173
185
 
174
186
  const actionControls = document.createElement('div');
175
187
  actionControls.className = 'zenfg-inspector-graph-action-controls';
176
188
  actionControls.append(
189
+ this.declarationsButton,
177
190
  this.groupsButton,
178
191
  this.collapseGroupsButton,
179
- createGraphIconButton('fit', 'Fit graph to view', () => fitGraph(this.graphView)),
192
+ createToolbarButton('Fit', 'Fit graph to view', () => fitGraph(this.graphView)),
180
193
  );
181
- this.graphView.toolbar.append(graphLegend, actionControls);
194
+ this.graphView.toolbar.append(actionControls);
182
195
 
183
196
  const callbacks: WorkbenchCallbacks = {
184
197
  onSelect: (selection) => this.handleSelect(selection),
185
198
  onHover: (selection) => this.handleHover(selection),
186
199
  onGroupToggle: (pathKey) => this.toggleGroup(pathKey),
187
200
  isGroupExpanded: (pathKey) => this.graphView.expandedGroupPaths.has(pathKey),
201
+ onReveal: (selection, tab) => this.handleReveal(selection, tab),
202
+ onNavigate: (tab, filter) => this.workbench.navigate(tab, filter),
188
203
  };
189
204
  this.workbench = new FrameGraphDebugWorkbench(this.graphView, callbacks, {
190
205
  onCapture: () => { void this.captureSnapshot(); },
@@ -206,20 +221,47 @@ export class FrameGraphInspector {
206
221
  this.updateCaptureActions();
207
222
  this.updateGraphControls();
208
223
  this.showEmptyState();
209
- queueMicrotask(() => this.maybeAutoCapture());
224
+ if (options.theme) this.setTheme(options.theme);
225
+ queueMicrotask(() => { if (!this.destroyed) { this.refreshTheme(); this.maybeAutoCapture(); } });
210
226
  }
211
227
 
228
+ /** Replace API-applied variables on this instance, then synchronize graph styles.
229
+ * Pass null to restore host CSS and defaults. Does not change capture or interaction state.
230
+ */
231
+ setTheme(theme: InspectorTheme | null): void {
232
+ if (this.destroyed) return;
233
+ this.themeController.apply(theme);
234
+ this.refreshTheme();
235
+ }
236
+
237
+ /** Re-read final CSS values after external CSS changes and refresh graph and legend.
238
+ * Does not write theme variables, recapture, fit, or rebuild the component.
239
+ */
240
+ refreshTheme(): void {
241
+ if (this.destroyed) return;
242
+ const theme = resolveGraphTheme(this.dom);
243
+ this.graphView.theme = theme;
244
+ this.graphView.renderer?.setTheme?.(theme);
245
+ if (this.viewModel) renderGraphLegend(this.graphView.legend, this.viewModel, theme, this.graphView.showResourceDeclarations);
246
+ }
247
+
212
248
  /**
213
249
  * Replaces or removes the live-capture provider.
214
250
  *
215
251
  * @remarks Installing a provider may immediately begin an asynchronous
216
- * capture when no snapshot is displayed.
252
+ * capture when no snapshot is displayed. Replacing or removing a provider
253
+ * invalidates its pending capture results without cancelling the provider
254
+ * promise. An already displayed snapshot and any pending import are preserved.
217
255
  */
218
256
  setCaptureSnapshotProvider(provider: FrameGraphInspectorOptions['captureSnapshot']): void {
219
- const changed = this.captureSnapshotCallback !== provider;
257
+ if (this.destroyed || this.captureSnapshotCallback === provider) return;
258
+ if (this.capturing) {
259
+ this.operationRevision += 1;
260
+ this.capturing = false;
261
+ }
220
262
  this.captureSnapshotCallback = provider;
221
- if (changed && !this.viewModel) this.initialAutoCaptureAttempted = false;
222
- if (!provider && !this.viewModel) this.showEmptyState();
263
+ if (!this.viewModel) this.initialAutoCaptureAttempted = false;
264
+ if (!provider && !this.viewModel && !this.importing) this.showEmptyState();
223
265
  this.updateCaptureActions();
224
266
  this.maybeAutoCapture();
225
267
  }
@@ -245,7 +287,8 @@ export class FrameGraphInspector {
245
287
  if (!this.viewModel) this.workbench.showEmptyState('capturing', 'Capturing the next rendered frame…');
246
288
  this.updateCaptureActions();
247
289
  try {
248
- const snapshot = await this.captureSnapshotCallback();
290
+ const request: FrameGraphCaptureRequest = { timing: 'both' };
291
+ const snapshot = await this.captureSnapshotCallback(request);
249
292
  if (this.destroyed || revision !== this.operationRevision) return;
250
293
  if (!snapshot) {
251
294
  this.reportCaptureIssue('No snapshot was produced. Capture again when rendering is active.');
@@ -270,9 +313,9 @@ export class FrameGraphInspector {
270
313
  }
271
314
 
272
315
  /**
273
- * Validates and synchronously displays a programmatic Snapshot 1.1 value.
316
+ * Validates and synchronously displays a programmatic Snapshot 1.2 value.
274
317
  *
275
- * @throws {@link FrameGraphSnapshotValidationError} if `snapshot` is invalid.
318
+ * @throws {@link @zenfg/snapshot!index.FrameGraphSnapshotValidationError | FrameGraphSnapshotValidationError} if `snapshot` is invalid.
276
319
  */
277
320
  setSnapshot(snapshot: FrameGraphSnapshot): void {
278
321
  if (this.destroyed) return;
@@ -322,10 +365,17 @@ export class FrameGraphInspector {
322
365
  });
323
366
  const sourceFormat = decoded.snapshot.capture.migration?.sourceFormat;
324
367
  if (sourceFormat) {
325
- const sourceLabel = sourceFormat === 'legacy-v0' ? 'Legacy V0' : 'Legacy Candidate V1';
368
+ // The current import format can differ from retained historical provenance.
369
+ const sourceLabel = {
370
+ 'legacy-v0': 'Legacy V0',
371
+ 'legacy-candidate-v1': 'Legacy Candidate V1',
372
+ 'snapshot-v1.1': 'Snapshot 1.1',
373
+ }[decoded.migrated && decoded.source !== 'v1' ? decoded.source : sourceFormat];
374
+ const version = decoded.snapshot.version;
375
+ const targetLabel = `Snapshot ${version.major}.${version.minor}`;
326
376
  this.statusMessage = decoded.migrated
327
- ? `Imported ${sourceLabel} and migrated it to ZenFG Snapshot V1.`
328
- : `Imported Snapshot V1 with ${sourceLabel} migration provenance.`;
377
+ ? `Imported ${sourceLabel} and migrated it to ZenFG ${targetLabel}.`
378
+ : `Imported ${targetLabel} with ${sourceLabel} migration provenance.`;
329
379
  this.statusTone = 'neutral';
330
380
  }
331
381
  }
@@ -401,15 +451,15 @@ export class FrameGraphInspector {
401
451
  this.protocolSnapshot = snapshot;
402
452
  this.viewModel = viewModel;
403
453
  this.graphView.fitOnNextRender = true;
454
+ this.graphView.captureRevision = (this.graphView.captureRevision ?? 0) + 1;
455
+ this.graphView.revealOnNextRender = undefined;
456
+ this.graphView.renderer?.cancelReveal?.();
404
457
  this.hovered = undefined;
405
458
  this.initialAutoCaptureAttempted = true;
406
459
  this.statusMessage = undefined;
407
460
  this.statusTone = 'neutral';
408
- if (!this.selected || !selectionExists(viewModel, this.selected)) {
409
- this.selected = viewModel.nodes[0]
410
- ? { kind: 'node', id: viewModel.nodes[0].id }
411
- : undefined;
412
- }
461
+ if (this.selected?.kind === 'node' || this.selected?.kind === 'culled') this.selected = resolveNodeSelection(viewModel, this.selected.id);
462
+ else if (this.selected && !selectionExists(viewModel, this.selected)) this.selected = undefined;
413
463
  this.workbench.setSnapshot(viewModel, this.selected);
414
464
  this.updateCaptureActions();
415
465
  this.updateGraphControls();
@@ -430,7 +480,9 @@ export class FrameGraphInspector {
430
480
  this.dom.removeEventListener('dragleave', this.handleDragLeave);
431
481
  this.dom.removeEventListener('dragend', this.handleDragEnd);
432
482
  this.dom.removeEventListener('drop', this.handleDrop);
483
+ this.themeController.destroy();
433
484
  destroyGraph(this.graphView);
485
+ this.workbench.destroy();
434
486
  this.dom.remove();
435
487
  }
436
488
 
@@ -460,10 +512,47 @@ export class FrameGraphInspector {
460
512
  void this.captureSnapshot();
461
513
  }
462
514
  private handleSelect(selection: Selection): void {
515
+ if (!this.viewModel || !selectionExists(this.viewModel, selection)) return;
463
516
  this.selected = selection;
464
517
  this.workbench.setSelection(selection);
465
518
  }
466
519
 
520
+ private handleReveal(selection: Selection, tab: WorkbenchTab): void {
521
+ const snapshot = this.viewModel;
522
+ if (!snapshot || !selectionExists(snapshot, selection)) return;
523
+ if (tab === 'graph') {
524
+ const expanded = new Set(this.graphView.expandedGroupPaths);
525
+ const groupsEnabled = this.graphView.groupsEnabled;
526
+ const showResourceDeclarations = this.graphView.showResourceDeclarations;
527
+ if (selection.kind === 'resource') this.graphView.showResourceDeclarations = true;
528
+ if (selection.kind === 'group') this.graphView.groupsEnabled = true;
529
+ let groupId: string | undefined;
530
+ if (selection.kind === 'node') groupId = snapshot.nodeById.get(selection.id)?.debugGroupId;
531
+ else if (selection.kind === 'resource') groupId = snapshot.resourceById.get(selection.id)?.debugGroupId;
532
+ else if (selection.kind === 'group') groupId = snapshot.groupByPathKey.get(selection.pathKey)?.parentId;
533
+ if (this.graphView.groupsEnabled && groupId) {
534
+ for (const id of snapshot.groupById.get(groupId)?.ancestorIds ?? []) {
535
+ const group = snapshot.groupById.get(id);
536
+ if (group) this.graphView.expandedGroupPaths.add(group.pathKey);
537
+ }
538
+ }
539
+ const scene = resolveGraphScene(this.graphView, snapshot);
540
+ const availableIds = new Set([...scene.nodes, ...scene.edges].map((entry) => entry.id));
541
+ if (!(scene.interaction.primaryElementIdsBySelection.get(selectionKey(selection))?.some((id) => availableIds.has(id)))) {
542
+ this.graphView.groupsEnabled = groupsEnabled;
543
+ this.graphView.showResourceDeclarations = showResourceDeclarations;
544
+ this.graphView.expandedGroupPaths.clear();
545
+ for (const path of expanded) this.graphView.expandedGroupPaths.add(path);
546
+ this.workbench.showNavigationIssue(selection.kind === 'culled' ? 'Culled passes are not part of Frame Flow.' : 'This object has no representation in Frame Flow.', selection);
547
+ return;
548
+ }
549
+ this.graphView.revealOnNextRender = { selection, revision: ++this.revealRevision };
550
+ this.updateGraphControls();
551
+ }
552
+ this.handleSelect(selection);
553
+ this.workbench.reveal(selection, tab);
554
+ }
555
+
467
556
  private handleHover(selection: Selection | undefined): void {
468
557
  if (!this.viewModel || sameSelection(this.hovered, selection)) return;
469
558
  this.hovered = selection;
@@ -471,14 +560,30 @@ export class FrameGraphInspector {
471
560
  }
472
561
 
473
562
  private updateGraphControls(): void {
563
+ const showDeclarations = this.graphView.showResourceDeclarations ?? true;
564
+ this.declarationsButton.classList.toggle('active', showDeclarations);
565
+ this.declarationsButton.setAttribute('aria-pressed', String(showDeclarations));
474
566
  const hasGroups = (this.viewModel?.debugGroups.length ?? 0) > 0;
475
567
  this.groupsButton.disabled = !hasGroups;
568
+ this.groupsButton.hidden = !hasGroups;
569
+ this.collapseGroupsButton.hidden = !hasGroups;
476
570
  this.groupsButton.classList.toggle('active', hasGroups && this.graphView.groupsEnabled);
477
571
  this.groupsButton.setAttribute('aria-pressed', hasGroups && this.graphView.groupsEnabled ? 'true' : 'false');
478
572
  const hasExpanded = this.viewModel?.debugGroups.some((group) => this.graphView.expandedGroupPaths.has(group.pathKey)) ?? false;
479
573
  this.collapseGroupsButton.disabled = !hasGroups || !this.graphView.groupsEnabled || !hasExpanded;
480
574
  }
481
575
 
576
+ private toggleDeclarations(): void {
577
+ this.handleHover(undefined);
578
+ this.graphView.showResourceDeclarations = !(this.graphView.showResourceDeclarations ?? true);
579
+ this.graphView.renderer?.cancelReveal?.();
580
+ this.graphView.revealOnNextRender = undefined;
581
+ this.graphView.anchorElementIdOnNextRender = undefined;
582
+ this.graphView.fitOnNextRender = true;
583
+ this.updateGraphControls();
584
+ this.workbench.refreshGraphStructure();
585
+ }
586
+
482
587
  private toggleGroups(): void {
483
588
  if (!this.viewModel?.debugGroups.length) return;
484
589
  this.handleHover(undefined);
@@ -546,16 +651,10 @@ export class FrameGraphInspector {
546
651
  export function mountFrameGraphInspector(host: HTMLElement, options?: FrameGraphInspectorOptions): FrameGraphInspector {
547
652
  const inspector = new FrameGraphInspector(options);
548
653
  host.appendChild(inspector.dom);
654
+ inspector.refreshTheme();
549
655
  return inspector;
550
656
  }
551
657
 
552
- function createGraphIconButton(icon: 'fit', title: string, onClick: () => void): HTMLButtonElement {
553
- const button = createToolbarButton('', title, onClick);
554
- button.classList.add('zenfg-inspector-icon-button');
555
- button.appendChild(createPanelIcon(icon));
556
- return button;
557
- }
558
-
559
658
  async function writeTextToClipboard(text: string): Promise<void> {
560
659
  if (navigator.clipboard?.writeText) {
561
660
  await navigator.clipboard.writeText(text);
@@ -596,7 +695,7 @@ function formatImportLimit(bytes: number): string {
596
695
  }
597
696
 
598
697
  function formatIssues(issues: readonly { readonly path: string; readonly message: string }[]): string {
599
- return issues.slice(0, 3).map((issue) => `${issue.path || '/'}: ${issue.message}`).join('; ');
698
+ return issues.map((issue) => `${issue.path || '/'}: ${issue.message}`).join('\n');
600
699
  }
601
700
 
602
701
  function isFileDrag(event: DragEvent): boolean {
package/src/capture.ts ADDED
@@ -0,0 +1,4 @@
1
+ /** Requested timing families for the next live capture; never changes imported data. */
2
+ export type FrameGraphCaptureRequest = {
3
+ readonly timing: 'cpu' | 'gpu' | 'both';
4
+ };
@@ -59,6 +59,7 @@ export type FrameGraphDebugNode = {
59
59
  readonly sideEffect: boolean;
60
60
  readonly debugGroupId?: string;
61
61
  readonly gpuDurationMicros?: number;
62
+ readonly cpuDurationMicros?: number;
62
63
  readonly reads: readonly FrameGraphDebugAccess[];
63
64
  readonly writes: readonly FrameGraphDebugAccess[];
64
65
  };
@@ -121,6 +122,8 @@ export type FrameGraphDebugGroupSummary = {
121
122
  readonly externalSubmissionCount: number;
122
123
  readonly gpuWorkDurationMicros: number;
123
124
  readonly timedNodeCount: number;
125
+ readonly cpuTimedNodeCount: number;
126
+ readonly cpuWorkDurationMicros: number;
124
127
  readonly timingEligibleNodeCount: number;
125
128
  };
126
129
 
@@ -140,14 +143,23 @@ export type FrameGraphDebugGroup = {
140
143
  export type FrameGraphDebugMetrics = {
141
144
  readonly timingEligibleNodeCount: number;
142
145
  readonly timedNodeCount: number;
146
+ readonly cpuTimedNodeCount: number;
147
+ readonly cpuWorkDurationMicros: number;
143
148
  readonly slowestNode?: FrameGraphDebugNode;
144
149
  readonly transientEstimatedByteSize?: number;
145
150
  readonly logicalCapacityBytes?: number;
146
151
  readonly physicalEstimatedBytes?: number;
147
152
  readonly aliasReuseBytes?: number;
148
153
  readonly aliasedAllocationCount: number;
154
+ readonly estimatedCoverage: {
155
+ readonly transient: EstimateCoverage;
156
+ readonly logical: EstimateCoverage;
157
+ readonly physical: EstimateCoverage;
158
+ };
149
159
  };
150
160
 
161
+ export type EstimateCoverage = { readonly known: number; readonly total: number };
162
+
151
163
  export type FrameGraphDebugSnapshotSource = {
152
164
  readonly kind: 'live' | 'file' | 'programmatic';
153
165
  readonly label: string;
@@ -169,6 +181,10 @@ export type FrameGraphDebugViewModel = {
169
181
  readonly executionSegments: readonly FrameGraphDebugExecutionSegment[];
170
182
  readonly textureViewById: ReadonlyMap<string, FrameGraphSnapshot['graph']['textureViews'][number]>;
171
183
  readonly nodeById: ReadonlyMap<string, FrameGraphDebugNode>;
184
+ readonly canonicalNodeById: ReadonlyMap<string, FrameGraphSnapshot['graph']['nodes'][number]>;
185
+ readonly culledById: ReadonlyMap<string, FrameGraphDebugCulledNode>;
186
+ readonly diagnosticsByNodeId: ReadonlyMap<string, readonly FrameGraphSnapshot['diagnostics'][number][]>;
187
+ readonly diagnosticsByResourceId: ReadonlyMap<string, readonly FrameGraphSnapshot['diagnostics'][number][]>;
172
188
  readonly resourceById: ReadonlyMap<string, FrameGraphDebugResource>;
173
189
  readonly groupById: ReadonlyMap<string, FrameGraphDebugGroup>;
174
190
  readonly groupByPathKey: ReadonlyMap<string, FrameGraphDebugGroup>;
@@ -178,6 +194,7 @@ export type FrameGraphDebugViewModel = {
178
194
  readonly segmentByNodeId: ReadonlyMap<string, FrameGraphDebugExecutionSegment>;
179
195
  readonly segmentByIndex: ReadonlyMap<number, FrameGraphDebugExecutionSegment>;
180
196
  readonly metrics: FrameGraphDebugMetrics;
197
+ readonly cpuProfiling: FrameGraphSnapshot['timings']['cpu'];
181
198
  readonly profiling:
182
199
  | {
183
200
  readonly status: 'available';
@@ -216,6 +233,7 @@ export function createDebugViewModel(
216
233
  ? { ...source, migratedFromLegacy: true }
217
234
  : source;
218
235
  const gpuTiming = snapshot.timings.gpu;
236
+ const cpuTimings = new Map(snapshot.timings.cpu.status === 'available' ? snapshot.timings.cpu.nodes.map(node => [node.nodeId, node.durationMicros]) : []);
219
237
  const resourcePool = snapshot.memory.poolReport;
220
238
  const normalizedGroups = normalizeDebugGroups(compilation);
221
239
  const knownGroupIds = new Set(normalizedGroups.map((group) => group.id));
@@ -278,6 +296,7 @@ export function createDebugViewModel(
278
296
  sideEffect: node.sideEffect,
279
297
  debugGroupId: node.groupId,
280
298
  gpuDurationMicros: timings.get(node.id),
299
+ cpuDurationMicros: cpuTimings.get(node.id),
281
300
  reads: nodeAccesses(node.id, 'read'),
282
301
  writes: nodeAccesses(node.id, 'write'),
283
302
  }));
@@ -345,6 +364,18 @@ export function createDebugViewModel(
345
364
  executionSegments,
346
365
  });
347
366
  const nodeById = new Map(nodes.map((node) => [node.id, node]));
367
+ const canonicalNodeById = new Map(compilation.nodes.map((node) => [node.id, node]));
368
+ const culledById = new Map(culledNodes.map((entry) => [entry.node.id, entry]));
369
+ const diagnosticsByNodeId = new Map<string, FrameGraphSnapshot['diagnostics'][number][]>();
370
+ const diagnosticsByResourceId = new Map<string, FrameGraphSnapshot['diagnostics'][number][]>();
371
+ for (const diagnostic of snapshot.diagnostics) {
372
+ for (const [id, index] of [[diagnostic.nodeId, diagnosticsByNodeId], [diagnostic.resourceId, diagnosticsByResourceId]] as const) {
373
+ if (id === undefined) continue;
374
+ const entries = index.get(id) ?? [];
375
+ entries.push(diagnostic);
376
+ index.set(id, entries);
377
+ }
378
+ }
348
379
  const resourceById = new Map(resources.map((resource) => [resource.id, resource]));
349
380
  const groupById = new Map(debugGroups.map((group) => [group.id, group]));
350
381
  const groupByPathKey = new Map(debugGroups.map((group) => [group.pathKey, group]));
@@ -362,14 +393,18 @@ export function createDebugViewModel(
362
393
  ), undefined);
363
394
  const transientResources = resources.filter((resource) => resource.origin === 'transient');
364
395
  const transientEstimatedByteSize = sumKnown(transientResources.map((resource) => resource.estimatedByteSize));
365
- const physicalEstimatedBytes = sumKnown(physicalAllocations.map((allocation) => allocation.estimatedByteSize));
396
+ const allocationAvailable = snapshot.memory.allocationReport.status === 'available';
397
+ const physicalSizes = physicalAllocations.map((allocation) => allocation.estimatedByteSize);
398
+ const physicalEstimatedBytes = allocationAvailable ? sumKnown(physicalSizes) : undefined;
366
399
  const logicalAllocationSizes = transientResources
367
400
  .filter((resource) => resource.physicalResourceId !== undefined)
368
401
  .map((resource) => allocationById.get(resource.physicalResourceId!)?.estimatedByteSize);
369
- const logicalCapacityBytes = sumKnown(logicalAllocationSizes);
402
+ const logicalCapacityBytes = allocationAvailable ? sumKnown(logicalAllocationSizes) : undefined;
370
403
  const metrics: FrameGraphDebugMetrics = {
371
404
  timingEligibleNodeCount: timingEligibleNodes.length,
372
405
  timedNodeCount: timedNodes.length,
406
+ cpuTimedNodeCount: nodes.filter(node => node.cpuDurationMicros !== undefined).length,
407
+ cpuWorkDurationMicros: nodes.reduce((sum, node) => sum + (node.cpuDurationMicros ?? 0), 0),
373
408
  slowestNode,
374
409
  transientEstimatedByteSize,
375
410
  logicalCapacityBytes,
@@ -378,6 +413,11 @@ export function createDebugViewModel(
378
413
  ? undefined
379
414
  : Math.max(0, logicalCapacityBytes - physicalEstimatedBytes),
380
415
  aliasedAllocationCount: physicalAllocations.filter((allocation) => allocation.resourceIds.length > 1).length,
416
+ estimatedCoverage: {
417
+ transient: estimateCoverage(transientResources.map((resource) => resource.estimatedByteSize)),
418
+ logical: estimateCoverage(logicalAllocationSizes),
419
+ physical: estimateCoverage(physicalSizes),
420
+ },
381
421
  };
382
422
 
383
423
  return {
@@ -395,6 +435,10 @@ export function createDebugViewModel(
395
435
  executionSegments,
396
436
  textureViewById,
397
437
  nodeById,
438
+ canonicalNodeById,
439
+ culledById,
440
+ diagnosticsByNodeId,
441
+ diagnosticsByResourceId,
398
442
  resourceById,
399
443
  groupById,
400
444
  groupByPathKey,
@@ -404,6 +448,7 @@ export function createDebugViewModel(
404
448
  segmentByNodeId,
405
449
  segmentByIndex,
406
450
  metrics,
451
+ cpuProfiling: snapshot.timings.cpu,
407
452
  profiling: gpuTiming.status === 'available'
408
453
  ? {
409
454
  status: 'available',
@@ -425,6 +470,10 @@ export function createDebugViewModel(
425
470
  };
426
471
  }
427
472
 
473
+ function estimateCoverage(values: readonly (number | undefined)[]): EstimateCoverage {
474
+ return { known: values.filter((value) => value !== undefined).length, total: values.length };
475
+ }
476
+
428
477
  function sumKnown(values: readonly (number | undefined)[]): number | undefined {
429
478
  let total = 0;
430
479
  for (const value of values) {
@@ -601,6 +650,8 @@ function buildDebugGroupSummaries(input: {
601
650
  externalSubmissionCount: retained.filter((node) => node.kind === 'external-submission').length,
602
651
  gpuWorkDurationMicros: timed.reduce((sum, node) => sum + node.gpuDurationMicros!, 0),
603
652
  timedNodeCount: timed.length,
653
+ cpuTimedNodeCount: retained.filter(node => node.cpuDurationMicros !== undefined).length,
654
+ cpuWorkDurationMicros: retained.reduce((sum, node) => sum + (node.cpuDurationMicros ?? 0), 0),
604
655
  timingEligibleNodeCount: timingEligible.length,
605
656
  },
606
657
  };
package/src/index.ts CHANGED
@@ -17,5 +17,9 @@ export type {
17
17
  } from '@zenfg/snapshot';
18
18
  export { FrameGraphInspector, mountFrameGraphInspector } from './FrameGraphInspector.ts';
19
19
  export type { FrameGraphInspectorOptions } from './FrameGraphInspector.ts';
20
- export { ZENFG_INSPECTOR_QUERY_PARAM, isZenFGInspectorRequested } from './query.ts';
21
- export { ensureFrameGraphInspectorStyles } from './styles.ts';
20
+ export { ZENFG_INSPECTOR_QUERY_PARAM, isZenFGInspectorRequested } from './query.ts';
21
+ export { ensureFrameGraphInspectorStyles } from './styles.ts';
22
+
23
+ export type { InspectorTheme, InspectorThemeVariables } from './theme.ts';
24
+
25
+ export type { FrameGraphCaptureRequest } from './capture.ts';
@@ -22,9 +22,16 @@ export type AliasAnalysis = {
22
22
  readonly resources: ReadonlyMap<string, AliasAnalysisResource>;
23
23
  readonly minUse: number;
24
24
  readonly maxUse: number;
25
+ readonly hasLifetimes: boolean;
25
26
  };
26
27
 
28
+ // View models are immutable for the lifetime of a capture. Search and selection
29
+ // changes should not rebuild allocation membership or the execution domain.
30
+ const analysisBySnapshot = new WeakMap<FrameGraphDebugViewModel, AliasAnalysis>();
31
+
27
32
  export function analyzeSnapshotAliases(snapshot: FrameGraphDebugViewModel): AliasAnalysis {
33
+ const cached = analysisBySnapshot.get(snapshot);
34
+ if (cached) return cached;
28
35
  const allocationById = snapshot.allocationById;
29
36
  const resourcesById = snapshot.resourceById;
30
37
  const allocationGroups = snapshot.physicalAllocations.map((allocation): AliasAnalysisAllocation => {
@@ -43,13 +50,14 @@ export function analyzeSnapshotAliases(snapshot: FrameGraphDebugViewModel): Alia
43
50
  let minUse = Number.POSITIVE_INFINITY;
44
51
  let maxUse = Number.NEGATIVE_INFINITY;
45
52
  for (const resource of snapshot.resources) {
46
- if (!resource.lifetime) {
53
+ if (resource.origin !== 'transient' || !resource.lifetime) {
47
54
  continue;
48
55
  }
49
56
  minUse = Math.min(minUse, resource.lifetime.firstUse);
50
57
  maxUse = Math.max(maxUse, resource.lifetime.lastUse);
51
58
  }
52
- if (!Number.isFinite(minUse) || !Number.isFinite(maxUse)) {
59
+ const hasLifetimes = Number.isFinite(minUse) && Number.isFinite(maxUse);
60
+ if (!hasLifetimes) {
53
61
  minUse = 0;
54
62
  maxUse = 0;
55
63
  }
@@ -65,16 +73,19 @@ export function analyzeSnapshotAliases(snapshot: FrameGraphDebugViewModel): Alia
65
73
  resource,
66
74
  allocation,
67
75
  aliasStatus,
68
- nonAliasReasons: resolveNonAliasReasons(resource, allocation, allocationGroups),
76
+ nonAliasReasons: describeAliasStatus(aliasStatus),
69
77
  });
70
78
  }
71
79
 
72
- return {
80
+ const analysis = {
73
81
  allocations: allocationGroups,
74
82
  resources: resourceAnalysis,
75
83
  minUse,
76
84
  maxUse,
85
+ hasLifetimes,
77
86
  };
87
+ analysisBySnapshot.set(snapshot, analysis);
88
+ return analysis;
78
89
  }
79
90
 
80
91
  function resolveAliasStatus(
@@ -94,71 +105,14 @@ function resolveAliasStatus(
94
105
  return allocationResources.length > 1 ? 'aliased' : 'single';
95
106
  }
96
107
 
97
- function resolveNonAliasReasons(
98
- resource: FrameGraphDebugResource,
99
- allocation: FrameGraphDebugPhysicalAllocation | undefined,
100
- allocations: readonly AliasAnalysisAllocation[],
101
- ): readonly string[] {
102
- if (resource.origin !== 'transient') {
103
- return ['Imported or swapchain resources do not participate in transient aliasing.'];
104
- }
105
- if (!resource.lifetime) {
106
- return ['No lifetime is available for this resource in the retained graph.'];
107
- }
108
- if (!allocation) {
109
- return ['No physical allocation is associated with this transient resource.'];
110
- }
111
-
112
- const reasons = new Set<string>();
113
- for (const group of allocations) {
114
- if (group.allocation.id === allocation.id) {
115
- continue;
116
- }
117
- for (const other of group.resources) {
118
- if (other.id === resource.id || other.origin !== 'transient') {
119
- continue;
120
- }
121
- if (other.kind !== resource.kind) {
122
- reasons.add(`Different kind from ${labelDebugResource(other)}.`);
123
- continue;
124
- }
125
- if (group.allocation.compatibilityClassId !== allocation.compatibilityClassId) {
126
- reasons.add(`Different compatibility class from ${labelDebugResource(other)}.`);
127
- continue;
128
- }
129
- if (!other.lifetime) {
130
- reasons.add(`Missing lifetime for ${labelDebugResource(other)}.`);
131
- continue;
132
- }
133
- if (lifetimesOverlap(resource.lifetime, other.lifetime)) {
134
- reasons.add(`Lifetime overlaps ${labelDebugResource(other)} (${other.lifetime.firstUse}-${other.lifetime.lastUse}).`);
135
- }
136
- }
137
- }
138
-
139
- if (reasons.size === 0) {
140
- return allocationResourcesFor(allocations, allocation.id).length > 1
141
- ? ['Aliases with resources in the same physical allocation.']
142
- : ['No compatible non-overlapping transient resource was found in this snapshot.'];
108
+ function describeAliasStatus(status: AliasAnalysisResource['aliasStatus']): readonly string[] {
109
+ switch (status) {
110
+ case 'not-transient': return ['Imported and surface resources do not participate in transient aliasing.'];
111
+ case 'no-lifetime': return ['No lifetime is available for this resource in the retained graph.'];
112
+ case 'not-allocated': return ['No physical allocation is associated with this transient resource.'];
113
+ case 'aliased': return ['Shares a physical allocation with other logical resources.'];
114
+ // The snapshot states allocation membership; it cannot explain allocator
115
+ // decisions by comparing every pair of logical resources.
116
+ case 'single': return ['This physical allocation contains one logical resource.'];
143
117
  }
144
-
145
- return Array.from(reasons).slice(0, 6);
146
- }
147
-
148
- function allocationResourcesFor(
149
- allocations: readonly AliasAnalysisAllocation[],
150
- allocationId: string,
151
- ): readonly FrameGraphDebugResource[] {
152
- return allocations.find((group) => group.allocation.id === allocationId)?.resources ?? [];
153
- }
154
-
155
- function lifetimesOverlap(
156
- a: NonNullable<FrameGraphDebugResource['lifetime']>,
157
- b: NonNullable<FrameGraphDebugResource['lifetime']>,
158
- ): boolean {
159
- return a.firstUse <= b.lastUse && b.firstUse <= a.lastUse;
160
- }
161
-
162
- function labelDebugResource(resource: FrameGraphDebugResource): string {
163
- return resource.label ?? `${resource.kind}-${resource.id}`;
164
118
  }