@zenfg/inspector 0.1.0-beta.2 → 0.1.0-beta.3

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 (62) hide show
  1. package/README.md +63 -1
  2. package/dist/FrameGraphInspector.d.ts +2 -5
  3. package/dist/FrameGraphInspector.d.ts.map +1 -1
  4. package/dist/FrameGraphInspector.js +16 -38
  5. package/dist/FrameGraphInspector.js.map +1 -1
  6. package/dist/debugCaptureModel.d.ts +9 -4
  7. package/dist/debugCaptureModel.d.ts.map +1 -1
  8. package/dist/debugCaptureModel.js +36 -14
  9. package/dist/debugCaptureModel.js.map +1 -1
  10. package/dist/panelCytoscapeGraphRenderer.d.ts +3 -1
  11. package/dist/panelCytoscapeGraphRenderer.d.ts.map +1 -1
  12. package/dist/panelCytoscapeGraphRenderer.js +39 -19
  13. package/dist/panelCytoscapeGraphRenderer.js.map +1 -1
  14. package/dist/panelDiagnosticsView.js +2 -2
  15. package/dist/panelDiagnosticsView.js.map +1 -1
  16. package/dist/panelGraphLayout.d.ts.map +1 -1
  17. package/dist/panelGraphLayout.js +15 -1
  18. package/dist/panelGraphLayout.js.map +1 -1
  19. package/dist/panelGraphScene.d.ts +14 -26
  20. package/dist/panelGraphScene.d.ts.map +1 -1
  21. package/dist/panelGraphScene.js +141 -195
  22. package/dist/panelGraphScene.js.map +1 -1
  23. package/dist/panelGraphView.d.ts.map +1 -1
  24. package/dist/panelGraphView.js +31 -9
  25. package/dist/panelGraphView.js.map +1 -1
  26. package/dist/panelGraphVisuals.d.ts +7 -3
  27. package/dist/panelGraphVisuals.d.ts.map +1 -1
  28. package/dist/panelGraphVisuals.js +45 -80
  29. package/dist/panelGraphVisuals.js.map +1 -1
  30. package/dist/panelInspectorView.d.ts +5 -0
  31. package/dist/panelInspectorView.d.ts.map +1 -1
  32. package/dist/panelInspectorView.js +99 -13
  33. package/dist/panelInspectorView.js.map +1 -1
  34. package/dist/panelSelection.js +2 -2
  35. package/dist/panelSelection.js.map +1 -1
  36. package/dist/panelTypes.d.ts +8 -3
  37. package/dist/panelTypes.d.ts.map +1 -1
  38. package/dist/panelTypes.js.map +1 -1
  39. package/dist/panelVisualTheme.d.ts +21 -13
  40. package/dist/panelVisualTheme.d.ts.map +1 -1
  41. package/dist/panelVisualTheme.js +15 -7
  42. package/dist/panelVisualTheme.js.map +1 -1
  43. package/dist/panelWorkbenchHelpers.js +1 -1
  44. package/dist/panelWorkbenchHelpers.js.map +1 -1
  45. package/dist/styles.d.ts.map +1 -1
  46. package/dist/styles.js +16 -12
  47. package/dist/styles.js.map +1 -1
  48. package/package.json +2 -2
  49. package/src/FrameGraphInspector.ts +17 -39
  50. package/src/debugCaptureModel.ts +44 -19
  51. package/src/panelCytoscapeGraphRenderer.ts +38 -18
  52. package/src/panelDiagnosticsView.ts +3 -3
  53. package/src/panelGraphLayout.ts +15 -1
  54. package/src/panelGraphScene.ts +153 -279
  55. package/src/panelGraphView.ts +31 -9
  56. package/src/panelGraphVisuals.ts +46 -82
  57. package/src/panelInspectorView.ts +104 -16
  58. package/src/panelSelection.ts +2 -2
  59. package/src/panelTypes.ts +9 -3
  60. package/src/panelVisualTheme.ts +18 -7
  61. package/src/panelWorkbenchHelpers.ts +2 -2
  62. package/src/styles.ts +16 -12
@@ -22,7 +22,7 @@ export function renderGraphView(
22
22
  onToggleGroup: (pathKey: string) => void,
23
23
  ): void {
24
24
  const scene = resolveGraphScene(graphView, snapshot);
25
- renderGraphLegend(graphView.legend, scene);
25
+ renderGraphLegend(graphView.legend, snapshot);
26
26
  const elementCount = scene.nodes.length + scene.edges.length;
27
27
  const layoutElementBudget = graphView.layoutElementBudget ?? Number.MAX_SAFE_INTEGER;
28
28
  if (elementCount > layoutElementBudget) {
@@ -56,14 +56,12 @@ export function resolveGraphScene(
56
56
  snapshot: FrameGraphDebugViewModel,
57
57
  ): GraphScene {
58
58
  const optionsKey = JSON.stringify([
59
- graphView.graphMode,
60
59
  graphView.groupsEnabled,
61
60
  [...graphView.expandedGroupPaths].sort(),
62
61
  ]);
63
62
  const cached = graphSceneCache.get(graphView);
64
63
  if (cached?.snapshot === snapshot && cached.optionsKey === optionsKey) return cached.scene;
65
64
  const scene = createGraphScene(snapshot, {
66
- mode: graphView.graphMode,
67
65
  groupsEnabled: graphView.groupsEnabled,
68
66
  expandedGroupPaths: graphView.expandedGroupPaths,
69
67
  });
@@ -85,13 +83,26 @@ export function destroyGraph(graphView: GraphViewState): void {
85
83
  graphSceneCache.delete(graphView);
86
84
  }
87
85
 
88
- function renderGraphLegend(host: HTMLElement | undefined, scene: GraphScene): void {
86
+ function renderGraphLegend(host: HTMLElement | undefined, snapshot: FrameGraphDebugViewModel): void {
89
87
  if (!host) return;
90
- const entries = createGraphLegend(scene);
91
- const key = entries.map((entry) => entry.key).join('|');
88
+ const entries = createGraphLegend(snapshot);
89
+ const key = JSON.stringify(entries);
92
90
  if (host.dataset.legendKey === key) return;
93
91
  host.dataset.legendKey = key;
94
- host.replaceChildren(...entries.map((entry) => {
92
+ host.replaceChildren();
93
+ let group: HTMLElement | undefined;
94
+ for (const entry of entries) {
95
+ if (group?.getAttribute('aria-label') !== entry.group) {
96
+ group = document.createElement('span');
97
+ group.className = 'zenfg-inspector-legend-group';
98
+ group.setAttribute('role', 'group');
99
+ group.setAttribute('aria-label', entry.group);
100
+ const heading = document.createElement('span');
101
+ heading.className = 'zenfg-inspector-legend-heading';
102
+ heading.textContent = entry.group;
103
+ group.appendChild(heading);
104
+ host.appendChild(group);
105
+ }
95
106
  const item = document.createElement('span');
96
107
  item.className = 'zenfg-inspector-legend-item';
97
108
  const swatch = document.createElement('span');
@@ -100,9 +111,20 @@ function renderGraphLegend(host: HTMLElement | undefined, scene: GraphScene): vo
100
111
  if (entry.lineStyle) swatch.dataset.lineStyle = entry.lineStyle;
101
112
  if (entry.hollowArrow) swatch.dataset.hollowArrow = 'true';
102
113
  swatch.style.setProperty('--zenfg-inspector-legend-color', entry.color);
114
+ if (entry.shape === 'cut-rectangle' || entry.shape === 'tag') {
115
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
116
+ svg.setAttribute('viewBox', '0 0 20 14');
117
+ svg.setAttribute('aria-hidden', 'true');
118
+ const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
119
+ polygon.setAttribute('points', entry.shape === 'tag'
120
+ ? '1,1 12,1 19,7 12,13 1,13'
121
+ : '4,1 16,1 19,4 19,10 16,13 4,13 1,10 1,4');
122
+ svg.appendChild(polygon);
123
+ swatch.appendChild(svg);
124
+ }
103
125
  const label = document.createElement('span');
104
126
  label.textContent = entry.label;
105
127
  item.append(swatch, label);
106
- return item;
107
- }));
128
+ group!.appendChild(item);
129
+ }
108
130
  }
@@ -2,6 +2,7 @@ import type cytoscape from 'cytoscape';
2
2
 
3
3
  import type { GraphScene, GraphSceneEdge, GraphSceneNode } from './panelGraphScene.ts';
4
4
  import { GRAPH_VISUAL_THEME } from './panelVisualTheme.ts';
5
+ import { declarationEntrances, type FrameGraphDebugViewModel } from './debugCaptureModel.ts';
5
6
 
6
7
  export { GRAPH_VISUAL_THEME } from './panelVisualTheme.ts';
7
8
 
@@ -16,16 +17,19 @@ export const GRAPH_GEOMETRY = {
16
17
  groupPadding: 32,
17
18
  elkGroupPadding: { top: 46, right: 24, bottom: 24, left: 24 },
18
19
  edgeCornerRadius: 6,
20
+ outputWidth: 256,
21
+ outputLabelWidth: 144,
19
22
  } as const;
20
23
 
21
24
  const ENDPOINT_EDGE_DISTANCES = 'endpoints' as unknown as cytoscape.Css.Edge['edge-distances'];
22
25
  const EDGE_SEGMENT_RADII = `${GRAPH_GEOMETRY.edgeCornerRadius}px` as unknown as cytoscape.Css.Edge['segment-radii'];
23
26
 
24
27
  export type GraphLegendEntry = {
28
+ readonly group: 'Execution' | 'Resources' | 'Relationships';
25
29
  readonly key: string;
26
30
  readonly label: string;
27
31
  readonly color: string;
28
- readonly shape: 'box' | 'ellipse' | 'group' | 'line';
32
+ readonly shape: 'box' | 'cut-rectangle' | 'tag' | 'ellipse' | 'group' | 'line';
29
33
  readonly lineStyle?: 'solid' | 'dotted' | 'dashed';
30
34
  readonly hollowArrow?: boolean;
31
35
  };
@@ -36,50 +40,37 @@ const NODE_LEGEND_ENTRIES = {
36
40
  copy: nodeLegend('copy', 'Copy', GRAPH_VISUAL_THEME.copy.stroke),
37
41
  'clear-buffer': nodeLegend('clear', 'Clear', GRAPH_VISUAL_THEME.clear.stroke),
38
42
  command: nodeLegend('command', 'Command', GRAPH_VISUAL_THEME.command.stroke),
39
- 'external-submission': nodeLegend('external', 'External', GRAPH_VISUAL_THEME.external.stroke),
43
+ 'external-submission': { ...nodeLegend('external', 'External', GRAPH_VISUAL_THEME.external.stroke), shape: 'cut-rectangle' as const },
40
44
  } as const;
41
45
 
42
- export function createGraphLegend(scene: GraphScene): readonly GraphLegendEntry[] {
46
+ export function createGraphLegend(snapshot: FrameGraphDebugViewModel): readonly GraphLegendEntry[] {
43
47
  const entries: GraphLegendEntry[] = [];
44
- const passKinds = new Set(scene.nodes.flatMap((node) => node.kind === 'pass' || node.kind === 'culled-pass'
45
- ? [node.passKind]
46
- : []));
48
+ const passKinds = new Set(snapshot.nodes.map((node) => node.kind));
47
49
  for (const kind of ['render', 'compute', 'copy', 'clear-buffer', 'command', 'external-submission'] as const) {
48
50
  if (passKinds.has(kind)) entries.push(NODE_LEGEND_ENTRIES[kind]);
49
51
  }
50
- if (scene.nodes.some((node) => node.kind === 'group')) {
51
- entries.push({ key: 'group', label: 'Group', color: GRAPH_VISUAL_THEME.group.stroke, shape: 'group' });
52
+ const retained = new Set(snapshot.nodes.map((node) => node.id));
53
+ const used = new Set([
54
+ ...snapshot.accessEdges.filter((access) => retained.has(access.nodeId)).map((access) => access.resource.id),
55
+ ...snapshot.roots.flatMap((root) => root.resource ? [root.resource.id] : []),
56
+ ]);
57
+ const resources = snapshot.resources.filter((resource) => used.has(resource.id));
58
+ if (resources.length) entries.push({ group: 'Resources', key: 'declaration', label: 'Declaration', color: GRAPH_VISUAL_THEME.declaration.stroke, shape: 'ellipse' });
59
+ if (snapshot.roots.some((root) => root.resource)) entries.push({ group: 'Resources', key: 'output', label: 'Output', color: GRAPH_VISUAL_THEME.output.stroke, shape: 'tag' });
60
+ if ([...snapshot.nodes, ...resources].some((item) => item.debugGroupId !== undefined)) {
61
+ entries.push({ group: 'Relationships', key: 'group', label: 'Group', color: GRAPH_VISUAL_THEME.group.stroke, shape: 'group' });
52
62
  }
53
- if (scene.nodes.some((node) => node.kind === 'resource' && node.resourceKind === 'texture')) {
54
- entries.push({ key: 'texture', label: 'Texture', color: GRAPH_VISUAL_THEME.texture.stroke, shape: 'ellipse' });
63
+ if (snapshot.edges.some((edge) => edge.kind === 'value')
64
+ || declarationEntrances(snapshot.nodes, snapshot.accessEdges, snapshot.edges).length
65
+ || snapshot.roots.some((root) => root.resource && root.resolution && (root.resolution.usesInitialContents || root.resolution.producerNodeIds.length))) {
66
+ entries.push(edgeLegend('flow', 'Resource Flow', GRAPH_VISUAL_THEME.dependency.value, 'solid'));
55
67
  }
56
- if (scene.nodes.some((node) => node.kind === 'resource' && node.resourceKind === 'buffer')) {
57
- entries.push({ key: 'buffer', label: 'Buffer', color: GRAPH_VISUAL_THEME.buffer.stroke, shape: 'ellipse' });
58
- }
59
- if (scene.nodes.some((node) => node.kind === 'culled-pass')) {
60
- entries.push({
61
- key: 'culled',
62
- label: 'Culled',
63
- color: GRAPH_VISUAL_THEME.culled.stroke,
64
- shape: 'box',
65
- lineStyle: 'dashed',
66
- });
67
- }
68
- if (scene.edges.some((edge) => edge.kind === 'dependency' && edge.dependencyKind === 'value')) {
69
- entries.push(edgeLegend('value', 'Value', GRAPH_VISUAL_THEME.dependency.value, 'solid'));
70
- }
71
- if (scene.edges.some((edge) => edge.kind === 'dependency' && edge.dependencyKind === 'ordering')) {
68
+ if (snapshot.edges.some((edge) => edge.kind === 'ordering')) {
72
69
  entries.push({
73
70
  ...edgeLegend('ordering', 'Order', GRAPH_VISUAL_THEME.dependency.ordering, 'dotted'),
74
71
  hollowArrow: true,
75
72
  });
76
73
  }
77
- if (scene.edges.some((edge) => edge.kind === 'access' && edge.accessMode === 'read')) {
78
- entries.push(edgeLegend('read', 'Read', GRAPH_VISUAL_THEME.access.read, 'solid'));
79
- }
80
- if (scene.edges.some((edge) => edge.kind === 'access' && edge.accessMode === 'write')) {
81
- entries.push(edgeLegend('write', 'Write', GRAPH_VISUAL_THEME.access.write, 'solid'));
82
- }
83
74
  return entries;
84
75
  }
85
76
 
@@ -102,6 +93,7 @@ export function createGraphStyles(): cytoscape.StylesheetJson {
102
93
  'background-color': theme.surfaceRaised,
103
94
  'border-color': theme.group.stroke,
104
95
  'border-width': 1.25,
96
+ 'border-style': 'solid',
105
97
  'text-valign': 'center',
106
98
  'text-halign': 'center',
107
99
  'overlay-opacity': 0,
@@ -118,34 +110,27 @@ export function createGraphStyles(): cytoscape.StylesheetJson {
118
110
  style: {
119
111
  'background-color': theme.external.fill,
120
112
  'border-color': theme.external.stroke,
121
- 'border-style': 'double',
122
- 'border-width': 3,
123
- },
124
- },
125
- {
126
- selector: 'node[kind = "culled-pass"]',
127
- style: {
128
- 'background-color': theme.culled.fill,
129
- 'border-color': theme.culled.stroke,
130
- 'color': theme.muted,
131
- 'border-style': 'dashed',
132
- 'opacity': 0.72,
113
+ 'shape': 'cut-rectangle',
114
+ 'corner-radius': '10px',
133
115
  },
134
116
  },
135
117
  {
136
- selector: 'node[kind = "resource"][resourceKind = "texture"]',
118
+ selector: 'node[kind = "resource"]',
137
119
  style: {
138
120
  'shape': 'ellipse',
139
- 'background-color': theme.texture.fill,
140
- 'border-color': theme.texture.stroke,
121
+ 'background-color': theme.declaration.fill,
122
+ 'border-color': theme.declaration.stroke,
141
123
  },
142
124
  },
143
125
  {
144
- selector: 'node[kind = "resource"][resourceKind = "buffer"]',
126
+ selector: 'node[kind = "root"]',
145
127
  style: {
146
- 'shape': 'ellipse',
147
- 'background-color': theme.buffer.fill,
148
- 'border-color': theme.buffer.stroke,
128
+ 'shape': 'tag',
129
+ 'background-color': theme.output.fill,
130
+ 'border-color': theme.output.stroke,
131
+ 'text-max-width': `${GRAPH_GEOMETRY.outputLabelWidth}px`,
132
+ // Native tag shoulders sit at 5/8 of its width. Center text in the rectangular body.
133
+ 'text-margin-x': -GRAPH_GEOMETRY.outputWidth * 3 / 16,
149
134
  },
150
135
  },
151
136
  {
@@ -183,7 +168,7 @@ export function createGraphStyles(): cytoscape.StylesheetJson {
183
168
  'arrow-scale': 0.8,
184
169
  'curve-style': 'straight',
185
170
  'label': 'data(displayLabel)',
186
- 'font-family': 'ui-monospace, SFMono-Regular, Consolas, Liberation Mono, monospace',
171
+ 'font-family': 'ui-monospace, SFMono-Regular, Consolas, Liberation Mono, monospace',
187
172
  'font-size': 10,
188
173
  'min-zoomed-font-size': GRAPH_GEOMETRY.minimumZoomedFontSize,
189
174
  'color': theme.text,
@@ -196,7 +181,7 @@ export function createGraphStyles(): cytoscape.StylesheetJson {
196
181
  },
197
182
  },
198
183
  {
199
- selector: 'edge[dependencyKind = "ordering"]',
184
+ selector: 'edge[kind = "ordering"]',
200
185
  style: {
201
186
  'line-color': theme.dependency.ordering,
202
187
  'target-arrow-color': theme.dependency.ordering,
@@ -205,24 +190,6 @@ export function createGraphStyles(): cytoscape.StylesheetJson {
205
190
  'line-opacity': 0.72,
206
191
  },
207
192
  },
208
- {
209
- selector: 'edge[accessMode = "read"]',
210
- style: {
211
- 'line-color': theme.access.read,
212
- 'target-arrow-color': theme.access.read,
213
- },
214
- },
215
- {
216
- selector: 'edge[accessMode = "write"]',
217
- style: {
218
- 'line-color': theme.access.write,
219
- 'target-arrow-color': theme.access.write,
220
- },
221
- },
222
- {
223
- selector: 'edge[dashed = 1]',
224
- style: { 'line-style': 'dashed', 'line-opacity': 0.46, 'opacity': 0.58 },
225
- },
226
193
  {
227
194
  selector: 'edge.elk-route',
228
195
  style: {
@@ -263,9 +230,6 @@ export function createGraphStyles(): cytoscape.StylesheetJson {
263
230
  style: {
264
231
  'border-color': theme.selected,
265
232
  'border-width': 3,
266
- 'underlay-color': theme.selected,
267
- 'underlay-padding': 5,
268
- 'underlay-opacity': 0.18,
269
233
  'opacity': 1,
270
234
  'z-index': 20,
271
235
  },
@@ -287,10 +251,10 @@ export function nodeDimensions(node: GraphSceneNode): { readonly width: number;
287
251
  switch (node.kind) {
288
252
  case 'pass':
289
253
  return { width: 184, height: node.label.includes('\n') ? 62 : 48 };
290
- case 'culled-pass':
291
- return { width: 184, height: 64 };
254
+ case 'root':
255
+ return { width: GRAPH_GEOMETRY.outputWidth, height: Math.max(58, node.label.split('\n').length * 17 + 24) };
292
256
  case 'resource':
293
- return { width: 166, height: node.label.includes('\n') ? 58 : 46 };
257
+ return { width: 184, height: Math.max(58, node.label.split('\n').length * 17 + 24) };
294
258
  case 'group':
295
259
  return node.collapsed ? { width: 224, height: 68 } : { width: 120, height: 80 };
296
260
  }
@@ -301,13 +265,13 @@ export function graphLayoutGeometryKey(scene: GraphScene): string {
301
265
  topology: scene.topologyKey,
302
266
  dimensions: scene.nodes.map((node) => {
303
267
  const dimensions = nodeDimensions(node);
304
- return [node.id, dimensions.width, dimensions.height];
268
+ return [node.id, dimensions.width, dimensions.height, node.kind === 'pass' && node.passKind === 'external-submission'];
305
269
  }),
306
270
  });
307
271
  }
308
272
 
309
- export function graphEdgeDisplayLabel(edge: GraphSceneEdge): string {
310
- return edge.kind === 'access' ? '' : edge.label ?? '';
273
+ export function graphEdgeDisplayLabel(_edge: GraphSceneEdge): string {
274
+ return '';
311
275
  }
312
276
 
313
277
  export function expandedGroupLabelMaxWidth(outerWidth: number): number {
@@ -319,7 +283,7 @@ export function isOverviewGraphScale(zoom: number): boolean {
319
283
  }
320
284
 
321
285
  function nodeLegend(key: string, label: string, color: string): GraphLegendEntry {
322
- return { key, label, color, shape: 'box' };
286
+ return { group: 'Execution', key, label, color, shape: 'box' };
323
287
  }
324
288
 
325
289
  function edgeLegend(
@@ -328,7 +292,7 @@ function edgeLegend(
328
292
  color: string,
329
293
  lineStyle: NonNullable<GraphLegendEntry['lineStyle']>,
330
294
  ): GraphLegendEntry {
331
- return { key, label, color, shape: 'line', lineStyle };
295
+ return { group: 'Relationships', key, label, color, shape: 'line', lineStyle };
332
296
  }
333
297
 
334
298
  function passStyle(
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  FrameGraphDebugAccess,
3
+ FrameGraphDebugRoot,
3
4
  FrameGraphDebugResourceRef,
4
5
  FrameGraphDebugViewModel,
5
6
  } from './debugCaptureModel.ts';
@@ -16,6 +17,28 @@ import {
16
17
  type WorkbenchCallbacks,
17
18
  } from './panelWorkbenchHelpers.ts';
18
19
 
20
+ type DetailRelation = readonly [label: string, selection: Selection, description?: string];
21
+
22
+ function formatAccessFacts(access: FrameGraphDebugAccess): string {
23
+ return [
24
+ access.mode, access.access,
25
+ ...(access.mode === 'write' ? [access.contents, `producesValue: ${access.producesValue}`] : []),
26
+ ...(access.bufferRange ? [`bytes ${access.bufferRange.offset}–${access.bufferRange.size === undefined ? 'end' : access.bufferRange.offset + access.bufferRange.size}`] : []),
27
+ ...(access.textureRegion ? [JSON.stringify(access.textureRegion)] : []),
28
+ ...(access.textureViewId ? [`view ${access.textureViewId}`] : []),
29
+ ].join(' · ');
30
+ }
31
+
32
+ function formatRootRange(root: FrameGraphDebugRoot): string {
33
+ if (!root.range) return 'Range unavailable';
34
+ if (root.range.kind === 'buffer') return `bytes ${root.range.offset}–${root.range.offset + root.range.size}`;
35
+ return root.range.regions.map((region) =>
36
+ `mip ${region.baseMipLevel}+${region.mipLevelCount} · ${region.baseDepthSlice === undefined
37
+ ? `layers ${region.baseArrayLayer}+${region.arrayLayerCount}`
38
+ : `depth ${region.baseDepthSlice}+${region.depthSliceCount}`} · ${region.aspect}`,
39
+ ).join('; ');
40
+ }
41
+
19
42
  export class InspectorView {
20
43
  readonly root = document.createElement('aside');
21
44
  private readonly title = document.createElement('strong');
@@ -26,6 +49,7 @@ export class InspectorView {
26
49
  private selected: Selection | undefined;
27
50
  private activeTab: InspectorTab = 'summary';
28
51
  private open = true;
52
+ private hoveredLink: HTMLButtonElement | undefined;
29
53
 
30
54
  constructor(
31
55
  private readonly callbacks: WorkbenchCallbacks,
@@ -39,7 +63,7 @@ export class InspectorView {
39
63
  const close = document.createElement('button');
40
64
  close.type = 'button';
41
65
  close.className = 'zenfg-inspector-inspector-close';
42
- close.appendChild(createPanelIcon('close'));
66
+ close.appendChild(createPanelIcon('close'));
43
67
  close.title = 'Close inspector';
44
68
  close.setAttribute('aria-label', 'Close inspector');
45
69
  close.addEventListener('click', () => this.setOpen(false));
@@ -76,6 +100,8 @@ export class InspectorView {
76
100
  }
77
101
 
78
102
  setSelection(selected: Selection | undefined, reveal = true): void {
103
+ if (selected?.kind === 'resource'
104
+ && (this.selected?.kind !== 'resource' || this.selected.id !== selected.id)) this.activeTab = 'summary';
79
105
  this.selected = selected;
80
106
  if (selected && reveal) this.setOpen(true);
81
107
  this.render();
@@ -83,6 +109,7 @@ export class InspectorView {
83
109
 
84
110
  setOpen(open: boolean): void {
85
111
  if (this.open === open) return;
112
+ if (!open) this.clearLinkHover();
86
113
  this.open = open;
87
114
  this.updateOpenState();
88
115
  this.onOpenChange(open);
@@ -98,6 +125,7 @@ export class InspectorView {
98
125
  }
99
126
 
100
127
  private render(): void {
128
+ this.clearLinkHover();
101
129
  for (const [tab, button] of this.tabs) {
102
130
  const active = tab === this.activeTab;
103
131
  button.classList.toggle('active', active);
@@ -140,7 +168,7 @@ export class InspectorView {
140
168
  return resource ? labelResource(resource) : `Resource #${selection.id}`;
141
169
  }
142
170
  case 'allocation': return `Allocation #${selection.id}`;
143
- case 'root': return `Retention root #${selection.index}`;
171
+ case 'root': return 'Output root';
144
172
  case 'culled': return `Culled node #${selection.index}`;
145
173
  case 'segment': return `Segment #${selection.index}`;
146
174
  }
@@ -200,11 +228,16 @@ export class InspectorView {
200
228
  ]);
201
229
  }
202
230
  case 'root': {
203
- const root = snapshot.roots[selection.index];
231
+ const root = snapshot.roots.find((root) => root.key === selection.key);
204
232
  return this.summary(root ? [
205
233
  ['Reason', root.reason],
206
234
  ['Node', root.nodeId === undefined ? '-' : `#${root.nodeId}`],
207
- ['Resource', root.resource ? labelResource(root.resource) : '-'],
235
+ ['Resource', root.resource ? this.viewResourceButton(snapshot, root.resource.id) : '-'],
236
+ ...(root.reason === 'side-effect' ? [] : [
237
+ ['Range', root.range ? JSON.stringify(root.range) : 'Unavailable in Legacy capture'],
238
+ ['Initial contents', root.resolution ? String(root.resolution.usesInitialContents) : 'Unavailable in Legacy capture'],
239
+ ['Producers', root.resolution ? root.resolution.producerNodeIds.join(', ') || 'None' : 'Unavailable in Legacy capture'],
240
+ ] as [string, string][]),
208
241
  ] : []);
209
242
  }
210
243
  case 'culled': {
@@ -254,6 +287,10 @@ export class InspectorView {
254
287
  host.append(
255
288
  this.resourceRelations('Inputs', group.summary.inputResources),
256
289
  this.resourceRelations('Outputs', group.summary.outputResources),
290
+ this.relationGroup('Output roots', group.summary.outputRoots.map((root) => [
291
+ `${root.resource ? labelResource(root.resource) : '-'} · ${root.reason} · ${root.range ? JSON.stringify(root.range) : 'Range unavailable'}`,
292
+ { kind: 'root', key: root.key },
293
+ ])),
257
294
  );
258
295
  }
259
296
  break;
@@ -261,15 +298,20 @@ export class InspectorView {
261
298
  case 'resource': {
262
299
  const resource = snapshot.resourceById.get(selection.id);
263
300
  const accesses = snapshot.accessesByResourceId.get(selection.id) ?? [];
264
- const passRelations: Array<readonly [string, Selection]> = [];
301
+ const passRelations: DetailRelation[] = [];
265
302
  for (const access of accesses) {
266
303
  const nodeSelection = this.accessNodeSelection(snapshot, access.nodeId);
267
304
  if (nodeSelection) passRelations.push([
268
- `${access.mode} · ${this.accessNodeLabel(snapshot, access.nodeId)} · ${access.access}`,
305
+ this.accessNodeLabel(snapshot, access.nodeId),
269
306
  nodeSelection,
307
+ formatAccessFacts(access),
270
308
  ]);
271
309
  }
272
310
  host.appendChild(this.relationGroup('Pass accesses', passRelations));
311
+ const roots = snapshot.roots.filter((root) => root.resource?.id === selection.id);
312
+ if (roots.length) host.appendChild(this.relationGroup('Output roots', roots.map((root) => [
313
+ `${root.reason} · ${formatRootRange(root)}`, { kind: 'root', key: root.key },
314
+ ])));
273
315
  if (resource?.physicalResourceId !== undefined) host.appendChild(this.relationGroup('Allocation', [
274
316
  [`#${resource.physicalResourceId}`, { kind: 'allocation', id: resource.physicalResourceId }],
275
317
  ]));
@@ -283,13 +325,15 @@ export class InspectorView {
283
325
  break;
284
326
  }
285
327
  case 'root': {
286
- const root = snapshot.roots[selection.index];
328
+ const root = snapshot.roots.find((root) => root.key === selection.key);
287
329
  const relations: Array<readonly [string, Selection]> = [];
288
330
  if (root?.nodeId !== undefined && snapshot.nodeById.has(root.nodeId)) relations.push([
289
331
  this.accessNodeLabel(snapshot, root.nodeId), { kind: 'node', id: root.nodeId },
290
332
  ]);
291
- if (root?.resource) relations.push([labelResource(root.resource), { kind: 'resource', id: root.resource.id }]);
292
- host.appendChild(this.relationGroup('Retained object', relations));
333
+ if (root?.resource) host.appendChild(this.relationGroup('Resource', [[labelResource(root.resource), { kind: 'resource', id: root.resource.id }]]));
334
+ if (root?.resource && root.resolution?.usesInitialContents) relations.push([`Initial contents · ${labelResource(root.resource)}`, { kind: 'resource', id: root.resource.id }]);
335
+ for (const id of root?.resolution?.producerNodeIds ?? []) relations.push([this.accessNodeLabel(snapshot, id), { kind: 'node', id }]);
336
+ host.appendChild(this.relationGroup('Output sources', relations));
293
337
  break;
294
338
  }
295
339
  case 'culled': {
@@ -320,8 +364,9 @@ export class InspectorView {
320
364
 
321
365
  private accessRelations(title: string, accesses: readonly FrameGraphDebugAccess[]): HTMLElement {
322
366
  return this.relationGroup(title, accesses.map((access) => [
323
- `${labelResource(access.resource)} · ${access.access}${access.mode === 'write' ? ` · ${access.contents}` : ''}`,
367
+ labelResource(access.resource),
324
368
  { kind: 'resource', id: access.resource.id },
369
+ formatAccessFacts(access),
325
370
  ]));
326
371
  }
327
372
 
@@ -331,13 +376,24 @@ export class InspectorView {
331
376
  ]));
332
377
  }
333
378
 
334
- private relationGroup(title: string, relations: readonly (readonly [string, Selection])[]): HTMLElement {
379
+ private relationGroup(title: string, relations: readonly DetailRelation[]): HTMLElement {
335
380
  const section = document.createElement('section');
336
381
  const heading = document.createElement('h3');
337
382
  heading.textContent = `${title} ${relations.length}`;
338
383
  section.appendChild(heading);
339
- for (const [label, selection] of relations) {
340
- section.appendChild(createRelationButton(label, selection, this.callbacks.onSelect));
384
+ for (const [label, selection, description] of relations) {
385
+ const link = this.createDetailLink(label, selection);
386
+ if (description === undefined) {
387
+ section.appendChild(link);
388
+ } else {
389
+ const entry = document.createElement('div');
390
+ entry.className = 'zenfg-inspector-relation-entry';
391
+ const metadata = document.createElement('span');
392
+ metadata.className = 'zenfg-inspector-muted';
393
+ metadata.textContent = description;
394
+ entry.append(link, metadata);
395
+ section.appendChild(entry);
396
+ }
341
397
  }
342
398
  if (relations.length === 0) {
343
399
  const empty = document.createElement('span');
@@ -348,15 +404,47 @@ export class InspectorView {
348
404
  return section;
349
405
  }
350
406
 
351
- private summary(rows: readonly (readonly [string, string])[]): HTMLElement {
407
+ private selectRelated(selection: Selection): void {
408
+ this.clearLinkHover();
409
+ this.callbacks.onSelect(selection);
410
+ }
411
+
412
+ private viewResourceButton(snapshot: FrameGraphDebugViewModel, resourceId: string): HTMLButtonElement {
413
+ return this.createDetailLink(`View resource · ${resourceLabel(snapshot, resourceId)}`,
414
+ { kind: 'resource', id: resourceId });
415
+ }
416
+
417
+ private createDetailLink(label: string, selection: Selection): HTMLButtonElement {
418
+ const link = createRelationButton(label, selection, (target) => this.selectRelated(target));
419
+ link.addEventListener('mouseenter', () => {
420
+ this.hoveredLink = link;
421
+ this.callbacks.onHover(selection);
422
+ });
423
+ link.addEventListener('mouseleave', () => {
424
+ if (this.hoveredLink === link) this.clearLinkHover();
425
+ });
426
+ return link;
427
+ }
428
+
429
+ private clearLinkHover(): void {
430
+ if (!this.hoveredLink) return;
431
+ this.hoveredLink = undefined;
432
+ this.callbacks.onHover(undefined);
433
+ }
434
+
435
+ private summary(rows: readonly (readonly [string, string | HTMLElement])[]): HTMLElement {
352
436
  const summary = document.createElement('dl');
353
437
  summary.className = 'zenfg-inspector-inspector-summary';
354
438
  for (const [label, value] of rows) {
355
439
  const term = document.createElement('dt');
356
440
  term.textContent = label;
357
441
  const description = document.createElement('dd');
358
- description.textContent = value;
359
- description.title = value;
442
+ if (typeof value === 'string') {
443
+ description.textContent = value;
444
+ description.title = value;
445
+ } else {
446
+ description.appendChild(value);
447
+ }
360
448
  summary.append(term, description);
361
449
  }
362
450
  return summary;
@@ -26,7 +26,7 @@ export function resolveSelectedDetail(
26
26
  case 'group':
27
27
  return snapshot.groupByPathKey.get(selected.pathKey);
28
28
  case 'root':
29
- return snapshot.roots[selected.index];
29
+ return snapshot.roots.find((root) => root.key === selected.key);
30
30
  case 'culled': {
31
31
  const culled = snapshot.culledNodes[selected.index];
32
32
  return culled ? {
@@ -54,7 +54,7 @@ export function selectionExists(snapshot: FrameGraphDebugViewModel, selected: Se
54
54
  case 'resource':
55
55
  return snapshot.resourceById.has(selected.id);
56
56
  case 'root':
57
- return isValidIndex(selected.index, snapshot.roots.length);
57
+ return snapshot.roots.filter((root) => root.key === selected.key).length === 1;
58
58
  case 'culled':
59
59
  return isValidIndex(selected.index, snapshot.culledNodes.length);
60
60
  case 'allocation':
package/src/panelTypes.ts CHANGED
@@ -1,10 +1,18 @@
1
1
  import type { GraphRenderer } from './panelGraphRenderer.ts';
2
+ import type { FrameGraphDebugEdge } from './debugCaptureModel.ts';
3
+
4
+ export type GraphFlowRelation = {
5
+ readonly role: 'declaration' | 'value' | 'ordering' | 'output-producer' | 'output-initial';
6
+ readonly nodeIds: readonly string[];
7
+ readonly rootKey?: string;
8
+ readonly dependency?: FrameGraphDebugEdge;
9
+ };
2
10
 
3
11
  export type Selection =
4
12
  | { kind: 'node'; id: string }
5
13
  | { kind: 'group'; pathKey: string }
6
14
  | { kind: 'resource'; id: string }
7
- | { kind: 'root'; index: number }
15
+ | { kind: 'root'; key: string }
8
16
  | { kind: 'culled'; index: number }
9
17
  | { kind: 'allocation'; id: string }
10
18
  | { kind: 'segment'; index: number };
@@ -12,14 +20,12 @@ export type Selection =
12
20
  export type WorkbenchTab = 'overview' | 'graph' | 'passes' | 'resources' | 'memory' | 'diagnostics';
13
21
  export type PassesSubview = 'list' | 'groups';
14
22
  export type InspectorTab = 'summary' | 'relations' | 'raw';
15
- export type GraphViewMode = 'passes' | 'resources';
16
23
 
17
24
  export type GraphViewState = {
18
25
  readonly host: HTMLElement;
19
26
  readonly toolbar: HTMLElement;
20
27
  readonly legend?: HTMLElement;
21
28
  readonly layoutElementBudget?: number;
22
- graphMode: GraphViewMode;
23
29
  groupsEnabled: boolean;
24
30
  readonly expandedGroupPaths: Set<string>;
25
31
  renderer?: GraphRenderer;