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

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 +60 -26
  3. package/THEMING.md +124 -0
  4. package/dist/FrameGraphInspector.d.ts +24 -8
  5. package/dist/FrameGraphInspector.d.ts.map +1 -1
  6. package/dist/FrameGraphInspector.js +151 -65
  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 +28 -4
  13. package/dist/debugCaptureModel.d.ts.map +1 -1
  14. package/dist/debugCaptureModel.js +72 -16
  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 +18 -3
  24. package/dist/panelCytoscapeGraphRenderer.d.ts.map +1 -1
  25. package/dist/panelCytoscapeGraphRenderer.js +183 -41
  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 +167 -58
  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 +20 -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 +15 -26
  50. package/dist/panelGraphScene.d.ts.map +1 -1
  51. package/dist/panelGraphScene.js +191 -208
  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 +42 -9
  60. package/dist/panelGraphView.js.map +1 -1
  61. package/dist/panelGraphVisuals.d.ts +18 -6
  62. package/dist/panelGraphVisuals.d.ts.map +1 -1
  63. package/dist/panelGraphVisuals.js +156 -116
  64. package/dist/panelGraphVisuals.js.map +1 -1
  65. package/dist/panelInspectorView.d.ts +13 -0
  66. package/dist/panelInspectorView.d.ts.map +1 -1
  67. package/dist/panelInspectorView.js +326 -46
  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 +36 -6
  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 +18 -4
  102. package/dist/panelTypes.d.ts.map +1 -1
  103. package/dist/panelTypes.js.map +1 -1
  104. package/dist/panelVisualTheme.d.ts +176 -71
  105. package/dist/panelVisualTheme.d.ts.map +1 -1
  106. package/dist/panelVisualTheme.js +34 -36
  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 +63 -2
  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 +161 -205
  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 +145 -68
  139. package/src/capture.ts +4 -0
  140. package/src/debugCaptureModel.ts +97 -21
  141. package/src/index.ts +6 -2
  142. package/src/panelAliasAnalysis.ts +24 -70
  143. package/src/panelCytoscapeGraphRenderer.ts +177 -40
  144. package/src/panelDetailLayout.ts +155 -0
  145. package/src/panelDetailStyles.ts +56 -0
  146. package/src/panelDiagnosticsView.ts +167 -86
  147. package/src/panelDomHelpers.ts +4 -5
  148. package/src/panelGraphLayout.ts +20 -5
  149. package/src/panelGraphRenderer.ts +7 -0
  150. package/src/panelGraphScene.ts +204 -292
  151. package/src/panelGraphSearch.ts +96 -0
  152. package/src/panelGraphView.ts +41 -9
  153. package/src/panelGraphVisuals.ts +152 -118
  154. package/src/panelInspectorView.ts +315 -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 +39 -7
  162. package/src/panelTheme.ts +85 -0
  163. package/src/panelTypes.ts +16 -4
  164. package/src/panelVisualTheme.ts +38 -38
  165. package/src/panelWorkbenchHelpers.ts +62 -4
  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 +162 -205
  170. package/src/theme.ts +24 -0
  171. package/src/themeDefinitions.ts +103 -0
  172. package/src/themePalette.ts +44 -0
@@ -1,3 +1,4 @@
1
+ import { GRAPH_VISUAL_THEME, type GraphVisualTheme } from './panelVisualTheme.ts';
1
2
  import type cytoscape from 'cytoscape';
2
3
  import type { ELK } from 'elkjs/lib/elk-api';
3
4
 
@@ -18,6 +19,9 @@ import {
18
19
  } from './panelGraphScene.ts';
19
20
  import {
20
21
  createGraphStyles,
22
+ fitGraphLabel,
23
+ fitCollapsedGroupLabel,
24
+ graphLabelFontSize,
21
25
  expandedGroupLabelMaxWidth,
22
26
  GRAPH_GEOMETRY,
23
27
  graphEdgeDisplayLabel,
@@ -38,7 +42,7 @@ export type GraphResizeSubscription = { disconnect(): void };
38
42
  export type GraphRendererEnvironment = {
39
43
  readonly createElement: (tagName: string) => HTMLElement;
40
44
  readonly loadRuntime: () => Promise<GraphRendererRuntime>;
41
- readonly layoutScene: (elk: ELK, scene: GraphScene) => Promise<GraphLayoutResult>;
45
+ readonly layoutScene: (elk: ELK, scene: GraphScene, theme?: GraphVisualTheme) => Promise<GraphLayoutResult>;
42
46
  readonly observeResize?: (element: HTMLElement, onResize: () => void) => GraphResizeSubscription;
43
47
  };
44
48
 
@@ -49,6 +53,9 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
49
53
  private readonly statusMessage: HTMLElement;
50
54
  private readonly retryButton: HTMLButtonElement;
51
55
  private readonly resizeSubscription: GraphResizeSubscription | undefined;
56
+ private theme: GraphVisualTheme = GRAPH_VISUAL_THEME;
57
+ private geometryRevision = 0;
58
+ private wasVisible = false;
52
59
  private core: cytoscape.Core | undefined;
53
60
  private elk: ELK | undefined;
54
61
  private loading: Promise<void> | undefined;
@@ -64,7 +71,16 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
64
71
  private anchorTargetContentKey: string | undefined;
65
72
  private failedSceneContentKey: string | undefined;
66
73
  private overview = false;
67
- private lastTap: { readonly id: string; readonly at: number } | undefined;
74
+ private dragPanning = false;
75
+ private pointerInside = false;
76
+ private pointerOverElement = false;
77
+ private readonly cancelPointerInteraction = () => {
78
+ this.dragPanning = false;
79
+ this.cancelGroupTap();
80
+ this.clearHover();
81
+ };
82
+ private pendingReveal: { readonly request: NonNullable<GraphRenderRequest['reveal']>; readonly contentKey: string; readonly captureRevision: number | undefined } | undefined;
83
+ private pendingGroupTap: { readonly id: string; readonly timer: ReturnType<typeof setTimeout> } | undefined;
68
84
 
69
85
  constructor(
70
86
  private readonly host: HTMLElement,
@@ -72,9 +88,15 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
72
88
  ) {
73
89
  this.canvasHost = environment.createElement('div');
74
90
  this.canvasHost.className = 'zenfg-inspector-graph-canvas';
91
+ this.updateCursor();
75
92
  this.tooltip = environment.createElement('div');
76
93
  this.tooltip.className = 'zenfg-inspector-graph-tooltip';
77
94
  this.tooltip.hidden = true;
95
+ // Cytoscape's node mouseout is not guaranteed when leaving its canvas directly.
96
+ this.canvasHost.addEventListener('pointerenter', () => { this.pointerInside = true; });
97
+ this.canvasHost.addEventListener('pointerleave', () => { this.pointerInside = false; this.clearHover(); });
98
+ this.canvasHost.addEventListener('pointercancel', this.cancelPointerInteraction);
99
+ this.canvasHost.ownerDocument?.defaultView?.addEventListener('blur', this.cancelPointerInteraction);
78
100
  this.status = environment.createElement('div');
79
101
  this.status.className = 'zenfg-inspector-graph-status';
80
102
  this.status.hidden = true;
@@ -88,12 +110,33 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
88
110
  this.retryButton.addEventListener('click', () => this.relayout());
89
111
  this.status.append(this.statusMessage, this.retryButton);
90
112
  this.host.replaceChildren(this.canvasHost, this.status, this.tooltip);
91
- this.resizeSubscription = environment.observeResize?.(host, () => this.core?.resize());
113
+ this.resizeSubscription = environment.observeResize?.(host, () => {
114
+ const visible = host.clientWidth > 0 && host.clientHeight > 0;
115
+ if (visible && !this.wasVisible) this.latestRequest?.onVisible?.();
116
+ this.wasVisible = visible;
117
+ this.core?.resize();
118
+ });
92
119
  }
93
120
 
94
121
  render(request: GraphRenderRequest): void {
95
122
  if (this.destroyed) return;
123
+ if (request.theme) this.setTheme(request.theme);
124
+ if (this.latestRequest?.scene !== request.scene
125
+ || (this.latestRequest?.selected && selectionKey(this.latestRequest.selected)) !== (request.selected && selectionKey(request.selected))) {
126
+ this.cancelGroupTap();
127
+ }
128
+ if ((!request.hovered && !this.tooltip.hidden) || this.latestRequest?.scene.contentKey !== request.scene.contentKey) {
129
+ this.hideTooltip();
130
+ this.core?.elements().removeClass('semantic-hover');
131
+ }
96
132
  this.latestRequest = request;
133
+ if (request.reveal) {
134
+ this.pendingReveal = { request: request.reveal, contentKey: request.scene.contentKey, captureRevision: request.captureRevision };
135
+ } else if (this.pendingReveal && (this.pendingReveal.captureRevision !== request.captureRevision
136
+ || this.pendingReveal.contentKey !== request.scene.contentKey
137
+ || !request.selected || selectionKey(request.selected) !== selectionKey(this.pendingReveal.request.selection))) {
138
+ this.pendingReveal = undefined;
139
+ }
97
140
  if (request.fit) {
98
141
  this.fitTargetContentKey = request.scene.contentKey;
99
142
  } else if (this.fitTargetContentKey !== request.scene.contentKey) {
@@ -122,16 +165,48 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
122
165
  this.syncInteraction(request);
123
166
  this.consumeFitIfCurrentTarget();
124
167
  this.consumeAnchorIfCurrentTarget();
168
+ this.consumeReveal();
125
169
  this.processedVersion = version;
126
170
  return;
127
171
  }
128
172
  this.scheduleProcessing();
129
173
  }
130
174
 
175
+ setTheme(theme: GraphVisualTheme): void {
176
+ if (this.destroyed || JSON.stringify(theme) === JSON.stringify(this.theme)) return;
177
+ const typographyChanged = theme.fontFamily !== this.theme.fontFamily || theme.fontSize !== this.theme.fontSize;
178
+ this.theme = theme;
179
+ this.core?.style().fromJson(createGraphStyles(theme)).update();
180
+ if (!typographyChanged) return;
181
+ this.geometryRevision++;
182
+ if (this.core && this.latestRequest && this.appliedScene) {
183
+ const selected = this.core.nodes('.semantic-selected').first();
184
+ const center = this.core.extent();
185
+ const target = selected.nonempty() ? selected : this.core.nodes().filter(node => !node.isParent()).sort((a, b) => {
186
+ const distance = (node: cytoscape.NodeSingular) => Math.hypot(node.position('x') - center.x1 - center.w / 2, node.position('y') - center.y1 - center.h / 2);
187
+ return distance(a as cytoscape.NodeSingular) - distance(b as cytoscape.NodeSingular);
188
+ }).first();
189
+ if (target.nonempty()) {
190
+ this.anchorElementId = target.id();
191
+ this.anchorTargetContentKey = this.latestRequest.scene.contentKey;
192
+ }
193
+ this.forceRelayout = true;
194
+ ++this.requestVersion;
195
+ this.scheduleProcessing();
196
+ }
197
+ this.updateSemanticZoom(true);
198
+ }
199
+
131
200
  resize(): void {
132
201
  this.core?.resize();
133
202
  }
134
203
 
204
+ cancelReveal(): void {
205
+ this.pendingReveal = undefined;
206
+ this.cancelGroupTap();
207
+ this.clearHover();
208
+ }
209
+
135
210
  fit(): void {
136
211
  const targetContentKey = this.latestRequest?.scene.contentKey;
137
212
  if (targetContentKey) this.fitTargetContentKey = targetContentKey;
@@ -144,6 +219,7 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
144
219
 
145
220
  relayout(): void {
146
221
  if (!this.latestRequest || this.destroyed) return;
222
+ this.cancelGroupTap();
147
223
  this.failedSceneContentKey = undefined;
148
224
  this.forceRelayout = true;
149
225
  this.fitTargetContentKey = this.latestRequest.scene.contentKey;
@@ -157,8 +233,10 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
157
233
  destroy(): void {
158
234
  if (this.destroyed) return;
159
235
  this.destroyed = true;
236
+ this.cancelGroupTap();
160
237
  this.requestVersion++;
161
238
  this.resizeSubscription?.disconnect();
239
+ this.canvasHost.ownerDocument?.defaultView?.removeEventListener('blur', this.cancelPointerInteraction);
162
240
  this.hideTooltip();
163
241
  this.core?.destroy();
164
242
  this.core = undefined;
@@ -192,7 +270,7 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
192
270
 
193
271
  if (request.scene.nodes.length === 0) {
194
272
  this.hideTooltip();
195
- if (this.core) replaceElements(this.core, request.scene);
273
+ if (this.core) replaceElements(this.core, request.scene, this.theme);
196
274
  this.appliedScene = request.scene;
197
275
  this.forceRelayout = false;
198
276
  if (this.fitTargetContentKey === request.scene.contentKey) this.fitTargetContentKey = undefined;
@@ -215,6 +293,7 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
215
293
  this.syncInteraction(request);
216
294
  this.consumeFitIfCurrentTarget();
217
295
  this.consumeAnchorIfCurrentTarget();
296
+ this.consumeReveal();
218
297
  this.hideStatus();
219
298
  this.processedVersion = version;
220
299
  continue;
@@ -235,16 +314,18 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
235
314
 
236
315
  if (geometryChanged || this.forceRelayout) {
237
316
  this.showStatus('layout', 'Laying out graph…');
238
- const result = await this.environment.layoutScene(this.elk!, request.scene);
317
+ const geometryRevision = this.geometryRevision;
318
+ const result = await this.environment.layoutScene(this.elk!, request.scene, this.theme);
319
+ if (geometryRevision !== this.geometryRevision) continue;
239
320
  const layoutRequest = this.resolveCurrentRequest(version, request.scene.contentKey);
240
321
  if (!layoutRequest) continue;
241
322
  ({ version, request } = layoutRequest);
242
- if (topologyChanged || !this.appliedScene) replaceElements(core, request.scene);
243
- else updateElementData(core, request.scene);
323
+ if (topologyChanged || !this.appliedScene) replaceElements(core, request.scene, this.theme);
324
+ else updateElementData(core, request.scene, this.theme);
244
325
  applyGraphLayout(core, request.scene, result);
245
326
  laidOut = true;
246
327
  } else {
247
- updateElementData(core, request.scene);
328
+ updateElementData(core, request.scene, this.theme);
248
329
  }
249
330
 
250
331
  syncExpandedGroupLabelGeometry(core);
@@ -264,6 +345,7 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
264
345
  restoreViewport(core, previousViewport);
265
346
  }
266
347
  this.consumeAnchorIfCurrentTarget();
348
+ this.consumeReveal();
267
349
  this.updateSemanticZoom(true);
268
350
  this.hideStatus();
269
351
  this.processedVersion = version;
@@ -301,7 +383,7 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
301
383
  this.core = runtime.createCore({
302
384
  container: this.canvasHost,
303
385
  elements: [],
304
- style: createGraphStyles(),
386
+ style: createGraphStyles(this.theme),
305
387
  layout: { name: 'preset' },
306
388
  minZoom: 0.03,
307
389
  maxZoom: 4,
@@ -319,25 +401,33 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
319
401
  if (!request) return;
320
402
  const id = event.target.id() as GraphSceneElementId;
321
403
  const selection = request.scene.interaction.selectionByElementId.get(id);
322
- if (selection) request.onSelect(selection);
323
- const now = performance.now();
324
- if (this.lastTap?.id === id && now - this.lastTap.at <= 350 && selection?.kind === 'group') {
404
+ const doubleTap = this.pendingGroupTap?.id === id;
405
+ this.cancelGroupTap();
406
+ if (selection?.kind !== 'group') {
407
+ if (selection) request.onSelect(selection);
408
+ } else if (doubleTap) {
325
409
  request.onToggleGroup(selection.pathKey);
326
- this.lastTap = undefined;
327
410
  } else {
328
- this.lastTap = { id, at: now };
411
+ // Defer group selection so expansion does not replace the inspected resource.
412
+ this.pendingGroupTap = { id, timer: setTimeout(() => {
413
+ this.pendingGroupTap = undefined;
414
+ this.interactiveRequest()?.onSelect(selection);
415
+ }, 350) };
329
416
  }
330
417
  };
331
418
  core.on('tap', 'node', selectElement);
332
419
  core.on('tap', 'edge', selectElement);
333
420
  core.on('tap', (event) => {
334
- if (event.target === core) this.lastTap = undefined;
421
+ if (event.target === core) this.cancelGroupTap();
335
422
  });
336
423
  const hoverElement = (event: cytoscape.EventObject) => {
337
424
  const request = this.interactiveRequest();
338
- if (!request) return;
425
+ if (!request || this.dragPanning) return;
339
426
  const id = event.target.id() as GraphSceneElementId;
340
427
  const selection = request.scene.interaction.selectionByElementId.get(id);
428
+ this.pointerInside = true;
429
+ this.pointerOverElement = !!selection;
430
+ this.updateCursor();
341
431
  if (selection) request.onHover(selection);
342
432
  const title = event.target.data('tooltip') as string | undefined;
343
433
  if (title) {
@@ -351,12 +441,21 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
351
441
  const moveTooltip = (event: cytoscape.EventObject) => this.positionTooltip(event.renderedPosition);
352
442
  core.on('mousemove', 'node', moveTooltip);
353
443
  core.on('mousemove', 'edge', moveTooltip);
354
- const clearHover = () => {
355
- this.hideTooltip();
356
- this.latestRequest?.onHover(undefined);
357
- };
444
+ const clearHover = () => this.clearHover();
358
445
  core.on('mouseout', 'node', clearHover);
359
446
  core.on('mouseout', 'edge', clearHover);
447
+ core.on('dragpan', () => {
448
+ if (this.dragPanning) return;
449
+ this.dragPanning = true;
450
+ this.cancelGroupTap();
451
+ this.clearHover();
452
+ });
453
+ core.on('tapend', (event) => {
454
+ this.dragPanning = false;
455
+ this.pointerOverElement = this.pointerInside && event.target !== core
456
+ && !!this.interactiveRequest()?.scene.interaction.selectionByElementId.get(event.target.id());
457
+ this.updateCursor();
458
+ });
360
459
  core.on('zoom', () => this.updateSemanticZoom());
361
460
  }
362
461
 
@@ -371,7 +470,7 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
371
470
  core.batch(() => {
372
471
  core.elements().removeClass('semantic-selected semantic-hover');
373
472
  if (request.hovered) {
374
- for (const id of request.scene.interaction.relatedElementIdsBySelection.get(selectionKey(request.hovered)) ?? []) {
473
+ for (const id of request.scene.interaction.hoverElementIdsBySelection.get(selectionKey(request.hovered)) ?? []) {
375
474
  core.getElementById(id).addClass('semantic-hover');
376
475
  }
377
476
  }
@@ -402,6 +501,23 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
402
501
  this.anchorTargetContentKey = undefined;
403
502
  }
404
503
 
504
+ private consumeReveal(): void {
505
+ const pending = this.pendingReveal;
506
+ const request = this.latestRequest;
507
+ if (!pending || !request || !this.core || this.targetNeedsApply()) return;
508
+ this.pendingReveal = undefined;
509
+ if (pending.captureRevision !== request.captureRevision || pending.contentKey !== request.scene.contentKey) return;
510
+ const ids = request.scene.interaction.primaryElementIdsBySelection.get(selectionKey(pending.request.selection)) ?? [];
511
+ const targets = this.core.collection();
512
+ for (const id of ids) targets.merge(this.core.getElementById(id));
513
+ if (!targets.length) return;
514
+ this.core.resize();
515
+ this.core.fit(targets, 64);
516
+ if (this.core.zoom() > 1.2) this.core.zoom(1.2);
517
+ this.core.center(targets);
518
+ this.updateSemanticZoom();
519
+ }
520
+
405
521
  private targetNeedsApply(): boolean {
406
522
  const target = this.latestRequest?.scene;
407
523
  if (!target || !this.appliedScene) return target !== undefined;
@@ -411,7 +527,7 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
411
527
  private updateSemanticZoom(force = false): void {
412
528
  const core = this.core;
413
529
  if (!core) return;
414
- const overview = isOverviewGraphScale(core.zoom());
530
+ const overview = isOverviewGraphScale(core.zoom(), this.theme);
415
531
  if (!force && overview === this.overview) return;
416
532
  this.overview = overview;
417
533
  core.batch(() => {
@@ -434,7 +550,22 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
434
550
  private hideTooltip(): void {
435
551
  this.tooltip.hidden = true;
436
552
  this.tooltip.textContent = '';
437
- this.lastTap = undefined;
553
+ }
554
+
555
+ private cancelGroupTap(): void {
556
+ if (this.pendingGroupTap) clearTimeout(this.pendingGroupTap.timer);
557
+ this.pendingGroupTap = undefined;
558
+ }
559
+
560
+ private clearHover(): void {
561
+ this.pointerOverElement = false;
562
+ this.updateCursor();
563
+ this.hideTooltip();
564
+ this.latestRequest?.onHover(undefined);
565
+ }
566
+
567
+ private updateCursor(): void {
568
+ this.canvasHost.style.cursor = this.dragPanning ? 'grabbing' : this.pointerOverElement ? 'pointer' : 'grab';
438
569
  }
439
570
 
440
571
  private showStatus(kind: 'loading' | 'layout' | 'empty' | 'error', message: string, retry = false): void {
@@ -502,9 +633,9 @@ const browserGraphRendererEnvironment: GraphRendererEnvironment = {
502
633
  },
503
634
  };
504
635
 
505
- function replaceElements(core: cytoscape.Core, scene: GraphScene): void {
636
+ function replaceElements(core: cytoscape.Core, scene: GraphScene, theme: GraphVisualTheme = GRAPH_VISUAL_THEME): void {
506
637
  const definitions: cytoscape.ElementDefinition[] = [
507
- ...scene.nodes.map(nodeDefinition),
638
+ ...scene.nodes.map(node => nodeDefinition(node, theme)),
508
639
  ...scene.edges.map(edgeDefinition),
509
640
  ];
510
641
  core.startBatch();
@@ -513,11 +644,11 @@ function replaceElements(core: cytoscape.Core, scene: GraphScene): void {
513
644
  core.endBatch();
514
645
  }
515
646
 
516
- export function updateElementData(core: cytoscape.Core, scene: GraphScene): void {
647
+ export function updateElementData(core: cytoscape.Core, scene: GraphScene, theme: GraphVisualTheme = GRAPH_VISUAL_THEME): void {
517
648
  core.batch(() => {
518
649
  for (const node of scene.nodes) {
519
650
  const element = core.getElementById(node.id);
520
- if (element.nonempty()) element.data(nodeRenderableData(node));
651
+ if (element.nonempty()) element.data(nodeRenderableData(node, theme));
521
652
  }
522
653
  for (const edge of scene.edges) {
523
654
  const element = core.getElementById(edge.id);
@@ -526,32 +657,41 @@ export function updateElementData(core: cytoscape.Core, scene: GraphScene): void
526
657
  });
527
658
  }
528
659
 
529
- function nodeDefinition(node: GraphSceneNode): cytoscape.ElementDefinition {
660
+ function nodeDefinition(node: GraphSceneNode, theme: GraphVisualTheme): cytoscape.ElementDefinition {
530
661
  return {
531
662
  group: 'nodes',
663
+ pannable: true,
532
664
  data: {
533
665
  id: node.id,
534
666
  parent: node.parentId,
535
- ...nodeRenderableData(node),
667
+ ...nodeRenderableData(node, theme),
536
668
  },
537
669
  };
538
670
  }
539
671
 
540
- function nodeRenderableData(node: GraphSceneNode): Record<string, unknown> {
541
- const dimensions = nodeDimensions(node);
672
+ function nodeRenderableData(node: GraphSceneNode, theme: GraphVisualTheme): Record<string, unknown> {
673
+ const dimensions = nodeDimensions(node, theme);
542
674
  const labelMaxWidth = node.kind === 'group' && node.collapsed
543
675
  ? expandedGroupLabelMaxWidth(dimensions.width)
544
- : 160;
676
+ : 160 * theme.fontSize / GRAPH_GEOMETRY.baseFontSize;
677
+ const fitLabel = (label: string): string => node.kind !== 'group'
678
+ ? fitGraphLabel(label, labelMaxWidth, theme)
679
+ : node.collapsed ? fitCollapsedGroupLabel(label, labelMaxWidth, theme) : label;
680
+ const detailLabel = fitLabel(node.label);
681
+ const labelFontSize = node.kind === 'group' && !node.collapsed
682
+ ? theme.fontSize * 1.2
683
+ : graphLabelFontSize(detailLabel, labelMaxWidth, dimensions.height - 12 * theme.fontSize / GRAPH_GEOMETRY.baseFontSize, theme);
545
684
  return {
546
685
  kind: node.kind,
547
- passKind: node.kind === 'pass' || node.kind === 'culled-pass' ? node.passKind : undefined,
548
- resourceKind: node.kind === 'resource' ? node.resourceKind : undefined,
686
+ passKind: node.kind === 'pass' ? node.passKind : undefined,
687
+ resourceKind: (node.kind === 'resource' || node.kind === 'root') ? node.resourceKind : undefined,
549
688
  collapsed: node.kind === 'group' && node.collapsed ? 1 : 0,
550
689
  hasCulled: node.kind === 'group' && node.culledNodeCount > 0 ? 1 : 0,
551
690
  depthBand: node.kind === 'group' ? node.depthBand : undefined,
552
- detailLabel: node.label,
553
- overviewLabel: node.overviewLabel,
554
- displayLabel: node.label,
691
+ detailLabel,
692
+ labelFontSize,
693
+ overviewLabel: fitLabel(node.overviewLabel),
694
+ displayLabel: detailLabel,
555
695
  tooltip: node.title,
556
696
  labelMaxWidth,
557
697
  width: dimensions.width,
@@ -575,9 +715,6 @@ function edgeRenderableData(edge: GraphSceneEdge): Record<string, unknown> {
575
715
  const detailLabel = graphEdgeDisplayLabel(edge);
576
716
  return {
577
717
  kind: edge.kind,
578
- dependencyKind: edge.kind === 'dependency' ? edge.dependencyKind : undefined,
579
- accessMode: edge.kind === 'access' ? edge.accessMode : undefined,
580
- dashed: edge.kind === 'access' && edge.dashed ? 1 : 0,
581
718
  detailLabel,
582
719
  displayLabel: detailLabel,
583
720
  tooltip: edge.title,
@@ -0,0 +1,155 @@
1
+ const MIN_DETAIL_WIDTH = 300;
2
+ const MAX_DETAIL_WIDTH = 480;
3
+ const MIN_MAIN_WIDTH = 520;
4
+ const DIVIDER_WIDTH = 8;
5
+
6
+ /** Owns the dock/drawer geometry and modal keyboard boundary for one workbench. */
7
+ export class DetailLayout {
8
+ private readonly backdrop = document.createElement('button');
9
+ private readonly divider = document.createElement('div');
10
+ private readonly resizeObserver: ResizeObserver | undefined;
11
+ private readonly abort = new AbortController();
12
+ private open = false;
13
+ private preferredWidth = MIN_DETAIL_WIDTH;
14
+ private previousFocus: HTMLElement | undefined;
15
+ private dragging = false;
16
+ private modal = false;
17
+
18
+ constructor(
19
+ private readonly workspace: HTMLElement,
20
+ private readonly main: HTMLElement,
21
+ private readonly aside: HTMLElement,
22
+ private readonly chrome: HTMLElement,
23
+ private readonly onClose: () => void,
24
+ ) {
25
+ this.backdrop.type = 'button';
26
+ this.backdrop.className = 'zenfg-inspector-detail-backdrop';
27
+ this.backdrop.setAttribute('aria-label', 'Close selection inspector');
28
+ this.backdrop.tabIndex = -1;
29
+ this.backdrop.hidden = true;
30
+ this.backdrop.addEventListener('click', onClose);
31
+ this.divider.className = 'zenfg-inspector-detail-divider';
32
+ this.divider.setAttribute('role', 'separator');
33
+ this.divider.setAttribute('aria-label', 'Resize selection inspector');
34
+ this.divider.setAttribute('aria-orientation', 'vertical');
35
+ this.divider.tabIndex = 0;
36
+ this.divider.hidden = true;
37
+ workspace.insertBefore(this.divider, aside);
38
+ workspace.insertBefore(this.backdrop, aside);
39
+ this.divider.addEventListener('pointerdown', (event) => {
40
+ this.dragging = true;
41
+ this.divider.setPointerCapture(event.pointerId);
42
+ event.preventDefault();
43
+ });
44
+ this.divider.addEventListener('pointermove', (event) => {
45
+ if (!this.dragging) return;
46
+ this.preferredWidth = this.clampWidth(workspace.getBoundingClientRect().right - event.clientX);
47
+ this.applyGeometry();
48
+ });
49
+ this.divider.addEventListener('pointerup', () => { this.dragging = false; });
50
+ this.divider.addEventListener('lostpointercapture', () => { this.dragging = false; });
51
+ this.divider.addEventListener('keydown', (event) => {
52
+ if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
53
+ event.preventDefault();
54
+ event.stopPropagation();
55
+ this.preferredWidth = this.clampWidth(event.key === 'Home' ? MIN_DETAIL_WIDTH : event.key === 'End' ? MAX_DETAIL_WIDTH
56
+ : this.preferredWidth + (event.key === 'ArrowLeft' ? 20 : -20));
57
+ this.applyGeometry();
58
+ });
59
+ workspace.parentElement?.addEventListener('keydown', this.handleKey, { signal: this.abort.signal });
60
+ this.resizeObserver = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(() => this.applyGeometry());
61
+ this.resizeObserver?.observe(workspace);
62
+ }
63
+
64
+ get isDrawer(): boolean { return this.modal; }
65
+
66
+ update(open: boolean): void {
67
+ const changed = open !== this.open;
68
+ if (changed && open) this.previousFocus = this.workspace.ownerDocument.activeElement as HTMLElement | undefined;
69
+ this.open = open;
70
+ this.applyGeometry();
71
+ if (changed && !open) this.restoreFocus();
72
+ }
73
+
74
+ destroy(): void {
75
+ this.abort.abort();
76
+ this.resizeObserver?.disconnect();
77
+ this.main.inert = false;
78
+ this.chrome.inert = false;
79
+ for (const sibling of this.chrome.parentElement?.children ?? []) if (sibling !== this.workspace) (sibling as HTMLElement).inert = false;
80
+ if (this.open && this.modal) this.restoreFocus();
81
+ this.open = false;
82
+ }
83
+
84
+ private restoreFocus(): void {
85
+ const isAvailable = (element: HTMLElement | undefined): element is HTMLElement => {
86
+ if (!element?.isConnected || element === element.ownerDocument.body || element.closest('[hidden], [inert]') || element.matches(':disabled')) return false;
87
+ for (let parent: HTMLElement | null = element; parent; parent = parent.parentElement) if (parent.inert) return false;
88
+ return true;
89
+ };
90
+ const target = isAvailable(this.previousFocus) ? this.previousFocus
91
+ : this.chrome.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]') ?? undefined;
92
+ if (isAvailable(target)) target.focus({ preventScroll: true });
93
+ }
94
+
95
+ private clampWidth(width: number): number {
96
+ const available = this.workspace.getBoundingClientRect().width || 1024;
97
+ return Math.max(MIN_DETAIL_WIDTH, Math.min(MAX_DETAIL_WIDTH, available - MIN_MAIN_WIDTH - DIVIDER_WIDTH, width));
98
+ }
99
+
100
+ private applyGeometry(): void {
101
+ const wasModal = this.modal;
102
+ const available = this.workspace.getBoundingClientRect().width || 1024;
103
+ this.modal = available < MIN_MAIN_WIDTH + DIVIDER_WIDTH + MIN_DETAIL_WIDTH;
104
+ if (this.modal && !wasModal && this.open && !this.aside.contains(document.activeElement)) this.previousFocus = this.workspace.ownerDocument.activeElement as HTMLElement | undefined;
105
+ const width = this.clampWidth(this.preferredWidth);
106
+ this.workspace.style.setProperty('--fgd-detail-width', `${width}px`);
107
+ this.workspace.classList.toggle('detail-drawer', this.modal);
108
+ this.workspace.classList.toggle('inspector-open', this.open);
109
+ this.divider.hidden = !this.open || this.modal;
110
+ this.divider.setAttribute('aria-valuemin', String(MIN_DETAIL_WIDTH));
111
+ this.divider.setAttribute('aria-valuemax', String(Math.max(MIN_DETAIL_WIDTH, Math.min(MAX_DETAIL_WIDTH, available - MIN_MAIN_WIDTH - DIVIDER_WIDTH))));
112
+ this.divider.setAttribute('aria-valuenow', String(Math.round(width)));
113
+ this.backdrop.hidden = !this.open || !this.modal;
114
+ this.main.inert = this.open && this.modal;
115
+ this.chrome.inert = this.open && this.modal;
116
+ for (const sibling of this.chrome.parentElement?.children ?? []) if (sibling !== this.workspace) (sibling as HTMLElement).inert = this.open && this.modal;
117
+ if (this.open && this.modal) {
118
+ this.aside.setAttribute('role', 'dialog');
119
+ this.aside.setAttribute('aria-modal', 'true');
120
+ if (!this.aside.contains(document.activeElement)) this.focusable()[0]?.focus();
121
+ } else {
122
+ this.aside.removeAttribute('role');
123
+ this.aside.removeAttribute('aria-modal');
124
+ }
125
+ if (wasModal !== this.modal && this.open) this.workspace.dispatchEvent(new Event('detail-layout-change'));
126
+ }
127
+
128
+ private focusable(): HTMLElement[] {
129
+ return Array.from(this.aside.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), summary, [tabindex="0"]'))
130
+ .filter((element) => {
131
+ if (element.closest('[hidden]') || element.tabIndex < 0 && element.tagName !== 'SUMMARY') return false;
132
+ for (let parent = element.parentElement; parent && parent !== this.aside; parent = parent.parentElement) {
133
+ if (parent.tagName === 'DETAILS' && !parent.hasAttribute('open') && !parent.querySelector(':scope > summary')?.contains(element)) return false;
134
+ }
135
+ return true;
136
+ });
137
+ }
138
+
139
+ private readonly handleKey = (event: KeyboardEvent): void => {
140
+ if (event.defaultPrevented || !this.open) return;
141
+ if (event.key === 'Escape') {
142
+ event.preventDefault();
143
+ event.stopPropagation();
144
+ this.onClose();
145
+ } else if (this.modal && event.key === 'Tab') {
146
+ const items = this.focusable();
147
+ if (!items.length) { event.preventDefault(); return; }
148
+ const index = items.indexOf(document.activeElement as HTMLElement);
149
+ if (event.shiftKey && index <= 0 || !event.shiftKey && (index === items.length - 1 || index < 0)) {
150
+ event.preventDefault();
151
+ (event.shiftKey ? items.at(-1) : items[0])?.focus();
152
+ }
153
+ }
154
+ };
155
+ }
@@ -0,0 +1,56 @@
1
+ export const PANEL_DETAIL_CSS = `
2
+ .zenfg-inspector-detail-actions,
3
+ .zenfg-inspector-relation-links,
4
+ .zenfg-inspector-copy-id,
5
+ .zenfg-inspector-raw-toolbar {
6
+ display: flex;
7
+ align-items: center;
8
+ flex-wrap: wrap;
9
+ gap: 6px 10px;
10
+ min-width: 0;
11
+ }
12
+ .zenfg-inspector-detail-actions { margin-bottom: var(--fgd-space-3); }
13
+ .zenfg-inspector-detail-actions:empty { display: none; }
14
+ .zenfg-inspector-inline-action,
15
+ .zenfg-inspector-copy-id button,
16
+ .zenfg-inspector-raw-toolbar button,
17
+ .zenfg-inspector-capture-summary section > button,
18
+ .zenfg-inspector-graph-search-results > button {
19
+ padding: 5px var(--fgd-space-2); border: 1px solid var(--fgd-border); border-radius: var(--fgd-radius-sm);
20
+ color: var(--fgd-text-secondary); background: var(--fgd-surface);
21
+ font: var(--fgd-font-size)/1.4 var(--fgd-font-ui); cursor: pointer;
22
+ }
23
+ .zenfg-inspector-inline-action:hover,
24
+ .zenfg-inspector-copy-id button:hover,
25
+ .zenfg-inspector-raw-toolbar button:hover,
26
+ .zenfg-inspector-capture-summary section > button:hover,
27
+ .zenfg-inspector-graph-search-results > button:hover { color: var(--fgd-text); background: var(--fgd-surface-hover); }
28
+ .zenfg-inspector-raw-toolbar input {
29
+ padding: 6px var(--fgd-space-2); border: 1px solid var(--fgd-border); border-radius: var(--fgd-radius-sm);
30
+ color: var(--fgd-text); background: var(--fgd-panel); font: var(--fgd-font-size) var(--fgd-font-ui);
31
+ }
32
+ .zenfg-inspector-inspector-summary dd,
33
+ .zenfg-inspector-relation-entry,
34
+ .zenfg-inspector-detail-diagnostics { min-width: 0; overflow-wrap: anywhere; }
35
+ .zenfg-inspector-inspector-summary { grid-template-columns: 108px minmax(0, 1fr); font-size: var(--fgd-font-size-small); }
36
+ .zenfg-inspector-inspector-summary dd { font-family: var(--fgd-font-ui); }
37
+ .zenfg-inspector-inspector-relations .zenfg-inspector-relation-button,
38
+ .zenfg-inspector-relation-entry > span { font: var(--fgd-font-size)/1.5 var(--fgd-font-ui); }
39
+ .zenfg-inspector-relation-links .zenfg-inspector-relation-button { flex: 1 1 130px; min-width: 0; }
40
+ .zenfg-inspector-relation-links .zenfg-inspector-inline-action { flex: 0 0 auto; }
41
+ .zenfg-inspector-copy-id code { overflow-wrap: anywhere; }
42
+ .zenfg-inspector-inspector-content > details { margin-top: 14px; }
43
+ .zenfg-inspector-inspector-content summary { cursor: pointer; }
44
+ .zenfg-inspector-inspector-content h3 { font-size: var(--fgd-font-size-small); margin: 14px 0 6px; }
45
+ .zenfg-inspector-detail-diagnostics article { padding: var(--fgd-space-2) 0; border-bottom: 1px solid var(--fgd-border); }
46
+ .zenfg-inspector-detail-diagnostics p { margin: 6px 0; white-space: pre-wrap; }
47
+ .zenfg-inspector-detail-diagnostics article[data-severity="error"] > strong { color: var(--fgd-danger); }
48
+ .zenfg-inspector-detail-diagnostics article[data-severity="warning"] > strong { color: var(--fgd-warning); }
49
+ .zenfg-inspector-raw-view { min-width: 0; display: grid; gap: 10px; }
50
+ .zenfg-inspector-raw-path { overflow-wrap: anywhere; font-size: var(--fgd-font-size-small); }
51
+ .zenfg-inspector-raw-toolbar input { flex: 1 1 170px; min-width: 0; }
52
+ .zenfg-inspector-inspector .zenfg-inspector-raw-detail { white-space: normal; overflow: visible; overflow-wrap: anywhere; font: var(--fgd-font-size-small)/1.55 var(--fgd-font-mono); }
53
+ .zenfg-inspector-raw-children { padding-left: var(--fgd-space-3); border-left: 1px solid var(--fgd-border); }
54
+ .zenfg-inspector-raw-detail summary, .zenfg-inspector-raw-leaf { padding: 3px 0; }
55
+ .zenfg-inspector-inspector-content :is(button, input, summary):focus-visible { outline: 2px solid var(--fgd-accent); outline-offset: 2px; }
56
+ `;