@zenfg/inspector 0.1.0-beta.1

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 (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/dist/FrameGraphInspector.d.ts +153 -0
  4. package/dist/FrameGraphInspector.d.ts.map +1 -0
  5. package/dist/FrameGraphInspector.js +580 -0
  6. package/dist/FrameGraphInspector.js.map +1 -0
  7. package/dist/debugCaptureModel.d.ts +166 -0
  8. package/dist/debugCaptureModel.d.ts.map +1 -0
  9. package/dist/debugCaptureModel.js +364 -0
  10. package/dist/debugCaptureModel.js.map +1 -0
  11. package/dist/index.d.ts +17 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +15 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/panelAliasAnalysis.d.ts +20 -0
  16. package/dist/panelAliasAnalysis.d.ts.map +1 -0
  17. package/dist/panelAliasAnalysis.js +113 -0
  18. package/dist/panelAliasAnalysis.js.map +1 -0
  19. package/dist/panelCytoscapeGraphRenderer.d.ts +71 -0
  20. package/dist/panelCytoscapeGraphRenderer.d.ts.map +1 -0
  21. package/dist/panelCytoscapeGraphRenderer.js +559 -0
  22. package/dist/panelCytoscapeGraphRenderer.js.map +1 -0
  23. package/dist/panelDiagnosticsView.d.ts +22 -0
  24. package/dist/panelDiagnosticsView.d.ts.map +1 -0
  25. package/dist/panelDiagnosticsView.js +123 -0
  26. package/dist/panelDiagnosticsView.js.map +1 -0
  27. package/dist/panelDomHelpers.d.ts +26 -0
  28. package/dist/panelDomHelpers.d.ts.map +1 -0
  29. package/dist/panelDomHelpers.js +129 -0
  30. package/dist/panelDomHelpers.js.map +1 -0
  31. package/dist/panelGraphLayout.d.ts +28 -0
  32. package/dist/panelGraphLayout.d.ts.map +1 -0
  33. package/dist/panelGraphLayout.js +223 -0
  34. package/dist/panelGraphLayout.js.map +1 -0
  35. package/dist/panelGraphRenderer.d.ts +20 -0
  36. package/dist/panelGraphRenderer.d.ts.map +1 -0
  37. package/dist/panelGraphRenderer.js +2 -0
  38. package/dist/panelGraphRenderer.js.map +1 -0
  39. package/dist/panelGraphScene.d.ts +98 -0
  40. package/dist/panelGraphScene.d.ts.map +1 -0
  41. package/dist/panelGraphScene.js +559 -0
  42. package/dist/panelGraphScene.js.map +1 -0
  43. package/dist/panelGraphView.d.ts +9 -0
  44. package/dist/panelGraphView.d.ts.map +1 -0
  45. package/dist/panelGraphView.js +88 -0
  46. package/dist/panelGraphView.js.map +1 -0
  47. package/dist/panelGraphVisuals.d.ts +38 -0
  48. package/dist/panelGraphVisuals.d.ts.map +1 -0
  49. package/dist/panelGraphVisuals.js +330 -0
  50. package/dist/panelGraphVisuals.js.map +1 -0
  51. package/dist/panelIcons.d.ts +4 -0
  52. package/dist/panelIcons.d.ts.map +1 -0
  53. package/dist/panelIcons.js +43 -0
  54. package/dist/panelIcons.js.map +1 -0
  55. package/dist/panelInspectorView.d.ts +33 -0
  56. package/dist/panelInspectorView.d.ts.map +1 -0
  57. package/dist/panelInspectorView.js +357 -0
  58. package/dist/panelInspectorView.js.map +1 -0
  59. package/dist/panelMemoryView.d.ts +21 -0
  60. package/dist/panelMemoryView.d.ts.map +1 -0
  61. package/dist/panelMemoryView.js +134 -0
  62. package/dist/panelMemoryView.js.map +1 -0
  63. package/dist/panelPassesView.d.ts +31 -0
  64. package/dist/panelPassesView.d.ts.map +1 -0
  65. package/dist/panelPassesView.js +183 -0
  66. package/dist/panelPassesView.js.map +1 -0
  67. package/dist/panelResourcesView.d.ts +19 -0
  68. package/dist/panelResourcesView.d.ts.map +1 -0
  69. package/dist/panelResourcesView.js +89 -0
  70. package/dist/panelResourcesView.js.map +1 -0
  71. package/dist/panelSelection.d.ts +5 -0
  72. package/dist/panelSelection.d.ts.map +1 -0
  73. package/dist/panelSelection.js +88 -0
  74. package/dist/panelSelection.js.map +1 -0
  75. package/dist/panelTypes.d.ts +40 -0
  76. package/dist/panelTypes.d.ts.map +1 -0
  77. package/dist/panelTypes.js +2 -0
  78. package/dist/panelTypes.js.map +1 -0
  79. package/dist/panelVisualTheme.d.ts +76 -0
  80. package/dist/panelVisualTheme.d.ts.map +1 -0
  81. package/dist/panelVisualTheme.js +39 -0
  82. package/dist/panelVisualTheme.js.map +1 -0
  83. package/dist/panelWorkbenchHelpers.d.ts +34 -0
  84. package/dist/panelWorkbenchHelpers.d.ts.map +1 -0
  85. package/dist/panelWorkbenchHelpers.js +162 -0
  86. package/dist/panelWorkbenchHelpers.js.map +1 -0
  87. package/dist/panelWorkbenchView.d.ts +77 -0
  88. package/dist/panelWorkbenchView.d.ts.map +1 -0
  89. package/dist/panelWorkbenchView.js +409 -0
  90. package/dist/panelWorkbenchView.js.map +1 -0
  91. package/dist/query.d.ts +11 -0
  92. package/dist/query.d.ts.map +1 -0
  93. package/dist/query.js +21 -0
  94. package/dist/query.js.map +1 -0
  95. package/dist/styles.d.ts +8 -0
  96. package/dist/styles.d.ts.map +1 -0
  97. package/dist/styles.js +1073 -0
  98. package/dist/styles.js.map +1 -0
  99. package/package.json +29 -0
  100. package/src/FrameGraphInspector.ts +632 -0
  101. package/src/debugCaptureModel.ts +583 -0
  102. package/src/index.ts +21 -0
  103. package/src/panelAliasAnalysis.ts +164 -0
  104. package/src/panelCytoscapeGraphRenderer.ts +617 -0
  105. package/src/panelDiagnosticsView.ts +160 -0
  106. package/src/panelDomHelpers.ts +151 -0
  107. package/src/panelGraphLayout.ts +281 -0
  108. package/src/panelGraphRenderer.ts +21 -0
  109. package/src/panelGraphScene.ts +787 -0
  110. package/src/panelGraphView.ts +108 -0
  111. package/src/panelGraphVisuals.ts +364 -0
  112. package/src/panelIcons.ts +56 -0
  113. package/src/panelInspectorView.ts +377 -0
  114. package/src/panelMemoryView.ts +154 -0
  115. package/src/panelPassesView.ts +222 -0
  116. package/src/panelResourcesView.ts +115 -0
  117. package/src/panelSelection.ts +99 -0
  118. package/src/panelTypes.ts +28 -0
  119. package/src/panelVisualTheme.ts +39 -0
  120. package/src/panelWorkbenchHelpers.ts +216 -0
  121. package/src/panelWorkbenchView.ts +465 -0
  122. package/src/query.ts +22 -0
  123. package/src/styles.ts +1076 -0
@@ -0,0 +1,160 @@
1
+ import type { FrameGraphDebugViewModel } from './debugCaptureModel.ts';
2
+ import { createCell, labelNode, labelResource } from './panelDomHelpers.ts';
3
+ import type { Selection } from './panelTypes.ts';
4
+ import {
5
+ createEmptyTableRow,
6
+ createKindCell,
7
+ createRelationButton,
8
+ createSelectionCell,
9
+ createTableScroller,
10
+ registerSelectable,
11
+ type WorkbenchCallbacks,
12
+ updateSelectedRows,
13
+ } from './panelWorkbenchHelpers.ts';
14
+
15
+ export class DiagnosticsView {
16
+ readonly root = document.createElement('section');
17
+ private readonly scroller = document.createElement('div');
18
+ private readonly content = document.createElement('div');
19
+ private readonly rows = new Map<string, HTMLElement[]>();
20
+ private snapshot: FrameGraphDebugViewModel | undefined;
21
+ private selected: Selection | undefined;
22
+
23
+ constructor(private readonly callbacks: WorkbenchCallbacks, idPrefix: string) {
24
+ this.root.className = 'zenfg-inspector-view zenfg-inspector-diagnostics-view';
25
+ this.root.id = `${idPrefix}-view-diagnostics`;
26
+ this.root.setAttribute('role', 'tabpanel');
27
+ this.scroller.className = 'zenfg-inspector-diagnostics-scroller';
28
+ this.content.className = 'zenfg-inspector-diagnostics-grid';
29
+ this.scroller.appendChild(this.content);
30
+ this.root.appendChild(this.scroller);
31
+ }
32
+
33
+ setSnapshot(snapshot: FrameGraphDebugViewModel): void {
34
+ this.snapshot = snapshot;
35
+ this.render();
36
+ }
37
+
38
+ setSelection(selected: Selection | undefined): void {
39
+ this.selected = selected;
40
+ updateSelectedRows(this.rows, selected);
41
+ }
42
+
43
+ private render(): void {
44
+ const snapshot = this.snapshot;
45
+ if (!snapshot) return;
46
+ this.rows.clear();
47
+ this.content.replaceChildren(
48
+ this.createRoots(snapshot),
49
+ this.createCulled(snapshot),
50
+ this.createSegments(snapshot),
51
+ this.createTiming(snapshot),
52
+ );
53
+ updateSelectedRows(this.rows, this.selected);
54
+ }
55
+
56
+ private createSection(title: string, description?: string): HTMLElement {
57
+ const section = document.createElement('section');
58
+ section.className = 'zenfg-inspector-diagnostic-card';
59
+ const heading = document.createElement('h2');
60
+ heading.textContent = title;
61
+ section.appendChild(heading);
62
+ if (description) {
63
+ const text = document.createElement('p');
64
+ text.textContent = description;
65
+ section.appendChild(text);
66
+ }
67
+ return section;
68
+ }
69
+
70
+ private createRoots(snapshot: FrameGraphDebugViewModel): HTMLElement {
71
+ const section = this.createSection('Retention roots', 'Roots explain why graph work survived dead-node elimination.');
72
+ const list = document.createElement('div');
73
+ list.className = 'zenfg-inspector-diagnostic-list';
74
+ snapshot.roots.forEach((root, index) => {
75
+ const selection: Selection = { kind: 'root', index };
76
+ const button = createRelationButton(
77
+ `${root.reason} · ${root.resource ? labelResource(root.resource) : root.nodeId === undefined ? '-' : `node-${root.nodeId}`}`,
78
+ selection,
79
+ this.callbacks.onSelect,
80
+ );
81
+ registerSelectable(this.rows, button, selection, this.callbacks);
82
+ list.appendChild(button);
83
+ });
84
+ if (snapshot.roots.length === 0) list.textContent = 'No retention roots.';
85
+ section.appendChild(list);
86
+ return section;
87
+ }
88
+
89
+ private createCulled(snapshot: FrameGraphDebugViewModel): HTMLElement {
90
+ const section = this.createSection('Culled nodes', 'Recorded nodes that were not reachable from a retention root.');
91
+ const table = createTableScroller([{ label: 'Node' }, { label: 'Kind', column: 'kind' }, { label: 'Reason' }]);
92
+ for (const [index, entry] of snapshot.culledNodes.entries()) {
93
+ const selection: Selection = { kind: 'culled', index };
94
+ const row = document.createElement('tr');
95
+ registerSelectable(this.rows, row, selection, this.callbacks);
96
+ row.append(
97
+ createSelectionCell(labelNode(entry.node), selection, this.callbacks),
98
+ createKindCell(entry.node.kind),
99
+ createCell(entry.reason),
100
+ );
101
+ table.body.appendChild(row);
102
+ }
103
+ if (snapshot.culledNodes.length === 0) table.body.appendChild(createEmptyTableRow(3, 'No culled nodes.'));
104
+ section.appendChild(table.scroller);
105
+ return section;
106
+ }
107
+
108
+ private createSegments(snapshot: FrameGraphDebugViewModel): HTMLElement {
109
+ const section = this.createSection(
110
+ 'Execution segments',
111
+ 'An opaque interval is a graph boundary around external work; it is not a count or timing of third-party GPU submissions.',
112
+ );
113
+ const table = createTableScroller([{ label: '#', column: 'numeric' }, { label: 'Kind', column: 'kind' }, { label: 'Nodes' }]);
114
+ for (const segment of snapshot.executionSegments) {
115
+ const selection: Selection = { kind: 'segment', index: segment.index };
116
+ const row = document.createElement('tr');
117
+ registerSelectable(this.rows, row, selection, this.callbacks);
118
+ const labels = segment.nodeIds.map((id) => snapshot.nodeById.get(id))
119
+ .filter((node) => node !== undefined)
120
+ .map((node) => labelNode(node));
121
+ row.append(
122
+ createSelectionCell(String(segment.index), selection, this.callbacks, 'numeric'),
123
+ createKindCell(segment.kind, segment.kind === 'frame-graph' ? 'FrameGraph command segment' : 'Opaque interval'),
124
+ createCell(labels.join(', ') || '-'),
125
+ );
126
+ table.body.appendChild(row);
127
+ }
128
+ if (snapshot.executionSegments.length === 0) table.body.appendChild(createEmptyTableRow(3, 'No execution segments.'));
129
+ section.appendChild(table.scroller);
130
+ return section;
131
+ }
132
+
133
+ private createTiming(snapshot: FrameGraphDebugViewModel): HTMLElement {
134
+ const metrics = snapshot.metrics;
135
+ const coverage = metrics.timingEligibleNodeCount === 0
136
+ ? 'No eligible render or compute passes'
137
+ : `${metrics.timedNodeCount}/${metrics.timingEligibleNodeCount} eligible passes timed (${((metrics.timedNodeCount / metrics.timingEligibleNodeCount) * 100).toFixed(0)}%)`;
138
+ const section = this.createSection('GPU timing coverage', coverage);
139
+ if (snapshot.profiling.status === 'unavailable') {
140
+ const status = document.createElement('p');
141
+ status.textContent = `Timing unavailable: ${snapshot.profiling.reason}.`;
142
+ section.appendChild(status);
143
+ }
144
+ const table = createTableScroller([{ label: 'Pass' }, { label: 'Status', column: 'kind' }, { label: 'GPU (ms)', column: 'numeric' }]);
145
+ for (const node of snapshot.nodes.filter((candidate) => candidate.kind === 'render' || candidate.kind === 'compute')) {
146
+ const selection: Selection = { kind: 'node', id: node.id };
147
+ const row = document.createElement('tr');
148
+ registerSelectable(this.rows, row, selection, this.callbacks);
149
+ row.append(
150
+ createSelectionCell(labelNode(node), selection, this.callbacks),
151
+ createKindCell(node.gpuDurationMicros === undefined ? 'not-timed' : 'timed', node.gpuDurationMicros === undefined ? 'Not timed' : 'Timed'),
152
+ createCell(node.gpuDurationMicros === undefined ? '-' : (node.gpuDurationMicros / 1000).toFixed(3), { column: 'numeric' }),
153
+ );
154
+ table.body.appendChild(row);
155
+ }
156
+ if (table.body.childElementCount === 0) table.body.appendChild(createEmptyTableRow(3, 'No timing-eligible passes.'));
157
+ section.appendChild(table.scroller);
158
+ return section;
159
+ }
160
+ }
@@ -0,0 +1,151 @@
1
+ import type {
2
+ FrameGraphDebugNode,
3
+ FrameGraphDebugViewModel,
4
+ } from './debugCaptureModel.ts';
5
+
6
+ export function createSection(title: string): HTMLElement {
7
+ const section = document.createElement('section');
8
+ section.className = 'zenfg-inspector-section';
9
+ const heading = document.createElement('h2');
10
+ heading.textContent = title;
11
+ section.appendChild(heading);
12
+ return section;
13
+ }
14
+
15
+ export function createTable(headers: readonly string[]): HTMLTableElement {
16
+ const table = document.createElement('table');
17
+ const head = document.createElement('thead');
18
+ const headRow = document.createElement('tr');
19
+ for (const header of headers) {
20
+ const cell = document.createElement('th');
21
+ cell.textContent = header;
22
+ headRow.appendChild(cell);
23
+ }
24
+ head.appendChild(headRow);
25
+ table.append(head, document.createElement('tbody'));
26
+ return table;
27
+ }
28
+
29
+ export function createStat(label: string, value: string): HTMLElement {
30
+ const item = document.createElement('div');
31
+ const name = document.createElement('span');
32
+ name.textContent = label;
33
+ const count = document.createElement('strong');
34
+ count.textContent = value;
35
+ item.append(name, count);
36
+ return item;
37
+ }
38
+
39
+ export type FrameGraphDebugCellOptions = {
40
+ readonly column?: 'code' | 'kind' | 'numeric';
41
+ readonly kind?: string;
42
+ };
43
+
44
+ export function createCell(text: string, options?: FrameGraphDebugCellOptions): HTMLTableCellElement {
45
+ const cell = document.createElement('td');
46
+ cell.textContent = text;
47
+ if (options?.column) cell.dataset.column = options.column;
48
+ if (options?.kind) cell.dataset.kind = options.kind;
49
+ return cell;
50
+ }
51
+
52
+ export function createButtonCell(text: string, onClick: () => void): HTMLTableCellElement {
53
+ const cell = document.createElement('td');
54
+ const button = document.createElement('button');
55
+ button.type = 'button';
56
+ button.textContent = text;
57
+ button.addEventListener('click', onClick);
58
+ cell.appendChild(button);
59
+ return cell;
60
+ }
61
+
62
+ export function createToolbarButton(text: string, title: string, onClick: () => void): HTMLButtonElement {
63
+ const button = document.createElement('button');
64
+ button.type = 'button';
65
+ button.textContent = text;
66
+ button.title = title;
67
+ button.setAttribute('aria-label', title);
68
+ button.addEventListener('click', onClick);
69
+ return button;
70
+ }
71
+
72
+ export function createListButton(text: string, selected: boolean, onClick: () => void): HTMLButtonElement {
73
+ const button = document.createElement('button');
74
+ button.type = 'button';
75
+ button.textContent = text;
76
+ if (selected) {
77
+ button.className = 'selected';
78
+ }
79
+ button.addEventListener('click', onClick);
80
+ return button;
81
+ }
82
+
83
+ export function createMutedText(text: string): HTMLElement {
84
+ const element = document.createElement('p');
85
+ element.className = 'zenfg-inspector-muted';
86
+ element.textContent = text;
87
+ return element;
88
+ }
89
+
90
+ export function createMutedActionText(prefix: string, actionText: string, suffix: string, onAction: () => void): HTMLElement {
91
+ const element = document.createElement('p');
92
+ element.className = 'zenfg-inspector-muted';
93
+ const button = document.createElement('button');
94
+ button.type = 'button';
95
+ button.className = 'zenfg-inspector-inline-action';
96
+ button.textContent = actionText;
97
+ button.addEventListener('click', onAction);
98
+ element.append(document.createTextNode(prefix), button, document.createTextNode(suffix));
99
+ return element;
100
+ }
101
+
102
+ export function formatProfilingStatus(snapshot: FrameGraphDebugViewModel): string {
103
+ const profiling = snapshot.profiling;
104
+ if (!profiling) {
105
+ return 'off';
106
+ }
107
+ if (profiling.status === 'available') {
108
+ return `frame ${profiling.frameIndex}`;
109
+ }
110
+ return profiling.reason;
111
+ }
112
+
113
+ export function formatGpuDuration(node: FrameGraphDebugNode): string {
114
+ if (node.gpuDurationMicros === undefined) {
115
+ return '-';
116
+ }
117
+ return (node.gpuDurationMicros / 1000).toFixed(3);
118
+ }
119
+
120
+ export function formatGpuFrameDuration(snapshot: FrameGraphDebugViewModel): string {
121
+ if (snapshot.profiling.status !== 'available') {
122
+ return '-';
123
+ }
124
+ return (snapshot.profiling.gpuFrameDurationMicros / 1000).toFixed(3);
125
+ }
126
+
127
+ export function formatPoolHitRate(snapshot: FrameGraphDebugViewModel): string {
128
+ const pool = snapshot.resourcePool;
129
+ if (!pool || pool.status !== 'available' || pool.acquireCount === 0) {
130
+ return '-';
131
+ }
132
+ return `${((pool.reuseCount / pool.acquireCount) * 100).toFixed(0)}%`;
133
+ }
134
+
135
+ export function formatBytes(bytes: number): string {
136
+ if (bytes < 1024) {
137
+ return `${bytes} B`;
138
+ }
139
+ if (bytes < 1024 * 1024) {
140
+ return `${(bytes / 1024).toFixed(1)} KiB`;
141
+ }
142
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
143
+ }
144
+
145
+ export function labelNode(node: Pick<FrameGraphDebugNode, 'id' | 'label'>): string {
146
+ return node.label ?? `node-${node.id}`;
147
+ }
148
+
149
+ export function labelResource(resource: { readonly id: string; readonly kind: string; readonly label?: string }): string {
150
+ return resource.label ?? `${resource.kind}-${resource.id}`;
151
+ }
@@ -0,0 +1,281 @@
1
+ import type cytoscape from 'cytoscape';
2
+ import type { ELK, ElkExtendedEdge, ElkNode, ElkPort } from 'elkjs/lib/elk-api';
3
+
4
+ import type { GraphScene, GraphSceneElementId, GraphSceneNode } from './panelGraphScene.ts';
5
+ import { expandedGroupLabelMaxWidth, GRAPH_GEOMETRY, nodeDimensions } from './panelGraphVisuals.ts';
6
+
7
+ export type GraphPosition = { readonly x: number; readonly y: number };
8
+ export type GraphEdgeRoute = {
9
+ readonly startPoint: GraphPosition;
10
+ readonly bendPoints: readonly GraphPosition[];
11
+ readonly endPoint: GraphPosition;
12
+ };
13
+ export type GraphLayoutResult = {
14
+ readonly positions: ReadonlyMap<GraphSceneElementId, GraphPosition>;
15
+ readonly routes: ReadonlyMap<GraphSceneElementId, GraphEdgeRoute>;
16
+ };
17
+
18
+ export type CytoscapeEdgeRouteData = {
19
+ readonly sourceEndpoint: string;
20
+ readonly targetEndpoint: string;
21
+ readonly segmentWeights: string;
22
+ readonly segmentDistances: string;
23
+ };
24
+
25
+ export async function layoutGraphScene(elk: ELK, scene: GraphScene): Promise<GraphLayoutResult> {
26
+ const graph = createElkLayoutGraph(scene);
27
+ const layout = await elk.layout(graph);
28
+ const positions = new Map<GraphSceneElementId, GraphPosition>();
29
+ collectLayoutPositions(layout, 0, 0, positions);
30
+ const nodeOffsets = new Map<GraphSceneElementId, GraphPosition>();
31
+ collectLayoutOffsets(layout, 0, 0, nodeOffsets);
32
+ const routes = new Map<GraphSceneElementId, GraphEdgeRoute>();
33
+ collectEdgeRoutes(layout, 0, 0, nodeOffsets, routes);
34
+ return { positions, routes };
35
+ }
36
+
37
+ export function createElkLayoutGraph(scene: GraphScene): ElkNode {
38
+ const childrenByParent = new Map<GraphSceneElementId | undefined, GraphSceneNode[]>();
39
+ const portsByNode = new Map<GraphSceneElementId, ElkPort[]>();
40
+ const elkEdges = scene.edges.map((edge): ElkExtendedEdge => {
41
+ const sourcePortId = `${edge.id}:source`;
42
+ const targetPortId = `${edge.id}:target`;
43
+ appendPort(portsByNode, edge.from, {
44
+ id: sourcePortId,
45
+ width: 1,
46
+ height: 1,
47
+ layoutOptions: { 'elk.port.side': 'EAST' },
48
+ });
49
+ appendPort(portsByNode, edge.to, {
50
+ id: targetPortId,
51
+ width: 1,
52
+ height: 1,
53
+ layoutOptions: { 'elk.port.side': 'WEST' },
54
+ });
55
+ return {
56
+ id: edge.id,
57
+ sources: [sourcePortId],
58
+ targets: [targetPortId],
59
+ };
60
+ });
61
+ for (const node of scene.nodes) {
62
+ const children = childrenByParent.get(node.parentId) ?? [];
63
+ children.push(node);
64
+ childrenByParent.set(node.parentId, children);
65
+ }
66
+ const createNode = (node: GraphSceneNode): ElkNode => {
67
+ const children = childrenByParent.get(node.id)?.map(createNode);
68
+ const dimensions = nodeDimensions(node);
69
+ return {
70
+ id: node.id,
71
+ width: dimensions.width,
72
+ height: dimensions.height,
73
+ children,
74
+ ports: portsByNode.get(node.id),
75
+ layoutOptions: children?.length ? {
76
+ 'elk.algorithm': 'layered',
77
+ 'elk.direction': 'RIGHT',
78
+ 'elk.edgeRouting': 'ORTHOGONAL',
79
+ 'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
80
+ 'elk.portConstraints': 'FIXED_SIDE',
81
+ 'elk.layered.mergeEdges': 'false',
82
+ 'elk.layered.mergeHierarchyEdges': 'false',
83
+ 'elk.padding': elkPadding(),
84
+ 'elk.spacing.nodeNode': '36',
85
+ 'elk.layered.spacing.nodeNodeBetweenLayers': '72',
86
+ 'elk.spacing.edgeEdge': '10',
87
+ 'elk.layered.spacing.edgeEdgeBetweenLayers': '10',
88
+ } : {
89
+ 'elk.portConstraints': 'FIXED_SIDE',
90
+ },
91
+ };
92
+ };
93
+ return {
94
+ id: 'root',
95
+ children: (childrenByParent.get(undefined) ?? []).map(createNode),
96
+ edges: elkEdges,
97
+ layoutOptions: {
98
+ 'elk.algorithm': 'layered',
99
+ 'elk.direction': 'RIGHT',
100
+ 'elk.edgeRouting': 'ORTHOGONAL',
101
+ 'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
102
+ 'elk.layered.mergeEdges': 'false',
103
+ 'elk.layered.mergeHierarchyEdges': 'false',
104
+ 'elk.spacing.nodeNode': '42',
105
+ 'elk.layered.spacing.nodeNodeBetweenLayers': '88',
106
+ 'elk.spacing.edgeNode': '20',
107
+ 'elk.spacing.edgeEdge': '10',
108
+ 'elk.layered.spacing.edgeEdgeBetweenLayers': '10',
109
+ 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES',
110
+ },
111
+ };
112
+ }
113
+
114
+ export function applyGraphLayout(core: cytoscape.Core, scene: GraphScene, layout: GraphLayoutResult): void {
115
+ applyPositions(core, layout.positions);
116
+ core.batch(() => {
117
+ for (const edge of scene.edges) {
118
+ const route = layout.routes.get(edge.id);
119
+ const cytoscapeEdge = core.getElementById(edge.id);
120
+ if (cytoscapeEdge.empty()) continue;
121
+ const sourceNode = core.getElementById(edge.from);
122
+ const targetNode = core.getElementById(edge.to);
123
+ const routeData = sourceNode.nonempty() && targetNode.nonempty()
124
+ ? createCytoscapeEdgeRouteData(route, sourceNode.position(), targetNode.position())
125
+ : undefined;
126
+ cytoscapeEdge
127
+ .removeClass('elk-route elk-segments')
128
+ .removeData('sourceEndpoint', 'targetEndpoint', 'segmentWeights', 'segmentDistances');
129
+ if (!routeData) continue;
130
+ cytoscapeEdge.data(routeData).addClass('elk-route');
131
+ if (route!.bendPoints.length > 0) cytoscapeEdge.addClass('elk-segments');
132
+ }
133
+ });
134
+ }
135
+
136
+ export function syncExpandedGroupLabelGeometry(core: cytoscape.Core): void {
137
+ const groups = core.nodes('node[kind = "group"][collapsed = 0]');
138
+ for (let iteration = 0; iteration < 2; iteration++) {
139
+ const updates = groups.map((node) => ({
140
+ node,
141
+ labelMaxWidth: expandedGroupLabelMaxWidth(node.outerWidth()),
142
+ }));
143
+ core.batch(() => {
144
+ for (const update of updates) update.node.data('labelMaxWidth', update.labelMaxWidth);
145
+ });
146
+ }
147
+ }
148
+
149
+ export function createCytoscapeEdgeRouteData(
150
+ route: GraphEdgeRoute | undefined,
151
+ sourceCenter: GraphPosition,
152
+ targetCenter: GraphPosition,
153
+ ): CytoscapeEdgeRouteData | undefined {
154
+ if (!route || !isValidRoute(route)) return undefined;
155
+ const segments = route.bendPoints.map((point) => segmentFromPoint(route.startPoint, route.endPoint, point));
156
+ return {
157
+ sourceEndpoint: endpointOffset(route.startPoint, sourceCenter),
158
+ targetEndpoint: endpointOffset(route.endPoint, targetCenter),
159
+ segmentWeights: segments.map((segment) => segment.weight).join(' '),
160
+ segmentDistances: segments.map((segment) => segment.distance).join(' '),
161
+ };
162
+ }
163
+
164
+ function elkPadding(): string {
165
+ const padding = GRAPH_GEOMETRY.elkGroupPadding;
166
+ return `[top=${padding.top},left=${padding.left},bottom=${padding.bottom},right=${padding.right}]`;
167
+ }
168
+
169
+ function appendPort(
170
+ portsByNode: Map<GraphSceneElementId, ElkPort[]>,
171
+ nodeId: GraphSceneElementId,
172
+ port: ElkPort,
173
+ ): void {
174
+ const ports = portsByNode.get(nodeId) ?? [];
175
+ ports.push(port);
176
+ portsByNode.set(nodeId, ports);
177
+ }
178
+
179
+ function collectLayoutPositions(
180
+ node: ElkNode,
181
+ offsetX: number,
182
+ offsetY: number,
183
+ positions: Map<GraphSceneElementId, GraphPosition>,
184
+ ): void {
185
+ for (const child of node.children ?? []) {
186
+ const x = offsetX + (child.x ?? 0);
187
+ const y = offsetY + (child.y ?? 0);
188
+ positions.set(child.id, {
189
+ x: x + (child.width ?? 0) / 2,
190
+ y: y + (child.height ?? 0) / 2,
191
+ });
192
+ collectLayoutPositions(child, x, y, positions);
193
+ }
194
+ }
195
+
196
+ function collectLayoutOffsets(
197
+ node: ElkNode,
198
+ offsetX: number,
199
+ offsetY: number,
200
+ offsets: Map<GraphSceneElementId, GraphPosition>,
201
+ ): void {
202
+ offsets.set(node.id, { x: offsetX, y: offsetY });
203
+ for (const child of node.children ?? []) {
204
+ collectLayoutOffsets(child, offsetX + (child.x ?? 0), offsetY + (child.y ?? 0), offsets);
205
+ }
206
+ }
207
+
208
+ function collectEdgeRoutes(
209
+ node: ElkNode,
210
+ offsetX: number,
211
+ offsetY: number,
212
+ nodeOffsets: ReadonlyMap<GraphSceneElementId, GraphPosition>,
213
+ routes: Map<GraphSceneElementId, GraphEdgeRoute>,
214
+ ): void {
215
+ for (const edge of node.edges ?? []) {
216
+ if (edge.sections?.length !== 1) continue;
217
+ const section = edge.sections[0];
218
+ if (!section) continue;
219
+ // ELK may keep hierarchy edges on an ancestor while expressing their sections in container-local coordinates.
220
+ const containerOffset = edge.container === undefined ? undefined : nodeOffsets.get(edge.container);
221
+ const routeOffsetX = containerOffset?.x ?? offsetX;
222
+ const routeOffsetY = containerOffset?.y ?? offsetY;
223
+ routes.set(edge.id, {
224
+ startPoint: offsetPoint(section.startPoint, routeOffsetX, routeOffsetY),
225
+ bendPoints: (section.bendPoints ?? []).map((point) => offsetPoint(point, routeOffsetX, routeOffsetY)),
226
+ endPoint: offsetPoint(section.endPoint, routeOffsetX, routeOffsetY),
227
+ });
228
+ }
229
+ for (const child of node.children ?? []) {
230
+ collectEdgeRoutes(
231
+ child,
232
+ offsetX + (child.x ?? 0),
233
+ offsetY + (child.y ?? 0),
234
+ nodeOffsets,
235
+ routes,
236
+ );
237
+ }
238
+ }
239
+
240
+ function offsetPoint(point: GraphPosition, offsetX: number, offsetY: number): GraphPosition {
241
+ return { x: point.x + offsetX, y: point.y + offsetY };
242
+ }
243
+
244
+ function isValidRoute(route: GraphEdgeRoute): boolean {
245
+ const dx = route.endPoint.x - route.startPoint.x;
246
+ const dy = route.endPoint.y - route.startPoint.y;
247
+ return isFinitePoint(route.startPoint)
248
+ && isFinitePoint(route.endPoint)
249
+ && dx * dx + dy * dy > Number.EPSILON
250
+ && route.bendPoints.every(isFinitePoint);
251
+ }
252
+
253
+ function isFinitePoint(point: GraphPosition): boolean {
254
+ return Number.isFinite(point.x) && Number.isFinite(point.y);
255
+ }
256
+
257
+ function endpointOffset(point: GraphPosition, center: GraphPosition): string {
258
+ return `${point.x - center.x}px ${point.y - center.y}px`;
259
+ }
260
+
261
+ function applyPositions(core: cytoscape.Core, positions: ReadonlyMap<GraphSceneElementId, GraphPosition>): void {
262
+ core.batch(() => {
263
+ for (const [id, position] of positions) {
264
+ const node = core.getElementById(id);
265
+ if (node.nonempty() && node.isNode() && !node.isParent()) node.position(position);
266
+ }
267
+ });
268
+ }
269
+
270
+ function segmentFromPoint(source: GraphPosition, target: GraphPosition, point: GraphPosition) {
271
+ const dx = target.x - source.x;
272
+ const dy = target.y - source.y;
273
+ const lengthSquared = dx * dx + dy * dy;
274
+ if (lengthSquared === 0) return { weight: 0.5, distance: 0 };
275
+ const weight = ((point.x - source.x) * dx + (point.y - source.y) * dy) / lengthSquared;
276
+ const projectedX = source.x + weight * dx;
277
+ const projectedY = source.y + weight * dy;
278
+ const length = Math.sqrt(lengthSquared);
279
+ const distance = ((point.x - projectedX) * -dy + (point.y - projectedY) * dx) / length;
280
+ return { weight, distance };
281
+ }
@@ -0,0 +1,21 @@
1
+ import type { GraphScene, GraphSceneElementId } from './panelGraphScene.ts';
2
+ import type { Selection } from './panelTypes.ts';
3
+
4
+ export type GraphRenderRequest = {
5
+ readonly scene: GraphScene;
6
+ readonly selected: Selection | undefined;
7
+ readonly hovered: Selection | undefined;
8
+ readonly fit: boolean;
9
+ readonly anchorElementId?: GraphSceneElementId;
10
+ readonly onSelect: (selection: Selection) => void;
11
+ readonly onHover: (selection: Selection | undefined) => void;
12
+ readonly onToggleGroup: (pathKey: string) => void;
13
+ };
14
+
15
+ export interface GraphRenderer {
16
+ render(request: GraphRenderRequest): void;
17
+ resize(): void;
18
+ fit(): void;
19
+ relayout(): void;
20
+ destroy(): void;
21
+ }