@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,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,6 +71,15 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
64
71
  private anchorTargetContentKey: string | undefined;
65
72
  private failedSceneContentKey: string | undefined;
66
73
  private overview = false;
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;
67
83
  private pendingGroupTap: { readonly id: string; readonly timer: ReturnType<typeof setTimeout> } | undefined;
68
84
 
69
85
  constructor(
@@ -72,11 +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;
78
95
  // Cytoscape's node mouseout is not guaranteed when leaving its canvas directly.
79
- this.canvasHost.addEventListener('pointerleave', () => this.clearHover());
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);
80
100
  this.status = environment.createElement('div');
81
101
  this.status.className = 'zenfg-inspector-graph-status';
82
102
  this.status.hidden = true;
@@ -90,11 +110,17 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
90
110
  this.retryButton.addEventListener('click', () => this.relayout());
91
111
  this.status.append(this.statusMessage, this.retryButton);
92
112
  this.host.replaceChildren(this.canvasHost, this.status, this.tooltip);
93
- 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
+ });
94
119
  }
95
120
 
96
121
  render(request: GraphRenderRequest): void {
97
122
  if (this.destroyed) return;
123
+ if (request.theme) this.setTheme(request.theme);
98
124
  if (this.latestRequest?.scene !== request.scene
99
125
  || (this.latestRequest?.selected && selectionKey(this.latestRequest.selected)) !== (request.selected && selectionKey(request.selected))) {
100
126
  this.cancelGroupTap();
@@ -104,6 +130,13 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
104
130
  this.core?.elements().removeClass('semantic-hover');
105
131
  }
106
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
+ }
107
140
  if (request.fit) {
108
141
  this.fitTargetContentKey = request.scene.contentKey;
109
142
  } else if (this.fitTargetContentKey !== request.scene.contentKey) {
@@ -132,16 +165,48 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
132
165
  this.syncInteraction(request);
133
166
  this.consumeFitIfCurrentTarget();
134
167
  this.consumeAnchorIfCurrentTarget();
168
+ this.consumeReveal();
135
169
  this.processedVersion = version;
136
170
  return;
137
171
  }
138
172
  this.scheduleProcessing();
139
173
  }
140
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
+
141
200
  resize(): void {
142
201
  this.core?.resize();
143
202
  }
144
203
 
204
+ cancelReveal(): void {
205
+ this.pendingReveal = undefined;
206
+ this.cancelGroupTap();
207
+ this.clearHover();
208
+ }
209
+
145
210
  fit(): void {
146
211
  const targetContentKey = this.latestRequest?.scene.contentKey;
147
212
  if (targetContentKey) this.fitTargetContentKey = targetContentKey;
@@ -171,6 +236,7 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
171
236
  this.cancelGroupTap();
172
237
  this.requestVersion++;
173
238
  this.resizeSubscription?.disconnect();
239
+ this.canvasHost.ownerDocument?.defaultView?.removeEventListener('blur', this.cancelPointerInteraction);
174
240
  this.hideTooltip();
175
241
  this.core?.destroy();
176
242
  this.core = undefined;
@@ -204,7 +270,7 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
204
270
 
205
271
  if (request.scene.nodes.length === 0) {
206
272
  this.hideTooltip();
207
- if (this.core) replaceElements(this.core, request.scene);
273
+ if (this.core) replaceElements(this.core, request.scene, this.theme);
208
274
  this.appliedScene = request.scene;
209
275
  this.forceRelayout = false;
210
276
  if (this.fitTargetContentKey === request.scene.contentKey) this.fitTargetContentKey = undefined;
@@ -227,6 +293,7 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
227
293
  this.syncInteraction(request);
228
294
  this.consumeFitIfCurrentTarget();
229
295
  this.consumeAnchorIfCurrentTarget();
296
+ this.consumeReveal();
230
297
  this.hideStatus();
231
298
  this.processedVersion = version;
232
299
  continue;
@@ -247,16 +314,18 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
247
314
 
248
315
  if (geometryChanged || this.forceRelayout) {
249
316
  this.showStatus('layout', 'Laying out graph…');
250
- 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;
251
320
  const layoutRequest = this.resolveCurrentRequest(version, request.scene.contentKey);
252
321
  if (!layoutRequest) continue;
253
322
  ({ version, request } = layoutRequest);
254
- if (topologyChanged || !this.appliedScene) replaceElements(core, request.scene);
255
- else updateElementData(core, request.scene);
323
+ if (topologyChanged || !this.appliedScene) replaceElements(core, request.scene, this.theme);
324
+ else updateElementData(core, request.scene, this.theme);
256
325
  applyGraphLayout(core, request.scene, result);
257
326
  laidOut = true;
258
327
  } else {
259
- updateElementData(core, request.scene);
328
+ updateElementData(core, request.scene, this.theme);
260
329
  }
261
330
 
262
331
  syncExpandedGroupLabelGeometry(core);
@@ -276,6 +345,7 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
276
345
  restoreViewport(core, previousViewport);
277
346
  }
278
347
  this.consumeAnchorIfCurrentTarget();
348
+ this.consumeReveal();
279
349
  this.updateSemanticZoom(true);
280
350
  this.hideStatus();
281
351
  this.processedVersion = version;
@@ -313,7 +383,7 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
313
383
  this.core = runtime.createCore({
314
384
  container: this.canvasHost,
315
385
  elements: [],
316
- style: createGraphStyles(),
386
+ style: createGraphStyles(this.theme),
317
387
  layout: { name: 'preset' },
318
388
  minZoom: 0.03,
319
389
  maxZoom: 4,
@@ -352,9 +422,12 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
352
422
  });
353
423
  const hoverElement = (event: cytoscape.EventObject) => {
354
424
  const request = this.interactiveRequest();
355
- if (!request) return;
425
+ if (!request || this.dragPanning) return;
356
426
  const id = event.target.id() as GraphSceneElementId;
357
427
  const selection = request.scene.interaction.selectionByElementId.get(id);
428
+ this.pointerInside = true;
429
+ this.pointerOverElement = !!selection;
430
+ this.updateCursor();
358
431
  if (selection) request.onHover(selection);
359
432
  const title = event.target.data('tooltip') as string | undefined;
360
433
  if (title) {
@@ -371,6 +444,18 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
371
444
  const clearHover = () => this.clearHover();
372
445
  core.on('mouseout', 'node', clearHover);
373
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
+ });
374
459
  core.on('zoom', () => this.updateSemanticZoom());
375
460
  }
376
461
 
@@ -416,6 +501,23 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
416
501
  this.anchorTargetContentKey = undefined;
417
502
  }
418
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
+
419
521
  private targetNeedsApply(): boolean {
420
522
  const target = this.latestRequest?.scene;
421
523
  if (!target || !this.appliedScene) return target !== undefined;
@@ -425,7 +527,7 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
425
527
  private updateSemanticZoom(force = false): void {
426
528
  const core = this.core;
427
529
  if (!core) return;
428
- const overview = isOverviewGraphScale(core.zoom());
530
+ const overview = isOverviewGraphScale(core.zoom(), this.theme);
429
531
  if (!force && overview === this.overview) return;
430
532
  this.overview = overview;
431
533
  core.batch(() => {
@@ -456,10 +558,16 @@ export class CytoscapeGraphRenderer implements GraphRenderer {
456
558
  }
457
559
 
458
560
  private clearHover(): void {
561
+ this.pointerOverElement = false;
562
+ this.updateCursor();
459
563
  this.hideTooltip();
460
564
  this.latestRequest?.onHover(undefined);
461
565
  }
462
566
 
567
+ private updateCursor(): void {
568
+ this.canvasHost.style.cursor = this.dragPanning ? 'grabbing' : this.pointerOverElement ? 'pointer' : 'grab';
569
+ }
570
+
463
571
  private showStatus(kind: 'loading' | 'layout' | 'empty' | 'error', message: string, retry = false): void {
464
572
  this.status.dataset.state = kind;
465
573
  this.statusMessage.textContent = message;
@@ -525,9 +633,9 @@ const browserGraphRendererEnvironment: GraphRendererEnvironment = {
525
633
  },
526
634
  };
527
635
 
528
- function replaceElements(core: cytoscape.Core, scene: GraphScene): void {
636
+ function replaceElements(core: cytoscape.Core, scene: GraphScene, theme: GraphVisualTheme = GRAPH_VISUAL_THEME): void {
529
637
  const definitions: cytoscape.ElementDefinition[] = [
530
- ...scene.nodes.map(nodeDefinition),
638
+ ...scene.nodes.map(node => nodeDefinition(node, theme)),
531
639
  ...scene.edges.map(edgeDefinition),
532
640
  ];
533
641
  core.startBatch();
@@ -536,11 +644,11 @@ function replaceElements(core: cytoscape.Core, scene: GraphScene): void {
536
644
  core.endBatch();
537
645
  }
538
646
 
539
- export function updateElementData(core: cytoscape.Core, scene: GraphScene): void {
647
+ export function updateElementData(core: cytoscape.Core, scene: GraphScene, theme: GraphVisualTheme = GRAPH_VISUAL_THEME): void {
540
648
  core.batch(() => {
541
649
  for (const node of scene.nodes) {
542
650
  const element = core.getElementById(node.id);
543
- if (element.nonempty()) element.data(nodeRenderableData(node));
651
+ if (element.nonempty()) element.data(nodeRenderableData(node, theme));
544
652
  }
545
653
  for (const edge of scene.edges) {
546
654
  const element = core.getElementById(edge.id);
@@ -549,22 +657,30 @@ export function updateElementData(core: cytoscape.Core, scene: GraphScene): void
549
657
  });
550
658
  }
551
659
 
552
- function nodeDefinition(node: GraphSceneNode): cytoscape.ElementDefinition {
660
+ function nodeDefinition(node: GraphSceneNode, theme: GraphVisualTheme): cytoscape.ElementDefinition {
553
661
  return {
554
662
  group: 'nodes',
663
+ pannable: true,
555
664
  data: {
556
665
  id: node.id,
557
666
  parent: node.parentId,
558
- ...nodeRenderableData(node),
667
+ ...nodeRenderableData(node, theme),
559
668
  },
560
669
  };
561
670
  }
562
671
 
563
- function nodeRenderableData(node: GraphSceneNode): Record<string, unknown> {
564
- const dimensions = nodeDimensions(node);
672
+ function nodeRenderableData(node: GraphSceneNode, theme: GraphVisualTheme): Record<string, unknown> {
673
+ const dimensions = nodeDimensions(node, theme);
565
674
  const labelMaxWidth = node.kind === 'group' && node.collapsed
566
675
  ? expandedGroupLabelMaxWidth(dimensions.width)
567
- : 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);
568
684
  return {
569
685
  kind: node.kind,
570
686
  passKind: node.kind === 'pass' ? node.passKind : undefined,
@@ -572,9 +688,10 @@ function nodeRenderableData(node: GraphSceneNode): Record<string, unknown> {
572
688
  collapsed: node.kind === 'group' && node.collapsed ? 1 : 0,
573
689
  hasCulled: node.kind === 'group' && node.culledNodeCount > 0 ? 1 : 0,
574
690
  depthBand: node.kind === 'group' ? node.depthBand : undefined,
575
- detailLabel: node.label,
576
- overviewLabel: node.overviewLabel,
577
- displayLabel: node.label,
691
+ detailLabel,
692
+ labelFontSize,
693
+ overviewLabel: fitLabel(node.overviewLabel),
694
+ displayLabel: detailLabel,
578
695
  tooltip: node.title,
579
696
  labelMaxWidth,
580
697
  width: dimensions.width,
@@ -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
+ `;