@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.
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/dist/FrameGraphInspector.d.ts +153 -0
- package/dist/FrameGraphInspector.d.ts.map +1 -0
- package/dist/FrameGraphInspector.js +580 -0
- package/dist/FrameGraphInspector.js.map +1 -0
- package/dist/debugCaptureModel.d.ts +166 -0
- package/dist/debugCaptureModel.d.ts.map +1 -0
- package/dist/debugCaptureModel.js +364 -0
- package/dist/debugCaptureModel.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/panelAliasAnalysis.d.ts +20 -0
- package/dist/panelAliasAnalysis.d.ts.map +1 -0
- package/dist/panelAliasAnalysis.js +113 -0
- package/dist/panelAliasAnalysis.js.map +1 -0
- package/dist/panelCytoscapeGraphRenderer.d.ts +71 -0
- package/dist/panelCytoscapeGraphRenderer.d.ts.map +1 -0
- package/dist/panelCytoscapeGraphRenderer.js +559 -0
- package/dist/panelCytoscapeGraphRenderer.js.map +1 -0
- package/dist/panelDiagnosticsView.d.ts +22 -0
- package/dist/panelDiagnosticsView.d.ts.map +1 -0
- package/dist/panelDiagnosticsView.js +123 -0
- package/dist/panelDiagnosticsView.js.map +1 -0
- package/dist/panelDomHelpers.d.ts +26 -0
- package/dist/panelDomHelpers.d.ts.map +1 -0
- package/dist/panelDomHelpers.js +129 -0
- package/dist/panelDomHelpers.js.map +1 -0
- package/dist/panelGraphLayout.d.ts +28 -0
- package/dist/panelGraphLayout.d.ts.map +1 -0
- package/dist/panelGraphLayout.js +223 -0
- package/dist/panelGraphLayout.js.map +1 -0
- package/dist/panelGraphRenderer.d.ts +20 -0
- package/dist/panelGraphRenderer.d.ts.map +1 -0
- package/dist/panelGraphRenderer.js +2 -0
- package/dist/panelGraphRenderer.js.map +1 -0
- package/dist/panelGraphScene.d.ts +98 -0
- package/dist/panelGraphScene.d.ts.map +1 -0
- package/dist/panelGraphScene.js +559 -0
- package/dist/panelGraphScene.js.map +1 -0
- package/dist/panelGraphView.d.ts +9 -0
- package/dist/panelGraphView.d.ts.map +1 -0
- package/dist/panelGraphView.js +88 -0
- package/dist/panelGraphView.js.map +1 -0
- package/dist/panelGraphVisuals.d.ts +38 -0
- package/dist/panelGraphVisuals.d.ts.map +1 -0
- package/dist/panelGraphVisuals.js +330 -0
- package/dist/panelGraphVisuals.js.map +1 -0
- package/dist/panelIcons.d.ts +4 -0
- package/dist/panelIcons.d.ts.map +1 -0
- package/dist/panelIcons.js +43 -0
- package/dist/panelIcons.js.map +1 -0
- package/dist/panelInspectorView.d.ts +33 -0
- package/dist/panelInspectorView.d.ts.map +1 -0
- package/dist/panelInspectorView.js +357 -0
- package/dist/panelInspectorView.js.map +1 -0
- package/dist/panelMemoryView.d.ts +21 -0
- package/dist/panelMemoryView.d.ts.map +1 -0
- package/dist/panelMemoryView.js +134 -0
- package/dist/panelMemoryView.js.map +1 -0
- package/dist/panelPassesView.d.ts +31 -0
- package/dist/panelPassesView.d.ts.map +1 -0
- package/dist/panelPassesView.js +183 -0
- package/dist/panelPassesView.js.map +1 -0
- package/dist/panelResourcesView.d.ts +19 -0
- package/dist/panelResourcesView.d.ts.map +1 -0
- package/dist/panelResourcesView.js +89 -0
- package/dist/panelResourcesView.js.map +1 -0
- package/dist/panelSelection.d.ts +5 -0
- package/dist/panelSelection.d.ts.map +1 -0
- package/dist/panelSelection.js +88 -0
- package/dist/panelSelection.js.map +1 -0
- package/dist/panelTypes.d.ts +40 -0
- package/dist/panelTypes.d.ts.map +1 -0
- package/dist/panelTypes.js +2 -0
- package/dist/panelTypes.js.map +1 -0
- package/dist/panelVisualTheme.d.ts +76 -0
- package/dist/panelVisualTheme.d.ts.map +1 -0
- package/dist/panelVisualTheme.js +39 -0
- package/dist/panelVisualTheme.js.map +1 -0
- package/dist/panelWorkbenchHelpers.d.ts +34 -0
- package/dist/panelWorkbenchHelpers.d.ts.map +1 -0
- package/dist/panelWorkbenchHelpers.js +162 -0
- package/dist/panelWorkbenchHelpers.js.map +1 -0
- package/dist/panelWorkbenchView.d.ts +77 -0
- package/dist/panelWorkbenchView.d.ts.map +1 -0
- package/dist/panelWorkbenchView.js +409 -0
- package/dist/panelWorkbenchView.js.map +1 -0
- package/dist/query.d.ts +11 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +21 -0
- package/dist/query.js.map +1 -0
- package/dist/styles.d.ts +8 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/styles.js +1073 -0
- package/dist/styles.js.map +1 -0
- package/package.json +29 -0
- package/src/FrameGraphInspector.ts +632 -0
- package/src/debugCaptureModel.ts +583 -0
- package/src/index.ts +21 -0
- package/src/panelAliasAnalysis.ts +164 -0
- package/src/panelCytoscapeGraphRenderer.ts +617 -0
- package/src/panelDiagnosticsView.ts +160 -0
- package/src/panelDomHelpers.ts +151 -0
- package/src/panelGraphLayout.ts +281 -0
- package/src/panelGraphRenderer.ts +21 -0
- package/src/panelGraphScene.ts +787 -0
- package/src/panelGraphView.ts +108 -0
- package/src/panelGraphVisuals.ts +364 -0
- package/src/panelIcons.ts +56 -0
- package/src/panelInspectorView.ts +377 -0
- package/src/panelMemoryView.ts +154 -0
- package/src/panelPassesView.ts +222 -0
- package/src/panelResourcesView.ts +115 -0
- package/src/panelSelection.ts +99 -0
- package/src/panelTypes.ts +28 -0
- package/src/panelVisualTheme.ts +39 -0
- package/src/panelWorkbenchHelpers.ts +216 -0
- package/src/panelWorkbenchView.ts +465 -0
- package/src/query.ts +22 -0
- package/src/styles.ts +1076 -0
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
import type cytoscape from 'cytoscape';
|
|
2
|
+
import type { ELK } from 'elkjs/lib/elk-api';
|
|
3
|
+
|
|
4
|
+
import type { GraphRenderer, GraphRenderRequest } from './panelGraphRenderer.ts';
|
|
5
|
+
import {
|
|
6
|
+
applyGraphLayout,
|
|
7
|
+
layoutGraphScene,
|
|
8
|
+
syncExpandedGroupLabelGeometry,
|
|
9
|
+
type GraphLayoutResult,
|
|
10
|
+
type GraphPosition,
|
|
11
|
+
} from './panelGraphLayout.ts';
|
|
12
|
+
import {
|
|
13
|
+
selectionKey,
|
|
14
|
+
type GraphScene,
|
|
15
|
+
type GraphSceneEdge,
|
|
16
|
+
type GraphSceneElementId,
|
|
17
|
+
type GraphSceneNode,
|
|
18
|
+
} from './panelGraphScene.ts';
|
|
19
|
+
import {
|
|
20
|
+
createGraphStyles,
|
|
21
|
+
expandedGroupLabelMaxWidth,
|
|
22
|
+
GRAPH_GEOMETRY,
|
|
23
|
+
graphEdgeDisplayLabel,
|
|
24
|
+
graphLayoutGeometryKey,
|
|
25
|
+
isOverviewGraphScale,
|
|
26
|
+
nodeDimensions,
|
|
27
|
+
} from './panelGraphVisuals.ts';
|
|
28
|
+
|
|
29
|
+
type Viewport = { readonly pan: GraphPosition; readonly zoom: number };
|
|
30
|
+
|
|
31
|
+
type GraphRendererRuntime = {
|
|
32
|
+
readonly createCore: (options: cytoscape.CytoscapeOptions) => cytoscape.Core;
|
|
33
|
+
readonly elk: ELK;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type GraphResizeSubscription = { disconnect(): void };
|
|
37
|
+
|
|
38
|
+
export type GraphRendererEnvironment = {
|
|
39
|
+
readonly createElement: (tagName: string) => HTMLElement;
|
|
40
|
+
readonly loadRuntime: () => Promise<GraphRendererRuntime>;
|
|
41
|
+
readonly layoutScene: (elk: ELK, scene: GraphScene) => Promise<GraphLayoutResult>;
|
|
42
|
+
readonly observeResize?: (element: HTMLElement, onResize: () => void) => GraphResizeSubscription;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export class CytoscapeGraphRenderer implements GraphRenderer {
|
|
46
|
+
private readonly canvasHost: HTMLElement;
|
|
47
|
+
private readonly tooltip: HTMLElement;
|
|
48
|
+
private readonly status: HTMLElement;
|
|
49
|
+
private readonly statusMessage: HTMLElement;
|
|
50
|
+
private readonly retryButton: HTMLButtonElement;
|
|
51
|
+
private readonly resizeSubscription: GraphResizeSubscription | undefined;
|
|
52
|
+
private core: cytoscape.Core | undefined;
|
|
53
|
+
private elk: ELK | undefined;
|
|
54
|
+
private loading: Promise<void> | undefined;
|
|
55
|
+
private processing: Promise<void> | undefined;
|
|
56
|
+
private latestRequest: GraphRenderRequest | undefined;
|
|
57
|
+
private appliedScene: GraphScene | undefined;
|
|
58
|
+
private requestVersion = 0;
|
|
59
|
+
private processedVersion = 0;
|
|
60
|
+
private destroyed = false;
|
|
61
|
+
private fitTargetContentKey: string | undefined;
|
|
62
|
+
private forceRelayout = false;
|
|
63
|
+
private anchorElementId: GraphSceneElementId | undefined;
|
|
64
|
+
private anchorTargetContentKey: string | undefined;
|
|
65
|
+
private failedSceneContentKey: string | undefined;
|
|
66
|
+
private overview = false;
|
|
67
|
+
private lastTap: { readonly id: string; readonly at: number } | undefined;
|
|
68
|
+
|
|
69
|
+
constructor(
|
|
70
|
+
private readonly host: HTMLElement,
|
|
71
|
+
private readonly environment: GraphRendererEnvironment = browserGraphRendererEnvironment,
|
|
72
|
+
) {
|
|
73
|
+
this.canvasHost = environment.createElement('div');
|
|
74
|
+
this.canvasHost.className = 'zenfg-inspector-graph-canvas';
|
|
75
|
+
this.tooltip = environment.createElement('div');
|
|
76
|
+
this.tooltip.className = 'zenfg-inspector-graph-tooltip';
|
|
77
|
+
this.tooltip.hidden = true;
|
|
78
|
+
this.status = environment.createElement('div');
|
|
79
|
+
this.status.className = 'zenfg-inspector-graph-status';
|
|
80
|
+
this.status.hidden = true;
|
|
81
|
+
this.status.setAttribute('role', 'status');
|
|
82
|
+
this.status.setAttribute('aria-live', 'polite');
|
|
83
|
+
this.statusMessage = environment.createElement('span');
|
|
84
|
+
this.retryButton = environment.createElement('button') as HTMLButtonElement;
|
|
85
|
+
this.retryButton.type = 'button';
|
|
86
|
+
this.retryButton.textContent = 'Retry';
|
|
87
|
+
this.retryButton.hidden = true;
|
|
88
|
+
this.retryButton.addEventListener('click', () => this.relayout());
|
|
89
|
+
this.status.append(this.statusMessage, this.retryButton);
|
|
90
|
+
this.host.replaceChildren(this.canvasHost, this.status, this.tooltip);
|
|
91
|
+
this.resizeSubscription = environment.observeResize?.(host, () => this.core?.resize());
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
render(request: GraphRenderRequest): void {
|
|
95
|
+
if (this.destroyed) return;
|
|
96
|
+
this.latestRequest = request;
|
|
97
|
+
if (request.fit) {
|
|
98
|
+
this.fitTargetContentKey = request.scene.contentKey;
|
|
99
|
+
} else if (this.fitTargetContentKey !== request.scene.contentKey) {
|
|
100
|
+
this.fitTargetContentKey = undefined;
|
|
101
|
+
}
|
|
102
|
+
if (request.anchorElementId !== undefined) {
|
|
103
|
+
this.anchorElementId = request.anchorElementId;
|
|
104
|
+
this.anchorTargetContentKey = request.scene.contentKey;
|
|
105
|
+
} else if (this.anchorTargetContentKey !== request.scene.contentKey) {
|
|
106
|
+
this.anchorElementId = undefined;
|
|
107
|
+
this.anchorTargetContentKey = undefined;
|
|
108
|
+
}
|
|
109
|
+
if (this.failedSceneContentKey && this.failedSceneContentKey !== request.scene.contentKey) {
|
|
110
|
+
this.failedSceneContentKey = undefined;
|
|
111
|
+
this.hideStatus();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const version = ++this.requestVersion;
|
|
115
|
+
if (this.failedSceneContentKey === request.scene.contentKey) {
|
|
116
|
+
this.syncInteraction(request);
|
|
117
|
+
this.processedVersion = version;
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (!this.processing && this.core && this.appliedScene?.contentKey === request.scene.contentKey && !this.forceRelayout) {
|
|
121
|
+
this.appliedScene = request.scene;
|
|
122
|
+
this.syncInteraction(request);
|
|
123
|
+
this.consumeFitIfCurrentTarget();
|
|
124
|
+
this.consumeAnchorIfCurrentTarget();
|
|
125
|
+
this.processedVersion = version;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
this.scheduleProcessing();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
resize(): void {
|
|
132
|
+
this.core?.resize();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
fit(): void {
|
|
136
|
+
const targetContentKey = this.latestRequest?.scene.contentKey;
|
|
137
|
+
if (targetContentKey) this.fitTargetContentKey = targetContentKey;
|
|
138
|
+
if (!this.core || !this.appliedScene) return;
|
|
139
|
+
this.core.resize();
|
|
140
|
+
this.core.fit(undefined, GRAPH_GEOMETRY.fitPadding);
|
|
141
|
+
this.updateSemanticZoom();
|
|
142
|
+
if (!this.targetNeedsApply()) this.fitTargetContentKey = undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
relayout(): void {
|
|
146
|
+
if (!this.latestRequest || this.destroyed) return;
|
|
147
|
+
this.failedSceneContentKey = undefined;
|
|
148
|
+
this.forceRelayout = true;
|
|
149
|
+
this.fitTargetContentKey = this.latestRequest.scene.contentKey;
|
|
150
|
+
this.anchorElementId = undefined;
|
|
151
|
+
this.anchorTargetContentKey = undefined;
|
|
152
|
+
this.hideTooltip();
|
|
153
|
+
++this.requestVersion;
|
|
154
|
+
this.scheduleProcessing();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
destroy(): void {
|
|
158
|
+
if (this.destroyed) return;
|
|
159
|
+
this.destroyed = true;
|
|
160
|
+
this.requestVersion++;
|
|
161
|
+
this.resizeSubscription?.disconnect();
|
|
162
|
+
this.hideTooltip();
|
|
163
|
+
this.core?.destroy();
|
|
164
|
+
this.core = undefined;
|
|
165
|
+
this.elk = undefined;
|
|
166
|
+
this.loading = undefined;
|
|
167
|
+
this.latestRequest = undefined;
|
|
168
|
+
this.appliedScene = undefined;
|
|
169
|
+
this.fitTargetContentKey = undefined;
|
|
170
|
+
this.anchorElementId = undefined;
|
|
171
|
+
this.anchorTargetContentKey = undefined;
|
|
172
|
+
this.host.replaceChildren();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private scheduleProcessing(): void {
|
|
176
|
+
if (this.processing || this.destroyed) return;
|
|
177
|
+
this.processing = this.processRequests().finally(() => {
|
|
178
|
+
this.processing = undefined;
|
|
179
|
+
if (!this.destroyed
|
|
180
|
+
&& !this.failedSceneContentKey
|
|
181
|
+
&& this.processedVersion !== this.requestVersion) {
|
|
182
|
+
this.scheduleProcessing();
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private async processRequests(): Promise<void> {
|
|
188
|
+
while (!this.destroyed && this.processedVersion !== this.requestVersion) {
|
|
189
|
+
let version = this.requestVersion;
|
|
190
|
+
let request = this.latestRequest;
|
|
191
|
+
if (!request) return;
|
|
192
|
+
|
|
193
|
+
if (request.scene.nodes.length === 0) {
|
|
194
|
+
this.hideTooltip();
|
|
195
|
+
if (this.core) replaceElements(this.core, request.scene);
|
|
196
|
+
this.appliedScene = request.scene;
|
|
197
|
+
this.forceRelayout = false;
|
|
198
|
+
if (this.fitTargetContentKey === request.scene.contentKey) this.fitTargetContentKey = undefined;
|
|
199
|
+
this.consumeAnchorIfCurrentTarget();
|
|
200
|
+
this.syncInteraction(request);
|
|
201
|
+
this.showStatus('empty', 'No graph elements in this view.');
|
|
202
|
+
this.processedVersion = version;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
if (!this.core) this.showStatus('loading', 'Loading graph runtime…');
|
|
208
|
+
await this.ensureRuntime();
|
|
209
|
+
const runtimeRequest = this.resolveCurrentRequest(version, request.scene.contentKey);
|
|
210
|
+
if (!runtimeRequest) continue;
|
|
211
|
+
({ version, request } = runtimeRequest);
|
|
212
|
+
const core = this.core!;
|
|
213
|
+
if (this.appliedScene?.contentKey === request.scene.contentKey && !this.forceRelayout) {
|
|
214
|
+
this.appliedScene = request.scene;
|
|
215
|
+
this.syncInteraction(request);
|
|
216
|
+
this.consumeFitIfCurrentTarget();
|
|
217
|
+
this.consumeAnchorIfCurrentTarget();
|
|
218
|
+
this.hideStatus();
|
|
219
|
+
this.processedVersion = version;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
this.hideTooltip();
|
|
224
|
+
const previousViewport = viewport(core);
|
|
225
|
+
const anchorElementId = this.anchorTargetContentKey === request.scene.contentKey
|
|
226
|
+
? this.anchorElementId
|
|
227
|
+
: undefined;
|
|
228
|
+
const anchorBefore = anchorElementId === undefined
|
|
229
|
+
? undefined
|
|
230
|
+
: renderedPosition(core, anchorElementId);
|
|
231
|
+
const topologyChanged = this.appliedScene?.topologyKey !== request.scene.topologyKey;
|
|
232
|
+
const geometryChanged = !this.appliedScene
|
|
233
|
+
|| graphLayoutGeometryKey(this.appliedScene) !== graphLayoutGeometryKey(request.scene);
|
|
234
|
+
let laidOut = false;
|
|
235
|
+
|
|
236
|
+
if (geometryChanged || this.forceRelayout) {
|
|
237
|
+
this.showStatus('layout', 'Laying out graph…');
|
|
238
|
+
const result = await this.environment.layoutScene(this.elk!, request.scene);
|
|
239
|
+
const layoutRequest = this.resolveCurrentRequest(version, request.scene.contentKey);
|
|
240
|
+
if (!layoutRequest) continue;
|
|
241
|
+
({ version, request } = layoutRequest);
|
|
242
|
+
if (topologyChanged || !this.appliedScene) replaceElements(core, request.scene);
|
|
243
|
+
else updateElementData(core, request.scene);
|
|
244
|
+
applyGraphLayout(core, request.scene, result);
|
|
245
|
+
laidOut = true;
|
|
246
|
+
} else {
|
|
247
|
+
updateElementData(core, request.scene);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
syncExpandedGroupLabelGeometry(core);
|
|
251
|
+
this.appliedScene = request.scene;
|
|
252
|
+
this.forceRelayout = false;
|
|
253
|
+
this.failedSceneContentKey = undefined;
|
|
254
|
+
this.syncInteraction(request);
|
|
255
|
+
core.resize();
|
|
256
|
+
if (this.fitTargetContentKey === request.scene.contentKey) {
|
|
257
|
+
core.fit(undefined, GRAPH_GEOMETRY.fitPadding);
|
|
258
|
+
this.fitTargetContentKey = undefined;
|
|
259
|
+
} else if (laidOut && anchorBefore && anchorElementId) {
|
|
260
|
+
restoreViewport(core, previousViewport);
|
|
261
|
+
const anchorAfter = renderedPosition(core, anchorElementId);
|
|
262
|
+
if (anchorAfter) core.panBy({ x: anchorBefore.x - anchorAfter.x, y: anchorBefore.y - anchorAfter.y });
|
|
263
|
+
} else {
|
|
264
|
+
restoreViewport(core, previousViewport);
|
|
265
|
+
}
|
|
266
|
+
this.consumeAnchorIfCurrentTarget();
|
|
267
|
+
this.updateSemanticZoom(true);
|
|
268
|
+
this.hideStatus();
|
|
269
|
+
this.processedVersion = version;
|
|
270
|
+
} catch (error) {
|
|
271
|
+
const failedRequest = this.resolveCurrentRequest(version, request.scene.contentKey);
|
|
272
|
+
if (!failedRequest) continue;
|
|
273
|
+
({ version, request } = failedRequest);
|
|
274
|
+
this.forceRelayout = false;
|
|
275
|
+
this.failedSceneContentKey = request.scene.contentKey;
|
|
276
|
+
this.processedVersion = version;
|
|
277
|
+
this.showStatus(
|
|
278
|
+
'error',
|
|
279
|
+
`Failed to render graph: ${error instanceof Error ? error.message : String(error)}`,
|
|
280
|
+
true,
|
|
281
|
+
);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private async ensureRuntime(): Promise<void> {
|
|
288
|
+
if (this.core || this.destroyed) return;
|
|
289
|
+
this.loading ??= this.loadRuntime().catch((error) => {
|
|
290
|
+
this.loading = undefined;
|
|
291
|
+
throw error;
|
|
292
|
+
});
|
|
293
|
+
await this.loading;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private async loadRuntime(): Promise<void> {
|
|
297
|
+
const runtime = await this.environment.loadRuntime();
|
|
298
|
+
if (this.destroyed) return;
|
|
299
|
+
this.canvasHost.textContent = '';
|
|
300
|
+
this.elk = runtime.elk;
|
|
301
|
+
this.core = runtime.createCore({
|
|
302
|
+
container: this.canvasHost,
|
|
303
|
+
elements: [],
|
|
304
|
+
style: createGraphStyles(),
|
|
305
|
+
layout: { name: 'preset' },
|
|
306
|
+
minZoom: 0.03,
|
|
307
|
+
maxZoom: 4,
|
|
308
|
+
boxSelectionEnabled: false,
|
|
309
|
+
autoungrabify: true,
|
|
310
|
+
autounselectify: true,
|
|
311
|
+
});
|
|
312
|
+
this.bindEvents(this.core);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private bindEvents(core: cytoscape.Core): void {
|
|
316
|
+
const selectElement = (event: cytoscape.EventObject) => {
|
|
317
|
+
const request = this.interactiveRequest();
|
|
318
|
+
if (!request) return;
|
|
319
|
+
const id = event.target.id() as GraphSceneElementId;
|
|
320
|
+
const selection = request.scene.interaction.selectionByElementId.get(id);
|
|
321
|
+
if (selection) request.onSelect(selection);
|
|
322
|
+
const now = performance.now();
|
|
323
|
+
if (this.lastTap?.id === id && now - this.lastTap.at <= 350 && selection?.kind === 'group') {
|
|
324
|
+
request.onToggleGroup(selection.pathKey);
|
|
325
|
+
this.lastTap = undefined;
|
|
326
|
+
} else {
|
|
327
|
+
this.lastTap = { id, at: now };
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
core.on('tap', 'node', selectElement);
|
|
331
|
+
core.on('tap', 'edge', selectElement);
|
|
332
|
+
core.on('tap', (event) => {
|
|
333
|
+
if (event.target === core) this.lastTap = undefined;
|
|
334
|
+
});
|
|
335
|
+
const hoverElement = (event: cytoscape.EventObject) => {
|
|
336
|
+
const request = this.interactiveRequest();
|
|
337
|
+
if (!request) return;
|
|
338
|
+
const id = event.target.id() as GraphSceneElementId;
|
|
339
|
+
const selection = request.scene.interaction.selectionByElementId.get(id);
|
|
340
|
+
if (selection) request.onHover(selection);
|
|
341
|
+
const title = event.target.data('tooltip') as string | undefined;
|
|
342
|
+
if (title) {
|
|
343
|
+
this.tooltip.textContent = title;
|
|
344
|
+
this.tooltip.hidden = false;
|
|
345
|
+
this.positionTooltip(event.renderedPosition);
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
core.on('mouseover', 'node', hoverElement);
|
|
349
|
+
core.on('mouseover', 'edge', hoverElement);
|
|
350
|
+
const moveTooltip = (event: cytoscape.EventObject) => this.positionTooltip(event.renderedPosition);
|
|
351
|
+
core.on('mousemove', 'node', moveTooltip);
|
|
352
|
+
core.on('mousemove', 'edge', moveTooltip);
|
|
353
|
+
const clearHover = () => {
|
|
354
|
+
this.hideTooltip();
|
|
355
|
+
this.latestRequest?.onHover(undefined);
|
|
356
|
+
};
|
|
357
|
+
core.on('mouseout', 'node', clearHover);
|
|
358
|
+
core.on('mouseout', 'edge', clearHover);
|
|
359
|
+
core.on('zoom', () => this.updateSemanticZoom());
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private interactiveRequest(): GraphRenderRequest | undefined {
|
|
363
|
+
const request = this.latestRequest;
|
|
364
|
+
return request && this.appliedScene?.contentKey === request.scene.contentKey ? request : undefined;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private syncInteraction(request: GraphRenderRequest): void {
|
|
368
|
+
const core = this.core;
|
|
369
|
+
if (!core || this.appliedScene?.topologyKey !== request.scene.topologyKey) return;
|
|
370
|
+
core.batch(() => {
|
|
371
|
+
core.elements().removeClass('semantic-selected semantic-hover');
|
|
372
|
+
if (request.hovered) {
|
|
373
|
+
for (const id of request.scene.interaction.relatedElementIdsBySelection.get(selectionKey(request.hovered)) ?? []) {
|
|
374
|
+
core.getElementById(id).addClass('semantic-hover');
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (request.selected) {
|
|
378
|
+
for (const id of request.scene.interaction.primaryElementIdsBySelection.get(selectionKey(request.selected)) ?? []) {
|
|
379
|
+
core.getElementById(id).addClass('semantic-selected');
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private consumeFitIfCurrentTarget(): void {
|
|
386
|
+
const targetContentKey = this.latestRequest?.scene.contentKey;
|
|
387
|
+
if (!targetContentKey
|
|
388
|
+
|| this.fitTargetContentKey !== targetContentKey
|
|
389
|
+
|| !this.core
|
|
390
|
+
|| this.targetNeedsApply()) return;
|
|
391
|
+
this.core.resize();
|
|
392
|
+
this.core.fit(undefined, GRAPH_GEOMETRY.fitPadding);
|
|
393
|
+
this.fitTargetContentKey = undefined;
|
|
394
|
+
this.updateSemanticZoom();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
private consumeAnchorIfCurrentTarget(): void {
|
|
398
|
+
const targetContentKey = this.latestRequest?.scene.contentKey;
|
|
399
|
+
if (!targetContentKey || this.anchorTargetContentKey !== targetContentKey) return;
|
|
400
|
+
this.anchorElementId = undefined;
|
|
401
|
+
this.anchorTargetContentKey = undefined;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private targetNeedsApply(): boolean {
|
|
405
|
+
const target = this.latestRequest?.scene;
|
|
406
|
+
if (!target || !this.appliedScene) return target !== undefined;
|
|
407
|
+
return this.forceRelayout || this.appliedScene.contentKey !== target.contentKey;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private updateSemanticZoom(force = false): void {
|
|
411
|
+
const core = this.core;
|
|
412
|
+
if (!core) return;
|
|
413
|
+
const overview = isOverviewGraphScale(core.zoom());
|
|
414
|
+
if (!force && overview === this.overview) return;
|
|
415
|
+
this.overview = overview;
|
|
416
|
+
core.batch(() => {
|
|
417
|
+
for (const node of core.nodes()) {
|
|
418
|
+
node.data('displayLabel', overview ? node.data('overviewLabel') : node.data('detailLabel'));
|
|
419
|
+
}
|
|
420
|
+
for (const edge of core.edges()) {
|
|
421
|
+
edge.data('displayLabel', overview ? '' : edge.data('detailLabel'));
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
private positionTooltip(position: GraphPosition): void {
|
|
427
|
+
const maxX = Math.max(8, this.host.clientWidth - this.tooltip.offsetWidth - 8);
|
|
428
|
+
const maxY = Math.max(8, this.host.clientHeight - this.tooltip.offsetHeight - 8);
|
|
429
|
+
this.tooltip.style.left = `${Math.max(8, Math.min(maxX, position.x + 12))}px`;
|
|
430
|
+
this.tooltip.style.top = `${Math.max(8, Math.min(maxY, position.y + 12))}px`;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private hideTooltip(): void {
|
|
434
|
+
this.tooltip.hidden = true;
|
|
435
|
+
this.tooltip.textContent = '';
|
|
436
|
+
this.lastTap = undefined;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private showStatus(kind: 'loading' | 'layout' | 'empty' | 'error', message: string, retry = false): void {
|
|
440
|
+
this.status.dataset.state = kind;
|
|
441
|
+
this.statusMessage.textContent = message;
|
|
442
|
+
this.retryButton.hidden = !retry;
|
|
443
|
+
this.status.hidden = false;
|
|
444
|
+
this.host.setAttribute('aria-busy', kind === 'loading' || kind === 'layout' ? 'true' : 'false');
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private hideStatus(): void {
|
|
448
|
+
this.status.hidden = true;
|
|
449
|
+
this.retryButton.hidden = true;
|
|
450
|
+
this.host.setAttribute('aria-busy', 'false');
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
private resolveCurrentRequest(
|
|
454
|
+
version: number,
|
|
455
|
+
contentKey: string,
|
|
456
|
+
): { readonly version: number; readonly request: GraphRenderRequest } | undefined {
|
|
457
|
+
if (this.destroyed) return undefined;
|
|
458
|
+
const request = this.latestRequest;
|
|
459
|
+
if (!request) return undefined;
|
|
460
|
+
if (version === this.requestVersion || request.scene.contentKey === contentKey) {
|
|
461
|
+
return { version: this.requestVersion, request };
|
|
462
|
+
}
|
|
463
|
+
return undefined;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const browserGraphRendererEnvironment: GraphRendererEnvironment = {
|
|
468
|
+
createElement: (tagName) => document.createElement(tagName),
|
|
469
|
+
loadRuntime: async () => {
|
|
470
|
+
const [cytoscapeModule, elkModule] = await Promise.all([
|
|
471
|
+
import('cytoscape'),
|
|
472
|
+
import('elkjs/lib/elk.bundled.js'),
|
|
473
|
+
]);
|
|
474
|
+
return {
|
|
475
|
+
createCore: cytoscapeModule.default,
|
|
476
|
+
elk: new elkModule.default(),
|
|
477
|
+
};
|
|
478
|
+
},
|
|
479
|
+
layoutScene: layoutGraphScene,
|
|
480
|
+
observeResize: (element, onResize) => {
|
|
481
|
+
if (typeof ResizeObserver === 'undefined') return { disconnect: () => undefined };
|
|
482
|
+
const view = element.ownerDocument.defaultView;
|
|
483
|
+
let animationFrame: number | undefined;
|
|
484
|
+
const observer = new ResizeObserver(() => {
|
|
485
|
+
if (!view || animationFrame !== undefined) return;
|
|
486
|
+
animationFrame = view.requestAnimationFrame(() => {
|
|
487
|
+
animationFrame = undefined;
|
|
488
|
+
onResize();
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
observer.observe(element);
|
|
492
|
+
return {
|
|
493
|
+
disconnect: () => {
|
|
494
|
+
observer.disconnect();
|
|
495
|
+
if (animationFrame !== undefined) {
|
|
496
|
+
view?.cancelAnimationFrame(animationFrame);
|
|
497
|
+
animationFrame = undefined;
|
|
498
|
+
}
|
|
499
|
+
},
|
|
500
|
+
};
|
|
501
|
+
},
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
function replaceElements(core: cytoscape.Core, scene: GraphScene): void {
|
|
505
|
+
const definitions: cytoscape.ElementDefinition[] = [
|
|
506
|
+
...scene.nodes.map(nodeDefinition),
|
|
507
|
+
...scene.edges.map(edgeDefinition),
|
|
508
|
+
];
|
|
509
|
+
core.startBatch();
|
|
510
|
+
core.elements().remove();
|
|
511
|
+
core.add(definitions);
|
|
512
|
+
core.endBatch();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export function updateElementData(core: cytoscape.Core, scene: GraphScene): void {
|
|
516
|
+
core.batch(() => {
|
|
517
|
+
for (const node of scene.nodes) {
|
|
518
|
+
const element = core.getElementById(node.id);
|
|
519
|
+
if (element.nonempty()) element.data(nodeRenderableData(node));
|
|
520
|
+
}
|
|
521
|
+
for (const edge of scene.edges) {
|
|
522
|
+
const element = core.getElementById(edge.id);
|
|
523
|
+
if (element.nonempty()) element.data(edgeRenderableData(edge));
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function nodeDefinition(node: GraphSceneNode): cytoscape.ElementDefinition {
|
|
529
|
+
return {
|
|
530
|
+
group: 'nodes',
|
|
531
|
+
data: {
|
|
532
|
+
id: node.id,
|
|
533
|
+
parent: node.parentId,
|
|
534
|
+
...nodeRenderableData(node),
|
|
535
|
+
},
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function nodeRenderableData(node: GraphSceneNode): Record<string, unknown> {
|
|
540
|
+
const dimensions = nodeDimensions(node);
|
|
541
|
+
const labelMaxWidth = node.kind === 'group' && node.collapsed
|
|
542
|
+
? expandedGroupLabelMaxWidth(dimensions.width)
|
|
543
|
+
: 160;
|
|
544
|
+
return {
|
|
545
|
+
kind: node.kind,
|
|
546
|
+
passKind: node.kind === 'pass' || node.kind === 'culled-pass' ? node.passKind : undefined,
|
|
547
|
+
resourceKind: node.kind === 'resource' ? node.resourceKind : undefined,
|
|
548
|
+
collapsed: node.kind === 'group' && node.collapsed ? 1 : 0,
|
|
549
|
+
hasCulled: node.kind === 'group' && node.culledNodeCount > 0 ? 1 : 0,
|
|
550
|
+
depthBand: node.kind === 'group' ? node.depthBand : undefined,
|
|
551
|
+
detailLabel: node.label,
|
|
552
|
+
overviewLabel: node.overviewLabel,
|
|
553
|
+
displayLabel: node.label,
|
|
554
|
+
tooltip: node.title,
|
|
555
|
+
labelMaxWidth,
|
|
556
|
+
width: dimensions.width,
|
|
557
|
+
height: dimensions.height,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function edgeDefinition(edge: GraphSceneEdge): cytoscape.ElementDefinition {
|
|
562
|
+
return {
|
|
563
|
+
group: 'edges',
|
|
564
|
+
data: {
|
|
565
|
+
id: edge.id,
|
|
566
|
+
source: edge.from,
|
|
567
|
+
target: edge.to,
|
|
568
|
+
...edgeRenderableData(edge),
|
|
569
|
+
},
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function edgeRenderableData(edge: GraphSceneEdge): Record<string, unknown> {
|
|
574
|
+
const detailLabel = graphEdgeDisplayLabel(edge);
|
|
575
|
+
return {
|
|
576
|
+
kind: edge.kind,
|
|
577
|
+
dependencyKind: edge.kind === 'dependency' ? edge.dependencyKind : undefined,
|
|
578
|
+
accessMode: edge.kind === 'access' ? edge.accessMode : undefined,
|
|
579
|
+
dashed: edge.kind === 'access' && edge.dashed ? 1 : 0,
|
|
580
|
+
detailLabel,
|
|
581
|
+
displayLabel: detailLabel,
|
|
582
|
+
tooltip: edge.title,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function viewport(core: cytoscape.Core): Viewport {
|
|
587
|
+
return { pan: { ...core.pan() }, zoom: core.zoom() };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function restoreViewport(core: cytoscape.Core, state: Viewport): void {
|
|
591
|
+
core.zoom(state.zoom);
|
|
592
|
+
core.pan(state.pan);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function renderedPosition(core: cytoscape.Core, id: GraphSceneElementId): GraphPosition | undefined {
|
|
596
|
+
const element = core.getElementById(id);
|
|
597
|
+
return element.nonempty() && element.isNode() ? { ...element.renderedPosition() } : undefined;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export {
|
|
601
|
+
createCytoscapeEdgeRouteData,
|
|
602
|
+
createElkLayoutGraph,
|
|
603
|
+
layoutGraphScene,
|
|
604
|
+
} from './panelGraphLayout.ts';
|
|
605
|
+
export type {
|
|
606
|
+
CytoscapeEdgeRouteData,
|
|
607
|
+
GraphEdgeRoute,
|
|
608
|
+
GraphLayoutResult,
|
|
609
|
+
GraphPosition,
|
|
610
|
+
} from './panelGraphLayout.ts';
|
|
611
|
+
export {
|
|
612
|
+
createGraphStyles,
|
|
613
|
+
expandedGroupLabelMaxWidth,
|
|
614
|
+
graphEdgeDisplayLabel,
|
|
615
|
+
graphLayoutGeometryKey,
|
|
616
|
+
isOverviewGraphScale,
|
|
617
|
+
} from './panelGraphVisuals.ts';
|