@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
@@ -0,0 +1,96 @@
1
+ import type { FrameGraphDebugViewModel } from './debugCaptureModel.ts';
2
+ import { labelNode, labelResource } from './panelDomHelpers.ts';
3
+ import type { Selection } from './panelTypes.ts';
4
+
5
+ type SearchEntry = { readonly label: string; readonly kind: string; readonly text: string; readonly selection: Selection };
6
+
7
+ export class GraphSearch {
8
+ readonly root = document.createElement('div');
9
+ private readonly toggle = document.createElement('button');
10
+ private readonly popover = document.createElement('div');
11
+ private readonly input = document.createElement('input');
12
+ private readonly results = document.createElement('div');
13
+ private entries: SearchEntry[] = [];
14
+
15
+ constructor(private readonly onReveal: (selection: Selection) => void, id: string) {
16
+ this.root.className = 'zenfg-inspector-graph-search';
17
+ this.toggle.type = 'button';
18
+ this.toggle.textContent = 'Search';
19
+ this.toggle.setAttribute('aria-expanded', 'false');
20
+ this.toggle.setAttribute('aria-controls', id);
21
+ this.toggle.addEventListener('click', () => this.setOpen(this.popover.hidden !== false));
22
+ this.popover.className = 'zenfg-inspector-graph-search-popover';
23
+ this.popover.id = id;
24
+ this.popover.hidden = true;
25
+ this.input.type = 'search';
26
+ this.input.placeholder = 'Find pass, resource, group or output';
27
+ this.input.setAttribute('aria-label', 'Find in graph');
28
+ this.results.className = 'zenfg-inspector-graph-search-results';
29
+ this.results.setAttribute('aria-label', 'Graph search results');
30
+ this.results.hidden = true;
31
+ this.input.addEventListener('input', () => this.render());
32
+ this.root.addEventListener('keydown', (event) => {
33
+ if (event.key === 'Escape' && !this.popover.hidden) {
34
+ event.preventDefault();
35
+ event.stopPropagation();
36
+ this.setOpen(false);
37
+ } else if (event.key === 'ArrowDown' && event.target === this.input) {
38
+ event.preventDefault();
39
+ this.results.querySelector('button')?.focus();
40
+ }
41
+ });
42
+ this.popover.append(this.input, this.results);
43
+ this.root.append(this.toggle, this.popover);
44
+ }
45
+
46
+ private setOpen(open: boolean): void {
47
+ this.popover.hidden = !open;
48
+ this.toggle.setAttribute('aria-expanded', String(open));
49
+ if (open) this.input.focus();
50
+ else {
51
+ this.input.value = '';
52
+ this.results.hidden = true;
53
+ this.toggle.focus();
54
+ }
55
+ }
56
+
57
+ setSnapshot(snapshot: FrameGraphDebugViewModel): void {
58
+ this.entries = [
59
+ ...snapshot.nodes.map((node): SearchEntry => ({ label: labelNode(node), kind: 'Pass',
60
+ text: `${node.id} ${labelNode(node)} ${node.debugGroupId ? snapshot.groupById.get(node.debugGroupId)?.path.join(' / ') : ''}`,
61
+ selection: { kind: 'node', id: node.id } })),
62
+ ...snapshot.resources.map((resource): SearchEntry => ({ label: labelResource(resource), kind: 'Resource',
63
+ text: `${resource.id} ${labelResource(resource)}`, selection: { kind: 'resource', id: resource.id } })),
64
+ ...snapshot.debugGroups.map((group): SearchEntry => ({ label: group.path.join(' / '), kind: 'Group',
65
+ text: `${group.id} ${group.path.join(' / ')}`, selection: { kind: 'group', pathKey: group.pathKey } })),
66
+ ...snapshot.roots.filter((root) => root.reason !== 'side-effect').map((root): SearchEntry => ({
67
+ label: `${root.reason} · ${root.resource ? labelResource(root.resource) : root.key}`, kind: 'Output',
68
+ text: `${root.reason} ${root.resource?.id} ${root.resource?.label}`, selection: { kind: 'root', key: root.key },
69
+ })),
70
+ ];
71
+ this.render();
72
+ }
73
+
74
+ private render(): void {
75
+ const query = this.input.value.trim().toLocaleLowerCase();
76
+ this.results.replaceChildren();
77
+ this.results.hidden = !query;
78
+ if (!query) return;
79
+ const matches = this.entries.filter((entry) => entry.text.toLocaleLowerCase().includes(query));
80
+ const count = document.createElement('p');
81
+ count.setAttribute('role', 'status');
82
+ count.textContent = matches.length > 50 ? `Showing 50 of ${matches.length} results. Refine your search.` : `${matches.length} results`;
83
+ this.results.append(count);
84
+ for (const entry of matches.slice(0, 50)) {
85
+ const button = document.createElement('button');
86
+ button.type = 'button';
87
+ button.textContent = `${entry.kind} · ${entry.label}`;
88
+ button.title = `Show in Graph · ${entry.label}`;
89
+ button.addEventListener('click', () => {
90
+ this.setOpen(false);
91
+ this.onReveal(entry.selection);
92
+ });
93
+ this.results.append(button);
94
+ }
95
+ }
96
+ }
@@ -1,3 +1,4 @@
1
+ import { GRAPH_VISUAL_THEME } from './panelVisualTheme.ts';
1
2
  import type { FrameGraphDebugViewModel } from './debugCaptureModel.ts';
2
3
  import { CytoscapeGraphRenderer } from './panelCytoscapeGraphRenderer.ts';
3
4
  import { createGraphScene, type GraphScene } from './panelGraphScene.ts';
@@ -22,7 +23,8 @@ export function renderGraphView(
22
23
  onToggleGroup: (pathKey: string) => void,
23
24
  ): void {
24
25
  const scene = resolveGraphScene(graphView, snapshot);
25
- renderGraphLegend(graphView.legend, snapshot);
26
+ if (!graphView.theme) graphView.refreshTheme?.();
27
+ renderGraphLegend(graphView.legend, snapshot, graphView.theme, graphView.showResourceDeclarations);
26
28
  const elementCount = scene.nodes.length + scene.edges.length;
27
29
  const layoutElementBudget = graphView.layoutElementBudget ?? Number.MAX_SAFE_INTEGER;
28
30
  if (elementCount > layoutElementBudget) {
@@ -39,16 +41,21 @@ export function renderGraphView(
39
41
  graphView.renderer ??= new CytoscapeGraphRenderer(graphView.host);
40
42
  graphView.renderer.render({
41
43
  scene,
44
+ theme: graphView.theme,
45
+ onVisible: graphView.refreshTheme,
42
46
  selected,
43
47
  hovered,
44
48
  fit: graphView.fitOnNextRender,
45
49
  anchorElementId: graphView.anchorElementIdOnNextRender,
50
+ reveal: graphView.revealOnNextRender,
51
+ captureRevision: graphView.captureRevision,
46
52
  onSelect,
47
53
  onHover,
48
54
  onToggleGroup,
49
55
  });
50
56
  graphView.fitOnNextRender = false;
51
57
  graphView.anchorElementIdOnNextRender = undefined;
58
+ graphView.revealOnNextRender = undefined;
52
59
  }
53
60
 
54
61
  export function resolveGraphScene(
@@ -57,12 +64,14 @@ export function resolveGraphScene(
57
64
  ): GraphScene {
58
65
  const optionsKey = JSON.stringify([
59
66
  graphView.groupsEnabled,
67
+ graphView.showResourceDeclarations ?? true,
60
68
  [...graphView.expandedGroupPaths].sort(),
61
69
  ]);
62
70
  const cached = graphSceneCache.get(graphView);
63
71
  if (cached?.snapshot === snapshot && cached.optionsKey === optionsKey) return cached.scene;
64
72
  const scene = createGraphScene(snapshot, {
65
73
  groupsEnabled: graphView.groupsEnabled,
74
+ showResourceDeclarations: graphView.showResourceDeclarations,
66
75
  expandedGroupPaths: graphView.expandedGroupPaths,
67
76
  });
68
77
  graphSceneCache.set(graphView, { snapshot, optionsKey, scene });
@@ -74,6 +83,7 @@ export function fitGraph(graphView: GraphViewState): void {
74
83
  }
75
84
 
76
85
  export function resizeGraph(graphView: GraphViewState): void {
86
+ graphView.refreshTheme?.();
77
87
  graphView.renderer?.resize();
78
88
  }
79
89
 
@@ -83,9 +93,9 @@ export function destroyGraph(graphView: GraphViewState): void {
83
93
  graphSceneCache.delete(graphView);
84
94
  }
85
95
 
86
- function renderGraphLegend(host: HTMLElement | undefined, snapshot: FrameGraphDebugViewModel): void {
96
+ export function renderGraphLegend(host: HTMLElement | undefined, snapshot: FrameGraphDebugViewModel, theme = GRAPH_VISUAL_THEME, showResourceDeclarations = true): void {
87
97
  if (!host) return;
88
- const entries = createGraphLegend(snapshot);
98
+ const entries = createGraphLegend(snapshot, theme, showResourceDeclarations);
89
99
  const key = JSON.stringify(entries);
90
100
  if (host.dataset.legendKey === key) return;
91
101
  host.dataset.legendKey = key;
@@ -117,7 +127,7 @@ function renderGraphLegend(host: HTMLElement | undefined, snapshot: FrameGraphDe
117
127
  svg.setAttribute('aria-hidden', 'true');
118
128
  const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
119
129
  polygon.setAttribute('points', entry.shape === 'tag'
120
- ? '1,1 12,1 19,7 12,13 1,13'
130
+ ? '1,1 16,1 19,7 16,13 1,13'
121
131
  : '4,1 16,1 19,4 19,10 16,13 4,13 1,10 1,4');
122
132
  svg.appendChild(polygon);
123
133
  swatch.appendChild(svg);
@@ -1,7 +1,7 @@
1
1
  import type cytoscape from 'cytoscape';
2
2
 
3
3
  import type { GraphScene, GraphSceneEdge, GraphSceneNode } from './panelGraphScene.ts';
4
- import { GRAPH_VISUAL_THEME } from './panelVisualTheme.ts';
4
+ import { GRAPH_VISUAL_THEME, type GraphVisualTheme } from './panelVisualTheme.ts';
5
5
  import { declarationEntrances, type FrameGraphDebugViewModel } from './debugCaptureModel.ts';
6
6
 
7
7
  export { GRAPH_VISUAL_THEME } from './panelVisualTheme.ts';
@@ -17,8 +17,9 @@ export const GRAPH_GEOMETRY = {
17
17
  groupPadding: 32,
18
18
  elkGroupPadding: { top: 46, right: 24, bottom: 24, left: 24 },
19
19
  edgeCornerRadius: 6,
20
- outputWidth: 256,
21
- outputLabelWidth: 144,
20
+ outputWidth: 208,
21
+ outputTipWidth: 24,
22
+ outputLabelWidth: 160,
22
23
  } as const;
23
24
 
24
25
  const ENDPOINT_EDGE_DISTANCES = 'endpoints' as unknown as cytoscape.Css.Edge['edge-distances'];
@@ -34,49 +35,57 @@ export type GraphLegendEntry = {
34
35
  readonly hollowArrow?: boolean;
35
36
  };
36
37
 
37
- const NODE_LEGEND_ENTRIES = {
38
- render: nodeLegend('render', 'Render', GRAPH_VISUAL_THEME.render.stroke),
39
- compute: nodeLegend('compute', 'Compute', GRAPH_VISUAL_THEME.compute.stroke),
40
- copy: nodeLegend('copy', 'Copy', GRAPH_VISUAL_THEME.copy.stroke),
41
- 'clear-buffer': nodeLegend('clear', 'Clear', GRAPH_VISUAL_THEME.clear.stroke),
42
- command: nodeLegend('command', 'Command', GRAPH_VISUAL_THEME.command.stroke),
43
- 'external-submission': { ...nodeLegend('external', 'External', GRAPH_VISUAL_THEME.external.stroke), shape: 'cut-rectangle' as const },
44
- } as const;
38
+ export function createGraphLegend(snapshot: FrameGraphDebugViewModel, theme: GraphVisualTheme = GRAPH_VISUAL_THEME, showResourceDeclarations = true): readonly GraphLegendEntry[] {
39
+ const nodeEntries = {
40
+ render: nodeLegend('render', 'Render', theme.render.stroke),
41
+ compute: nodeLegend('compute', 'Compute', theme.compute.stroke),
42
+ copy: nodeLegend('copy', 'Copy', theme.copy.stroke),
43
+ 'clear-buffer': nodeLegend('clear', 'Clear', theme.clear.stroke),
44
+ command: nodeLegend('command', 'Command', theme.command.stroke),
45
+ 'external-submission': { ...nodeLegend('external', 'External', theme.external.stroke), shape: 'cut-rectangle' as const },
46
+ } as const;
45
47
 
46
- export function createGraphLegend(snapshot: FrameGraphDebugViewModel): readonly GraphLegendEntry[] {
47
48
  const entries: GraphLegendEntry[] = [];
48
49
  const passKinds = new Set(snapshot.nodes.map((node) => node.kind));
49
50
  for (const kind of ['render', 'compute', 'copy', 'clear-buffer', 'command', 'external-submission'] as const) {
50
- if (passKinds.has(kind)) entries.push(NODE_LEGEND_ENTRIES[kind]);
51
+ if (passKinds.has(kind)) entries.push(nodeEntries[kind]);
51
52
  }
52
53
  const retained = new Set(snapshot.nodes.map((node) => node.id));
53
54
  const used = new Set([
54
55
  ...snapshot.accessEdges.filter((access) => retained.has(access.nodeId)).map((access) => access.resource.id),
55
56
  ...snapshot.roots.flatMap((root) => root.resource ? [root.resource.id] : []),
56
57
  ]);
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' });
58
+ const resources = showResourceDeclarations ? snapshot.resources.filter((resource) => used.has(resource.id)) : [];
59
+ if (resources.length) entries.push({ group: 'Resources', key: 'declaration', label: 'Declaration', color: theme.declaration.stroke, shape: 'ellipse' });
60
+ if (snapshot.roots.some((root) => root.resource)) entries.push({ group: 'Resources', key: 'output', label: 'Output', color: theme.output.stroke, shape: 'tag' });
60
61
  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' });
62
+ entries.push({ group: 'Relationships', key: 'group', label: 'Group', color: theme.group.stroke, shape: 'group' });
62
63
  }
63
64
  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'));
65
+ || (showResourceDeclarations && declarationEntrances(snapshot.nodes, snapshot.accessEdges, snapshot.edges).length)
66
+ || snapshot.roots.some((root) => root.resource && root.resolution && ((showResourceDeclarations && root.resolution.usesInitialContents) || root.resolution.producerNodeIds.length))) {
67
+ entries.push(edgeLegend('flow', 'Resource Flow', theme.dependency.value, 'solid'));
67
68
  }
68
69
  if (snapshot.edges.some((edge) => edge.kind === 'ordering')) {
69
70
  entries.push({
70
- ...edgeLegend('ordering', 'Order', GRAPH_VISUAL_THEME.dependency.ordering, 'dotted'),
71
+ ...edgeLegend('ordering', 'Order', theme.dependency.ordering, 'dotted'),
71
72
  hollowArrow: true,
72
73
  });
73
74
  }
74
75
  return entries;
75
76
  }
76
77
 
77
- export function createGraphStyles(): cytoscape.StylesheetJson {
78
- const theme = GRAPH_VISUAL_THEME;
78
+ export function createGraphStyles(theme: GraphVisualTheme = GRAPH_VISUAL_THEME): cytoscape.StylesheetJson {
79
79
  return [
80
+ {
81
+ selector: 'core',
82
+ style: {
83
+ 'active-bg-color': theme.activeBackground.color,
84
+ 'active-bg-opacity': theme.activeBackground.opacity,
85
+ 'active-bg-size': theme.activeBackground.size,
86
+ // Cytoscape accepts partial core styles; its Core type marks every property required.
87
+ } satisfies Partial<cytoscape.Css.Core> as cytoscape.Css.Core,
88
+ },
80
89
  {
81
90
  selector: 'node',
82
91
  style: {
@@ -85,14 +94,15 @@ export function createGraphStyles(): cytoscape.StylesheetJson {
85
94
  'height': 'data(height)',
86
95
  'label': 'data(displayLabel)',
87
96
  'text-wrap': 'wrap',
88
- 'text-max-width': '160px',
89
- 'font-family': 'ui-monospace, SFMono-Regular, Consolas, Liberation Mono, monospace',
90
- 'font-size': GRAPH_GEOMETRY.baseFontSize,
97
+ 'text-max-width': 'data(labelMaxWidth)',
98
+ 'font-family': theme.fontFamily,
99
+ 'font-size': (element: cytoscape.SingularElementArgument) => (element.data('labelFontSize') as number | undefined) ?? theme.fontSize,
100
+ 'line-height': 1.2,
91
101
  'min-zoomed-font-size': GRAPH_GEOMETRY.minimumZoomedFontSize,
92
102
  'color': theme.text,
93
103
  'background-color': theme.surfaceRaised,
94
104
  'border-color': theme.group.stroke,
95
- 'border-width': 1.25,
105
+ 'border-width': theme.nodeBorderWidth,
96
106
  'border-style': 'solid',
97
107
  'text-valign': 'center',
98
108
  'text-halign': 'center',
@@ -125,12 +135,13 @@ export function createGraphStyles(): cytoscape.StylesheetJson {
125
135
  {
126
136
  selector: 'node[kind = "root"]',
127
137
  style: {
128
- 'shape': 'tag',
138
+ 'shape': 'polygon',
139
+ 'shape-polygon-points': `-1 -1 ${1 - 2 * GRAPH_GEOMETRY.outputTipWidth / GRAPH_GEOMETRY.outputWidth} -1 1 0 ${1 - 2 * GRAPH_GEOMETRY.outputTipWidth / GRAPH_GEOMETRY.outputWidth} 1 -1 1`,
129
140
  'background-color': theme.output.fill,
130
141
  '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,
142
+ 'text-max-width': 'data(labelMaxWidth)',
143
+ // Keep a short tip and center the label in the rectangular body.
144
+ 'text-margin-x': -GRAPH_GEOMETRY.outputTipWidth / 2 * theme.fontSize / GRAPH_GEOMETRY.baseFontSize,
134
145
  },
135
146
  },
136
147
  {
@@ -144,13 +155,13 @@ export function createGraphStyles(): cytoscape.StylesheetJson {
144
155
  style: {
145
156
  'background-color': theme.group.fill,
146
157
  'border-color': theme.group.stroke,
147
- 'border-width': 2,
158
+ 'border-width': theme.groupBorderWidth,
148
159
  'color': theme.text,
149
160
  },
150
161
  },
151
162
  {
152
163
  selector: 'node[kind = "group"][collapsed = 0]',
153
- style: expandedGroupStyle(theme.group.fill),
164
+ style: expandedGroupStyle(theme.group.fill, theme),
154
165
  },
155
166
  {
156
167
  selector: 'node[kind = "group"][collapsed = 0][depthBand = 1]',
@@ -159,16 +170,16 @@ export function createGraphStyles(): cytoscape.StylesheetJson {
159
170
  {
160
171
  selector: 'edge',
161
172
  style: {
162
- 'width': 1.5,
173
+ 'width': theme.edgeWidth,
163
174
  'line-color': theme.dependency.value,
164
- 'line-opacity': 0.82,
175
+ 'line-opacity': theme.edgeOpacity,
165
176
  'target-arrow-color': theme.dependency.value,
166
177
  'target-arrow-shape': 'triangle',
167
178
  'target-arrow-fill': 'filled',
168
- 'arrow-scale': 0.8,
179
+ 'arrow-scale': theme.arrowScale,
169
180
  'curve-style': 'straight',
170
181
  'label': 'data(displayLabel)',
171
- 'font-family': 'ui-monospace, SFMono-Regular, Consolas, Liberation Mono, monospace',
182
+ 'font-family': theme.fontFamily,
172
183
  'font-size': 10,
173
184
  'min-zoomed-font-size': GRAPH_GEOMETRY.minimumZoomedFontSize,
174
185
  'color': theme.text,
@@ -187,7 +198,7 @@ export function createGraphStyles(): cytoscape.StylesheetJson {
187
198
  'target-arrow-color': theme.dependency.ordering,
188
199
  'line-style': 'dotted',
189
200
  'target-arrow-fill': 'hollow',
190
- 'line-opacity': 0.72,
201
+ 'line-opacity': theme.orderingOpacity,
191
202
  },
192
203
  },
193
204
  {
@@ -211,7 +222,7 @@ export function createGraphStyles(): cytoscape.StylesheetJson {
211
222
  selector: 'node.semantic-hover',
212
223
  style: {
213
224
  'border-color': theme.hover,
214
- 'border-width': 2,
225
+ 'border-width': theme.hoverWidth,
215
226
  'z-index': 15,
216
227
  },
217
228
  },
@@ -221,7 +232,7 @@ export function createGraphStyles(): cytoscape.StylesheetJson {
221
232
  'line-color': theme.hover,
222
233
  'target-arrow-color': theme.hover,
223
234
  'line-opacity': 1,
224
- 'width': 2.5,
235
+ 'width': theme.hoverWidth,
225
236
  'z-index': 15,
226
237
  },
227
238
  },
@@ -229,7 +240,7 @@ export function createGraphStyles(): cytoscape.StylesheetJson {
229
240
  selector: 'node.semantic-selected',
230
241
  style: {
231
242
  'border-color': theme.selected,
232
- 'border-width': 3,
243
+ 'border-width': theme.selectedWidth,
233
244
  'opacity': 1,
234
245
  'z-index': 20,
235
246
  },
@@ -240,14 +251,14 @@ export function createGraphStyles(): cytoscape.StylesheetJson {
240
251
  'line-color': theme.selected,
241
252
  'target-arrow-color': theme.selected,
242
253
  'line-opacity': 1,
243
- 'width': 3,
254
+ 'width': theme.selectedWidth,
244
255
  'z-index': 20,
245
256
  },
246
257
  },
247
258
  ];
248
259
  }
249
260
 
250
- export function nodeDimensions(node: GraphSceneNode): { readonly width: number; readonly height: number } {
261
+ function baseNodeDimensions(node: GraphSceneNode): { readonly width: number; readonly height: number } {
251
262
  switch (node.kind) {
252
263
  case 'pass':
253
264
  return { width: 184, height: node.label.includes('\n') ? 62 : 48 };
@@ -256,10 +267,68 @@ export function nodeDimensions(node: GraphSceneNode): { readonly width: number;
256
267
  case 'resource':
257
268
  return { width: 184, height: Math.max(58, node.label.split('\n').length * 17 + 24) };
258
269
  case 'group':
259
- return node.collapsed ? { width: 224, height: 68 } : { width: 120, height: 80 };
270
+ return node.collapsed ? { width: 240, height: 80 } : { width: 120, height: 80 };
260
271
  }
261
272
  }
262
273
 
274
+ export function nodeDimensions(node: GraphSceneNode, theme: GraphVisualTheme = GRAPH_VISUAL_THEME): { readonly width: number; readonly height: number } {
275
+ const base = baseNodeDimensions(node);
276
+ const scale = theme.fontSize / GRAPH_GEOMETRY.baseFontSize;
277
+ return { width: base.width * scale, height: base.height * scale };
278
+ }
279
+
280
+ /** Enlarge short labels within the existing box; retain the theme size for dense text. */
281
+ export function graphLabelFontSize(label: string, width: number, height: number, theme: GraphVisualTheme): number {
282
+ const context = graphTextContext();
283
+ if (context) context.font = theme.fontSize + 'px ' + theme.fontFamily;
284
+ const lines = label.split('\n');
285
+ const widest = Math.max(1, ...lines.map(line => context?.measureText(line).width ?? [...line].length * theme.fontSize * .62));
286
+ const widthScale = width / widest;
287
+ const heightScale = height / (lines.length * theme.fontSize * 1.2);
288
+ return Math.max(theme.fontSize, Math.floor(theme.fontSize * Math.min(1.25, widthScale, heightScale) * 4) / 4);
289
+ }
290
+
291
+ /** Wrap only the group name (up to two lines); keep the node-count row distinct. */
292
+ export function fitCollapsedGroupLabel(label: string, width: number, theme: GraphVisualTheme): string {
293
+ const [name = '', ...summary] = label.split('\n');
294
+ const context = graphTextContext();
295
+ if (context) context.font = theme.fontSize + 'px ' + theme.fontFamily;
296
+ const measure = (text: string) => context?.measureText(text).width ?? [...text].length * theme.fontSize * .62;
297
+ const chars = [...name];
298
+ let split = 0;
299
+ while (split < chars.length && measure(chars.slice(0, split + 1).join('')) <= width) split++;
300
+ if (split === chars.length) return fitGraphLabel(label, width, theme);
301
+ // Prefer a word/path boundary without losing characters, including Unicode.
302
+ const prefix = chars.slice(0, split).join('');
303
+ const boundary = Math.max(prefix.lastIndexOf(' '), prefix.lastIndexOf('.'), prefix.lastIndexOf('/'));
304
+ if (boundary > prefix.length / 2) split = [...prefix.slice(0, boundary + 1)].length;
305
+ return fitGraphLabel([
306
+ chars.slice(0, split).join('').trimEnd(),
307
+ chars.slice(split).join('').trimStart(),
308
+ ...summary,
309
+ ].join('\n'), width, theme);
310
+ }
311
+
312
+ /** Fit the already semantic label to its available width after font changes. */
313
+ export function fitGraphLabel(label: string, width: number, theme: GraphVisualTheme): string {
314
+ const context = graphTextContext();
315
+ if (context) context.font = theme.fontSize + 'px ' + theme.fontFamily;
316
+ const measure = (text: string) => context?.measureText(text).width ?? [...text].length * theme.fontSize * .62;
317
+ return label.split('\n').map(line => {
318
+ if (measure(line) <= width) return line;
319
+ const chars = [...line];
320
+ while (chars.length && measure(chars.join('') + '…') > width) chars.pop();
321
+ return chars.join('') + '…';
322
+ }).join('\n');
323
+ }
324
+
325
+ let textContext: CanvasRenderingContext2D | null | undefined;
326
+ function graphTextContext(): CanvasRenderingContext2D | null {
327
+ if (typeof document === 'undefined') return null;
328
+ if (textContext === undefined) textContext = document.createElement('canvas').getContext('2d');
329
+ return textContext;
330
+ }
331
+
263
332
  export function graphLayoutGeometryKey(scene: GraphScene): string {
264
333
  return JSON.stringify({
265
334
  topology: scene.topologyKey,
@@ -278,8 +347,8 @@ export function expandedGroupLabelMaxWidth(outerWidth: number): number {
278
347
  return Math.max(80, Math.floor(outerWidth - GRAPH_GEOMETRY.groupTitleInset * 2));
279
348
  }
280
349
 
281
- export function isOverviewGraphScale(zoom: number): boolean {
282
- return GRAPH_GEOMETRY.baseFontSize * zoom < GRAPH_GEOMETRY.overviewFontThreshold;
350
+ export function isOverviewGraphScale(zoom: number, theme: GraphVisualTheme = GRAPH_VISUAL_THEME): boolean {
351
+ return theme.fontSize * zoom < GRAPH_GEOMETRY.overviewFontThreshold;
283
352
  }
284
353
 
285
354
  function nodeLegend(key: string, label: string, color: string): GraphLegendEntry {
@@ -305,15 +374,16 @@ function passStyle(
305
374
  };
306
375
  }
307
376
 
308
- function expandedGroupStyle(backgroundColor: string): cytoscape.Css.Node {
377
+ function expandedGroupStyle(backgroundColor: string, theme: GraphVisualTheme): cytoscape.Css.Node {
309
378
  const geometry = GRAPH_GEOMETRY;
310
379
  return {
311
380
  'background-color': backgroundColor,
312
- 'background-opacity': 0.34,
313
- 'border-color': GRAPH_VISUAL_THEME.group.stroke,
314
- 'border-width': 1.5,
381
+ 'background-opacity': theme.groupOpacity,
382
+ 'border-color': theme.group.stroke,
383
+ 'border-width': theme.groupBorderWidth,
315
384
  'padding': `${geometry.groupPadding}px`,
316
- 'compound-sizing-wrt-labels': 'include',
385
+ 'compound-sizing-wrt-labels': 'exclude',
386
+ 'text-wrap': 'ellipsis',
317
387
  'min-width': '120px',
318
388
  'min-height': '80px',
319
389
  'text-valign': 'top-inside',
@@ -322,7 +392,7 @@ function expandedGroupStyle(backgroundColor: string): cytoscape.Css.Node {
322
392
  'text-margin-x': geometry.groupTitleInset - geometry.groupPadding * 2,
323
393
  'text-margin-y': geometry.groupTitleInset - geometry.groupPadding * 2,
324
394
  'line-height': geometry.groupTitleLineHeight,
325
- 'color': GRAPH_VISUAL_THEME.text,
395
+ 'color': theme.text,
326
396
  'font-weight': 600,
327
397
  };
328
398
  }