polycodegraph 0.1.0__py3-none-any.whl
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.
- codegraph/__init__.py +10 -0
- codegraph/analysis/__init__.py +30 -0
- codegraph/analysis/_common.py +125 -0
- codegraph/analysis/blast_radius.py +63 -0
- codegraph/analysis/cycles.py +79 -0
- codegraph/analysis/dataflow.py +861 -0
- codegraph/analysis/dead_code.py +165 -0
- codegraph/analysis/hotspots.py +68 -0
- codegraph/analysis/infrastructure.py +439 -0
- codegraph/analysis/metrics.py +52 -0
- codegraph/analysis/report.py +222 -0
- codegraph/analysis/roles.py +323 -0
- codegraph/analysis/untested.py +79 -0
- codegraph/cli.py +1506 -0
- codegraph/config.py +64 -0
- codegraph/embed/__init__.py +35 -0
- codegraph/embed/chunker.py +120 -0
- codegraph/embed/embedder.py +113 -0
- codegraph/embed/query.py +181 -0
- codegraph/embed/store.py +360 -0
- codegraph/graph/__init__.py +0 -0
- codegraph/graph/builder.py +212 -0
- codegraph/graph/schema.py +69 -0
- codegraph/graph/store_networkx.py +55 -0
- codegraph/graph/store_sqlite.py +249 -0
- codegraph/mcp_server/__init__.py +6 -0
- codegraph/mcp_server/server.py +933 -0
- codegraph/parsers/__init__.py +0 -0
- codegraph/parsers/base.py +70 -0
- codegraph/parsers/go.py +570 -0
- codegraph/parsers/python.py +1707 -0
- codegraph/parsers/typescript.py +1397 -0
- codegraph/py.typed +0 -0
- codegraph/resolve/__init__.py +4 -0
- codegraph/resolve/calls.py +480 -0
- codegraph/review/__init__.py +31 -0
- codegraph/review/baseline.py +32 -0
- codegraph/review/differ.py +211 -0
- codegraph/review/hook.py +70 -0
- codegraph/review/risk.py +219 -0
- codegraph/review/rules.py +342 -0
- codegraph/viz/__init__.py +17 -0
- codegraph/viz/_style.py +45 -0
- codegraph/viz/dashboard.py +740 -0
- codegraph/viz/diagrams.py +370 -0
- codegraph/viz/explore.py +453 -0
- codegraph/viz/hld.py +683 -0
- codegraph/viz/html.py +115 -0
- codegraph/viz/mermaid.py +111 -0
- codegraph/viz/svg.py +77 -0
- codegraph/web/__init__.py +4 -0
- codegraph/web/server.py +165 -0
- codegraph/web/static/app.css +664 -0
- codegraph/web/static/app.js +919 -0
- codegraph/web/static/index.html +112 -0
- codegraph/web/static/views/architecture.js +1671 -0
- codegraph/web/static/views/graph3d.css +564 -0
- codegraph/web/static/views/graph3d.js +999 -0
- codegraph/web/static/views/graph3d_transform.js +984 -0
- codegraph/workspace/__init__.py +34 -0
- codegraph/workspace/config.py +110 -0
- codegraph/workspace/operations.py +294 -0
- polycodegraph-0.1.0.dist-info/METADATA +687 -0
- polycodegraph-0.1.0.dist-info/RECORD +67 -0
- polycodegraph-0.1.0.dist-info/WHEEL +4 -0
- polycodegraph-0.1.0.dist-info/entry_points.txt +2 -0
- polycodegraph-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,999 @@
|
|
|
1
|
+
/* graph3d.js — 3D Graph view (focus-mode flow tracer) for the codegraph
|
|
2
|
+
* dashboard.
|
|
3
|
+
*
|
|
4
|
+
* Loads as a classic <script>. Reads global state, helpers (esc, showTip,
|
|
5
|
+
* hideTip, toast), and the ForceGraph3D global from the 3d-force-graph CDN.
|
|
6
|
+
* Exposes a single global function: window.renderGraph3d(host).
|
|
7
|
+
*
|
|
8
|
+
* Story (replaces the 0.1.0 "show all 326 nodes" cloud):
|
|
9
|
+
* 1. Default state shows a symbol picker, not a graph.
|
|
10
|
+
* 2. User picks a symbol. We render a small focused subgraph: the root,
|
|
11
|
+
* its ancestors (callers, amber), and its descendants (callees, cyan),
|
|
12
|
+
* out to N hops controlled by the depth slider.
|
|
13
|
+
* 3. Clicking any non-root node re-centers the focus on that node.
|
|
14
|
+
* 4. A breadcrumb of the last 3 focuses lets the user jump back.
|
|
15
|
+
* 5. ?demo=1 autoplays a 3-stop tour through hand-picked qualnames.
|
|
16
|
+
*
|
|
17
|
+
* SECURITY: every dynamic value passed into innerHTML goes through ESC()
|
|
18
|
+
* (window.esc fall back to escapeBasic). Static markup is authored inline,
|
|
19
|
+
* matching the convention used by renderOverview / renderHld in app.js.
|
|
20
|
+
*/
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
(function () {
|
|
24
|
+
// ---- Module-level state (one instance per dashboard session) ------------
|
|
25
|
+
var instance = null;
|
|
26
|
+
var demoCtl = null;
|
|
27
|
+
var resizeObs = null;
|
|
28
|
+
var currentHost = null;
|
|
29
|
+
var cameraFitDone = false; // one-shot zoomToFit per focus change
|
|
30
|
+
|
|
31
|
+
var focus = null; // { rootQn, depth, direction }
|
|
32
|
+
var focusState = null; // mutable graph state from makeFocusState
|
|
33
|
+
var history = [];
|
|
34
|
+
|
|
35
|
+
var DEFAULT_DEPTH = 2;
|
|
36
|
+
var DEFAULT_DIRECTION = 'both';
|
|
37
|
+
var DEFAULT_HIDE_TESTS = true; // most users want to see real-code call chains first
|
|
38
|
+
var MAX_HISTORY = 8;
|
|
39
|
+
var HIDE_TESTS_LS_KEY = 'cg.graph3d.hideTests';
|
|
40
|
+
|
|
41
|
+
// Whether to filter test_* symbols out of focused subgraphs. Persists
|
|
42
|
+
// to localStorage so the choice survives page reloads. Returns
|
|
43
|
+
// DEFAULT_HIDE_TESTS when localStorage is unavailable or the value
|
|
44
|
+
// hasn't been set yet.
|
|
45
|
+
function getHideTests() {
|
|
46
|
+
try {
|
|
47
|
+
var v = window.localStorage.getItem(HIDE_TESTS_LS_KEY);
|
|
48
|
+
if (v === '1') return true;
|
|
49
|
+
if (v === '0') return false;
|
|
50
|
+
} catch (e) { /* private mode etc. */ }
|
|
51
|
+
return DEFAULT_HIDE_TESTS;
|
|
52
|
+
}
|
|
53
|
+
function setHideTests(on) {
|
|
54
|
+
try {
|
|
55
|
+
window.localStorage.setItem(HIDE_TESTS_LS_KEY, on ? '1' : '0');
|
|
56
|
+
} catch (e) { /* ignore */ }
|
|
57
|
+
}
|
|
58
|
+
function focusOpts() {
|
|
59
|
+
return { hideTests: getHideTests() };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Hand-picked tour stops (top fan_in/fan_out symbols of the self-graph).
|
|
63
|
+
var DEMO_STOPS = [
|
|
64
|
+
'codegraph.viz.dashboard.build_dashboard_payload',
|
|
65
|
+
'codegraph.review.risk.score_change',
|
|
66
|
+
'codegraph.parsers.python.PythonExtractor._handle_class',
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
var CDN_URL = 'https://unpkg.com/3d-force-graph@1/dist/3d-force-graph.min.js';
|
|
70
|
+
|
|
71
|
+
// ---- Sprite label cache (Change 1) -------------------------------------
|
|
72
|
+
//
|
|
73
|
+
// Build a THREE.Sprite text label per node so labels stay visible at all
|
|
74
|
+
// camera distances. Labels are cached per node id to avoid GC churn during
|
|
75
|
+
// re-renders. Distance fade is handled implicitly via sprite scale: a
|
|
76
|
+
// smaller canvas projects a smaller label far from the camera, keeping
|
|
77
|
+
// foreground nodes readable without an explicit per-tick LOD pass.
|
|
78
|
+
var SPRITE_CACHE = new Map();
|
|
79
|
+
|
|
80
|
+
function makeLabelSprite(text) {
|
|
81
|
+
var SpriteText = (typeof window !== 'undefined') ? window.SpriteText : null;
|
|
82
|
+
if (typeof SpriteText !== 'function') return null;
|
|
83
|
+
var sprite = new SpriteText(String(text || ''));
|
|
84
|
+
sprite.color = '#f1f5ff';
|
|
85
|
+
sprite.backgroundColor = 'rgba(8,12,20,0.55)';
|
|
86
|
+
sprite.padding = 2;
|
|
87
|
+
sprite.borderRadius = 3;
|
|
88
|
+
sprite.fontFace = 'Inter, ui-sans-serif, system-ui, sans-serif';
|
|
89
|
+
sprite.fontWeight = 'bold';
|
|
90
|
+
sprite.textHeight = 6;
|
|
91
|
+
return sprite;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---- Edge arg label (Change 2 / DF0) ----------------------------------
|
|
95
|
+
//
|
|
96
|
+
// Render a small monospace sprite at the midpoint of CALLS edges that
|
|
97
|
+
// carry a non-empty argLabel (produced by the transform from the
|
|
98
|
+
// payload's parallel callee_args array).
|
|
99
|
+
function makeEdgeLabelSprite(text) {
|
|
100
|
+
var SpriteText = (typeof window !== 'undefined') ? window.SpriteText : null;
|
|
101
|
+
if (typeof SpriteText !== 'function') return null;
|
|
102
|
+
var sprite = new SpriteText(String(text || ''));
|
|
103
|
+
sprite.color = '#cbd5f5';
|
|
104
|
+
sprite.backgroundColor = 'rgba(8,12,20,0.6)';
|
|
105
|
+
sprite.padding = 1.5;
|
|
106
|
+
sprite.borderRadius = 2;
|
|
107
|
+
sprite.fontFace = 'JetBrains Mono, ui-monospace, SFMono-Regular, Menlo, monospace';
|
|
108
|
+
sprite.textHeight = 4;
|
|
109
|
+
return sprite;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
var EDGE_SPRITE_CACHE = new Map();
|
|
113
|
+
function getOrMakeEdgeLabelSprite(link) {
|
|
114
|
+
if (!link) return null;
|
|
115
|
+
var text = link.argLabel || '';
|
|
116
|
+
if (!text) return null;
|
|
117
|
+
var key = (link.source && link.source.id || link.source) + '->'
|
|
118
|
+
+ (link.target && link.target.id || link.target) + '|' + text;
|
|
119
|
+
var cached = EDGE_SPRITE_CACHE.get(key);
|
|
120
|
+
if (cached) return cached;
|
|
121
|
+
var sprite = makeEdgeLabelSprite(text);
|
|
122
|
+
if (sprite) EDGE_SPRITE_CACHE.set(key, sprite);
|
|
123
|
+
return sprite;
|
|
124
|
+
}
|
|
125
|
+
function clearEdgeSpriteCache() { EDGE_SPRITE_CACHE.clear(); }
|
|
126
|
+
|
|
127
|
+
function getOrMakeLabelSprite(node) {
|
|
128
|
+
if (!node) return null;
|
|
129
|
+
var cached = SPRITE_CACHE.get(node.id);
|
|
130
|
+
if (cached) return cached.sprite;
|
|
131
|
+
var sprite = makeLabelSprite(node.name || node.id);
|
|
132
|
+
if (!sprite) return null;
|
|
133
|
+
SPRITE_CACHE.set(node.id, { sprite: sprite });
|
|
134
|
+
return sprite;
|
|
135
|
+
}
|
|
136
|
+
function clearSpriteCache() { SPRITE_CACHE.clear(); }
|
|
137
|
+
|
|
138
|
+
function getTransform() {
|
|
139
|
+
var T = window.CG_Graph3DTransform;
|
|
140
|
+
if (!T) throw new Error('graph3d_transform.js not loaded');
|
|
141
|
+
return T;
|
|
142
|
+
}
|
|
143
|
+
function isLight() {
|
|
144
|
+
return document.documentElement.classList.contains('theme-light');
|
|
145
|
+
}
|
|
146
|
+
function bgColor() { return isLight() ? '#f4f6fb' : '#05070d'; }
|
|
147
|
+
function hasWebGL() {
|
|
148
|
+
try {
|
|
149
|
+
var c = document.createElement('canvas');
|
|
150
|
+
return !!(c.getContext('webgl') || c.getContext('experimental-webgl'));
|
|
151
|
+
} catch (e) { return false; }
|
|
152
|
+
}
|
|
153
|
+
function loadLibrary() {
|
|
154
|
+
if (typeof window.ForceGraph3D !== 'undefined') return Promise.resolve();
|
|
155
|
+
return new Promise(function (resolve, reject) {
|
|
156
|
+
var existing = document.querySelector('script[data-cg-3dfg]');
|
|
157
|
+
if (existing) {
|
|
158
|
+
existing.addEventListener('load', resolve);
|
|
159
|
+
existing.addEventListener('error', reject);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
var s = document.createElement('script');
|
|
163
|
+
s.src = CDN_URL;
|
|
164
|
+
s.async = true;
|
|
165
|
+
s.dataset.cg3dfg = '1';
|
|
166
|
+
s.onload = resolve;
|
|
167
|
+
s.onerror = function () { reject(new Error('CDN load failed')); };
|
|
168
|
+
document.head.appendChild(s);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
function escapeBasic(s) {
|
|
172
|
+
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
|
173
|
+
return ({ '&':'&','<':'<','>':'>','"':'"',"'":''' })[c];
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
var ESC = (typeof window !== 'undefined' && window.esc) || escapeBasic;
|
|
177
|
+
|
|
178
|
+
function destroyScene() {
|
|
179
|
+
if (demoCtl) { try { demoCtl.destroy(); } catch (e) {} demoCtl = null; }
|
|
180
|
+
if (instance) {
|
|
181
|
+
try { instance._destructor && instance._destructor(); } catch (e) {}
|
|
182
|
+
instance = null;
|
|
183
|
+
}
|
|
184
|
+
if (resizeObs) { try { resizeObs.disconnect(); } catch (e) {} resizeObs = null; }
|
|
185
|
+
cameraFitDone = false;
|
|
186
|
+
clearSpriteCache();
|
|
187
|
+
clearEdgeSpriteCache();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function fallbackHtml(msg) {
|
|
191
|
+
return [
|
|
192
|
+
'<div class="p-8 max-w-4xl mx-auto">',
|
|
193
|
+
'<div class="help-card mb-6">',
|
|
194
|
+
'<i data-lucide="alert-triangle" class="icon w-4 h-4"></i>',
|
|
195
|
+
'<div><b>3D Graph unavailable.</b> ', ESC(msg), '</div>',
|
|
196
|
+
'</div>',
|
|
197
|
+
'<div class="g3d-fallback panel p-6">',
|
|
198
|
+
'<p class="mb-4 text-app-2">Try the 2D HLD view instead.</p>',
|
|
199
|
+
'<button class="g3d-filter-btn active" id="g3d-fallback-hld">Open HLD</button>',
|
|
200
|
+
'</div></div>',
|
|
201
|
+
].join('');
|
|
202
|
+
}
|
|
203
|
+
function wireFallback(host) {
|
|
204
|
+
if (window.lucide) window.lucide.createIcons();
|
|
205
|
+
var btn = host.querySelector('#g3d-fallback-hld');
|
|
206
|
+
if (btn && typeof window.activate === 'function') {
|
|
207
|
+
btn.addEventListener('click', function () { window.activate('hld'); });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---- Picker shell ------------------------------------------------------
|
|
212
|
+
function pickerShellHtml() {
|
|
213
|
+
return [
|
|
214
|
+
'<div class="p-6 max-w-7xl mx-auto" id="g3d-shell">',
|
|
215
|
+
'<div class="help-card mb-4">',
|
|
216
|
+
'<i data-lucide="atom" class="icon w-4 h-4"></i>',
|
|
217
|
+
'<div><b>3D flow tracer.</b> Pick a symbol to trace its data flow — ',
|
|
218
|
+
'callers flow in, callees flow out, click any node to recenter.</div>',
|
|
219
|
+
'</div>',
|
|
220
|
+
'<div id="g3d-bar"></div>',
|
|
221
|
+
'<div id="g3d-breadcrumb"></div>',
|
|
222
|
+
'<div id="g3d-stage"></div>',
|
|
223
|
+
'</div>',
|
|
224
|
+
].join('');
|
|
225
|
+
}
|
|
226
|
+
function searchInputHtml(value) {
|
|
227
|
+
return [
|
|
228
|
+
'<div class="g3d-search">',
|
|
229
|
+
'<i data-lucide="search" class="icon w-4 h-4 g3d-search-icon"></i>',
|
|
230
|
+
'<input type="text" id="g3d-search-input" placeholder="Search symbols by name or qualname…" ',
|
|
231
|
+
'autocomplete="off" spellcheck="false" value="', ESC(value || ''), '" />',
|
|
232
|
+
'</div>',
|
|
233
|
+
].join('');
|
|
234
|
+
}
|
|
235
|
+
function pickerEmptyStageHtml() {
|
|
236
|
+
return [
|
|
237
|
+
'<div class="g3d-picker-stage">',
|
|
238
|
+
'<div class="g3d-picker-results" id="g3d-picker-results"></div>',
|
|
239
|
+
'<div class="g3d-picker-empty">',
|
|
240
|
+
'<i data-lucide="compass" class="icon w-8 h-8"></i>',
|
|
241
|
+
'<h3>Pick a symbol to trace its data flow.</h3>',
|
|
242
|
+
'<p>Search above. The graph renders only the chosen symbol’s neighborhood — ',
|
|
243
|
+
'no more 326-node cloud.</p>',
|
|
244
|
+
'</div>',
|
|
245
|
+
'</div>',
|
|
246
|
+
].join('');
|
|
247
|
+
}
|
|
248
|
+
function kindBadge(kind) {
|
|
249
|
+
var k = String(kind || '').toUpperCase();
|
|
250
|
+
return '<span class="g3d-kind-badge g3d-kind-' + ESC(k.toLowerCase()) + '">'
|
|
251
|
+
+ ESC(k || '?') + '</span>';
|
|
252
|
+
}
|
|
253
|
+
function pickerResultRowHtml(r) {
|
|
254
|
+
return [
|
|
255
|
+
'<button class="g3d-pick-row" data-qn="', ESC(r.qualname), '">',
|
|
256
|
+
kindBadge(r.kind),
|
|
257
|
+
'<span class="g3d-pick-name">', ESC(r.name || r.qualname), '</span>',
|
|
258
|
+
'<span class="g3d-pick-qn">', ESC(r.qualname), '</span>',
|
|
259
|
+
'<span class="g3d-pick-meta">in ', Number(r.fan_in) || 0,
|
|
260
|
+
' · out ', Number(r.fan_out) || 0, '</span>',
|
|
261
|
+
'</button>',
|
|
262
|
+
].join('');
|
|
263
|
+
}
|
|
264
|
+
function renderPickerResults(host, hld, query) {
|
|
265
|
+
var box = host.querySelector('#g3d-picker-results');
|
|
266
|
+
if (!box) return;
|
|
267
|
+
var T = getTransform();
|
|
268
|
+
var roleGroups = T.filterGroupedByRole(T.groupSymbolsByRole(hld), query);
|
|
269
|
+
var nonEmpty = roleGroups.filter(function (rg) { return rg.modules.length; });
|
|
270
|
+
if (!nonEmpty.length) {
|
|
271
|
+
box.innerHTML = '<div class="g3d-picker-noresults">No matches.</div>';
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
box.innerHTML = nonEmpty.map(pickerRoleBucketHtml).join('');
|
|
275
|
+
box.querySelectorAll('.g3d-pick-row').forEach(function (btn) {
|
|
276
|
+
btn.addEventListener('click', function () {
|
|
277
|
+
setFocus(host, hld, btn.dataset.qn);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function pickerRoleBucketHtml(rg) {
|
|
283
|
+
var modules = rg.modules.map(pickerGroupHtml).join('');
|
|
284
|
+
return [
|
|
285
|
+
'<div class="g3d-role-bucket">',
|
|
286
|
+
'<div class="g3d-role-bucket-hdr">',
|
|
287
|
+
'<span class="g3d-role-chip" style="background:', ESC(rg.color), ';"></span>',
|
|
288
|
+
'<span class="g3d-role-bucket-name">', ESC(rg.role), '</span>',
|
|
289
|
+
'<span class="g3d-role-bucket-count">', rg.modules.length, ' module',
|
|
290
|
+
(rg.modules.length === 1 ? '' : 's'), '</span>',
|
|
291
|
+
'</div>',
|
|
292
|
+
'<div class="g3d-role-bucket-body">',
|
|
293
|
+
modules,
|
|
294
|
+
'</div>',
|
|
295
|
+
'</div>',
|
|
296
|
+
].join('');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function pickerGroupHtml(g) {
|
|
300
|
+
var classBlocks = g.classes.map(pickerClassHtml).join('');
|
|
301
|
+
var fnBlocks = g.functions.map(function (f) {
|
|
302
|
+
return pickerLeafHtml(f, false);
|
|
303
|
+
}).join('');
|
|
304
|
+
return [
|
|
305
|
+
'<div class="g3d-grp">',
|
|
306
|
+
'<div class="g3d-grp-hdr">',
|
|
307
|
+
'<span class="g3d-grp-tag">MOD</span>',
|
|
308
|
+
'<span class="g3d-grp-name qn-mono">', ESC(g.qualname), '</span>',
|
|
309
|
+
(g.file ? '<span class="g3d-grp-file">' + ESC(g.file) + '</span>' : ''),
|
|
310
|
+
'</div>',
|
|
311
|
+
'<div class="g3d-grp-body">',
|
|
312
|
+
classBlocks,
|
|
313
|
+
fnBlocks,
|
|
314
|
+
'</div>',
|
|
315
|
+
'</div>',
|
|
316
|
+
].join('');
|
|
317
|
+
}
|
|
318
|
+
function pickerClassHtml(c) {
|
|
319
|
+
var methods = c.methods.map(function (m) { return pickerLeafHtml(m, true); }).join('');
|
|
320
|
+
return [
|
|
321
|
+
'<div class="g3d-grp-class">',
|
|
322
|
+
'<div class="g3d-grp-class-hdr">',
|
|
323
|
+
'<span class="g3d-kind-badge g3d-kind-class">C</span>',
|
|
324
|
+
'<span class="g3d-grp-class-name">', ESC(c.name), '</span>',
|
|
325
|
+
'</div>',
|
|
326
|
+
methods,
|
|
327
|
+
'</div>',
|
|
328
|
+
].join('');
|
|
329
|
+
}
|
|
330
|
+
function pickerLeafHtml(s, indent) {
|
|
331
|
+
var k = String(s.kind || '').toUpperCase();
|
|
332
|
+
var badge = k === 'METHOD' ? 'M' : (k === 'FUNCTION' ? 'FN' : (k.slice(0, 3) || '?'));
|
|
333
|
+
return [
|
|
334
|
+
'<button class="g3d-pick-row', (indent ? ' g3d-pick-indent' : ''),
|
|
335
|
+
'" data-qn="', ESC(s.qualname), '">',
|
|
336
|
+
'<span class="g3d-kind-badge g3d-kind-', ESC(k.toLowerCase()), '">',
|
|
337
|
+
ESC(badge), '</span>',
|
|
338
|
+
'<span class="g3d-pick-name">', ESC(s.name || s.qualname), '</span>',
|
|
339
|
+
'<span class="g3d-pick-meta">in ', Number(s.fan_in) || 0,
|
|
340
|
+
' · out ', Number(s.fan_out) || 0, '</span>',
|
|
341
|
+
'</button>',
|
|
342
|
+
].join('');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ---- Controls bar ------------------------------------------------------
|
|
346
|
+
function controlsBarHtml(focusState, nodeCount) {
|
|
347
|
+
var d = focusState ? focusState.depth : DEFAULT_DEPTH;
|
|
348
|
+
var dir = focusState ? focusState.direction : DEFAULT_DIRECTION;
|
|
349
|
+
var dirBtn = function (val, label) {
|
|
350
|
+
var on = (dir === val) ? ' active' : '';
|
|
351
|
+
return '<button class="g3d-filter-btn' + on + '" data-dir="' + ESC(val)
|
|
352
|
+
+ '">' + ESC(label) + '</button>';
|
|
353
|
+
};
|
|
354
|
+
return [
|
|
355
|
+
'<div class="g3d-controls">',
|
|
356
|
+
'<div class="g3d-controls-group g3d-controls-search">',
|
|
357
|
+
searchInputHtml(focusState ? focusState.rootQn : ''),
|
|
358
|
+
'</div>',
|
|
359
|
+
'<div class="g3d-controls-sep"></div>',
|
|
360
|
+
'<div class="g3d-controls-group">',
|
|
361
|
+
'<span class="g3d-controls-lbl">Depth</span>',
|
|
362
|
+
'<input type="range" id="g3d-depth" min="1" max="4" step="1" value="', d, '" />',
|
|
363
|
+
'<span class="g3d-depth-val" id="g3d-depth-val">', d, '</span>',
|
|
364
|
+
'</div>',
|
|
365
|
+
'<div class="g3d-controls-sep"></div>',
|
|
366
|
+
'<div class="g3d-controls-group">',
|
|
367
|
+
dirBtn('ancestors', 'Ancestors'),
|
|
368
|
+
dirBtn('both', 'Both'),
|
|
369
|
+
dirBtn('descendants', 'Descendants'),
|
|
370
|
+
'</div>',
|
|
371
|
+
'<div class="g3d-controls-sep"></div>',
|
|
372
|
+
'<div class="g3d-controls-group">',
|
|
373
|
+
'<button class="g3d-filter-btn', getHideTests() ? ' active' : '',
|
|
374
|
+
'" id="g3d-hide-tests-btn"',
|
|
375
|
+
' title="Hide test_* / *.test.* / tests/ files from the graph"',
|
|
376
|
+
' aria-pressed="', getHideTests() ? 'true' : 'false', '">',
|
|
377
|
+
'Hide tests',
|
|
378
|
+
'</button>',
|
|
379
|
+
'</div>',
|
|
380
|
+
'<div class="g3d-controls-sep"></div>',
|
|
381
|
+
'<div class="g3d-controls-group">',
|
|
382
|
+
'<button class="g3d-filter-btn" id="g3d-demo-btn" title="Autoplay tour">Demo</button>',
|
|
383
|
+
'<button class="g3d-filter-btn" id="g3d-reset-btn" title="Reset to picker">Reset to picker</button>',
|
|
384
|
+
'<span class="g3d-controls-lbl ml-auto g3d-node-count">',
|
|
385
|
+
Number(nodeCount) || 0, ' nodes</span>',
|
|
386
|
+
'</div>',
|
|
387
|
+
'</div>',
|
|
388
|
+
'<div id="g3d-search-popover" class="g3d-search-popover" hidden></div>',
|
|
389
|
+
].join('');
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function breadcrumbHtml() {
|
|
393
|
+
if (!history.length) return '';
|
|
394
|
+
var recent = history.slice(-3);
|
|
395
|
+
var crumbs = recent.map(function (qn) {
|
|
396
|
+
var isCurrent = (focus && qn === focus.rootQn);
|
|
397
|
+
return '<button class="g3d-crumb' + (isCurrent ? ' is-current' : '')
|
|
398
|
+
+ '" data-qn="' + ESC(qn) + '">' + ESC(qn) + '</button>';
|
|
399
|
+
});
|
|
400
|
+
return [
|
|
401
|
+
'<div class="g3d-breadcrumb">',
|
|
402
|
+
'<span class="g3d-breadcrumb-lbl">Trail</span>',
|
|
403
|
+
crumbs.join('<span class="g3d-breadcrumb-sep">›</span>'),
|
|
404
|
+
'</div>',
|
|
405
|
+
].join('');
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function pushHistory(qn) {
|
|
409
|
+
if (!qn) return;
|
|
410
|
+
if (history.length && history[history.length - 1] === qn) return;
|
|
411
|
+
history.push(qn);
|
|
412
|
+
while (history.length > MAX_HISTORY) history.shift();
|
|
413
|
+
}
|
|
414
|
+
function setFocus(host, hld, qn) {
|
|
415
|
+
if (!qn) return;
|
|
416
|
+
focus = {
|
|
417
|
+
rootQn: qn,
|
|
418
|
+
depth: focus ? focus.depth : DEFAULT_DEPTH,
|
|
419
|
+
direction: focus ? focus.direction : DEFAULT_DIRECTION,
|
|
420
|
+
};
|
|
421
|
+
focusState = getTransform().makeFocusState(
|
|
422
|
+
hld, qn, focus.depth, focus.direction, focusOpts()
|
|
423
|
+
);
|
|
424
|
+
pushHistory(qn);
|
|
425
|
+
renderFocusedView(host, hld);
|
|
426
|
+
}
|
|
427
|
+
function clearFocus(host, hld) {
|
|
428
|
+
focus = null;
|
|
429
|
+
focusState = null;
|
|
430
|
+
history = [];
|
|
431
|
+
renderPickerView(host, hld);
|
|
432
|
+
}
|
|
433
|
+
function toggleExpand(host, hld, qn) {
|
|
434
|
+
if (!focusState || !qn) return;
|
|
435
|
+
if (qn === focus.rootQn) return;
|
|
436
|
+
var T = getTransform();
|
|
437
|
+
if (T.isExpanded(focusState, qn)) {
|
|
438
|
+
T.collapseNode(focusState, qn);
|
|
439
|
+
} else {
|
|
440
|
+
T.expandNode(focusState, hld, qn, focusOpts());
|
|
441
|
+
}
|
|
442
|
+
refreshGraphData(host);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Per-node detail panel.
|
|
446
|
+
//
|
|
447
|
+
// Intentionally does NOT show fan_in / fan_out (Item 5): those are
|
|
448
|
+
// graph-theory metrics that fit the Hotspots view, not the data-flow
|
|
449
|
+
// story this 3D view tells. Detail shows: name, qualname, kind, file,
|
|
450
|
+
// role, layer (if present), plus action buttons (Expand / Set as root)
|
|
451
|
+
// for non-external internal nodes.
|
|
452
|
+
function detailHtml(node) {
|
|
453
|
+
var roleLabel = ({
|
|
454
|
+
root: 'root', ancestor: 'caller', descendant: 'callee', external: 'external',
|
|
455
|
+
})[node.role] || node.role || '';
|
|
456
|
+
var expanded = focusState && getTransform().isExpanded(focusState, node.id);
|
|
457
|
+
var actions = '';
|
|
458
|
+
if (!node.external && node.role !== 'root') {
|
|
459
|
+
actions = [
|
|
460
|
+
'<div class="g3d-detail-actions mt-3">',
|
|
461
|
+
'<button class="g3d-filter-btn" data-action="toggle-expand" data-qn="', ESC(node.id), '">',
|
|
462
|
+
expanded ? 'Collapse neighbors' : 'Expand neighbors',
|
|
463
|
+
'</button>',
|
|
464
|
+
'<button class="g3d-filter-btn" data-action="set-root" data-qn="', ESC(node.id), '">',
|
|
465
|
+
'Set as root',
|
|
466
|
+
'</button>',
|
|
467
|
+
'</div>',
|
|
468
|
+
].join('');
|
|
469
|
+
}
|
|
470
|
+
var T = getTransform();
|
|
471
|
+
var signature = T.formatSignature ? T.formatSignature(node) : '';
|
|
472
|
+
var sigBlock = '';
|
|
473
|
+
if (signature) {
|
|
474
|
+
sigBlock = [
|
|
475
|
+
'<div class="g3d-detail-sig-lbl">Signature</div>',
|
|
476
|
+
'<div class="g3d-detail-sig">', ESC(signature), '</div>',
|
|
477
|
+
].join('');
|
|
478
|
+
}
|
|
479
|
+
var roleChip = '';
|
|
480
|
+
if (node.symbolRole) {
|
|
481
|
+
var color = (T.ROLE_PICKER_COLORS && T.ROLE_PICKER_COLORS[node.symbolRole]) || '#8b9ab8';
|
|
482
|
+
roleChip = [
|
|
483
|
+
'<span class="g3d-detail-rolechip" style="background:', ESC(color), ';"></span>',
|
|
484
|
+
'<span class="g3d-detail-role-name">', ESC(node.symbolRole), '</span>',
|
|
485
|
+
].join('');
|
|
486
|
+
}
|
|
487
|
+
return [
|
|
488
|
+
'<div class="g3d-detail panel p-5">',
|
|
489
|
+
'<div class="text-[11px] uppercase tracking-[0.14em] text-app-3 mb-1">',
|
|
490
|
+
ESC(node.kind), ' · ', ESC(roleLabel),
|
|
491
|
+
(node.file ? (' · ' + ESC(node.file)) : ''),
|
|
492
|
+
(node.layer ? (' · layer ' + ESC(node.layer)) : ''),
|
|
493
|
+
'</div>',
|
|
494
|
+
'<div class="text-base font-semibold qn-mono mb-1"><span class="g3d-detail-name">',
|
|
495
|
+
ESC(node.name || node.id), '</span>',
|
|
496
|
+
(roleChip ? ' <span class="g3d-detail-role">' + roleChip + '</span>' : ''),
|
|
497
|
+
'</div>',
|
|
498
|
+
'<div class="text-xs text-app-3 qn-mono mb-2">', ESC(node.id), '</div>',
|
|
499
|
+
sigBlock,
|
|
500
|
+
(node.external
|
|
501
|
+
? '<div class="g3d-detail-hint mt-1">External symbol — terminal leaf.</div>'
|
|
502
|
+
: ''),
|
|
503
|
+
actions,
|
|
504
|
+
'</div>',
|
|
505
|
+
].join('');
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function renderDetail(el, node) {
|
|
509
|
+
if (!el || !node) return;
|
|
510
|
+
el.innerHTML = detailHtml(node);
|
|
511
|
+
el.querySelectorAll('[data-action]').forEach(function (btn) {
|
|
512
|
+
btn.addEventListener('click', function () {
|
|
513
|
+
var action = btn.dataset.action;
|
|
514
|
+
var qn = btn.dataset.qn;
|
|
515
|
+
if (action === 'toggle-expand') {
|
|
516
|
+
toggleExpand(currentHost, currentHldRef(), qn);
|
|
517
|
+
// Re-render detail to flip Expand/Collapse label.
|
|
518
|
+
var fresh = focusState && focusState.nodes && focusState.nodes.get(qn);
|
|
519
|
+
if (fresh) renderDetail(el, fresh);
|
|
520
|
+
} else if (action === 'set-root') {
|
|
521
|
+
setFocus(currentHost, currentHldRef(), qn);
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function currentHldRef() {
|
|
528
|
+
return (window.state && window.state.data && window.state.data.hld) || { modules: {} };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function renderShell(host) {
|
|
532
|
+
host.innerHTML = pickerShellHtml();
|
|
533
|
+
if (window.lucide) window.lucide.createIcons();
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function renderPickerView(host, hld) {
|
|
537
|
+
destroyScene();
|
|
538
|
+
renderShell(host);
|
|
539
|
+
var bar = host.querySelector('#g3d-bar');
|
|
540
|
+
bar.innerHTML = [
|
|
541
|
+
'<div class="g3d-controls">',
|
|
542
|
+
'<div class="g3d-controls-group g3d-controls-search g3d-controls-search-large">',
|
|
543
|
+
searchInputHtml(''),
|
|
544
|
+
'</div>',
|
|
545
|
+
'<div class="g3d-controls-group">',
|
|
546
|
+
'<button class="g3d-filter-btn" id="g3d-demo-btn">Demo tour</button>',
|
|
547
|
+
'</div>',
|
|
548
|
+
'</div>',
|
|
549
|
+
].join('');
|
|
550
|
+
host.querySelector('#g3d-stage').innerHTML = pickerEmptyStageHtml();
|
|
551
|
+
if (window.lucide) window.lucide.createIcons();
|
|
552
|
+
wirePickerInputs(host, hld);
|
|
553
|
+
renderPickerResults(host, hld, '');
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function wirePickerInputs(host, hld) {
|
|
557
|
+
var input = host.querySelector('#g3d-search-input');
|
|
558
|
+
if (input) {
|
|
559
|
+
input.addEventListener('input', function () {
|
|
560
|
+
renderPickerResults(host, hld, input.value);
|
|
561
|
+
});
|
|
562
|
+
input.addEventListener('keydown', function (e) {
|
|
563
|
+
if (e.key === 'Enter') {
|
|
564
|
+
var first = host.querySelector('.g3d-pick-row');
|
|
565
|
+
if (first) first.click();
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
setTimeout(function () { try { input.focus(); } catch (e) {} }, 30);
|
|
569
|
+
}
|
|
570
|
+
var demoBtn = host.querySelector('#g3d-demo-btn');
|
|
571
|
+
if (demoBtn) demoBtn.addEventListener('click', function () { startDemoTour(host, hld); });
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// ---- Color & kind legend (Item 3) -------------------------------------
|
|
575
|
+
var LEGEND_KEY = 'cg-3d-legend-collapsed';
|
|
576
|
+
function legendCollapsed() {
|
|
577
|
+
// Change 5: legend is expanded by default. Only collapsed when the
|
|
578
|
+
// user has explicitly stored '1'. Absence of the key => expanded.
|
|
579
|
+
try {
|
|
580
|
+
if (!window.localStorage) return false;
|
|
581
|
+
return window.localStorage.getItem(LEGEND_KEY) === '1';
|
|
582
|
+
} catch (e) { return false; }
|
|
583
|
+
}
|
|
584
|
+
function setLegendCollapsed(v) {
|
|
585
|
+
try { window.localStorage && window.localStorage.setItem(LEGEND_KEY, v ? '1' : '0'); }
|
|
586
|
+
catch (e) { /* ignore quota / SecurityError */ }
|
|
587
|
+
}
|
|
588
|
+
function legendHtml() {
|
|
589
|
+
var collapsed = legendCollapsed();
|
|
590
|
+
return [
|
|
591
|
+
'<div class="g3d-legend', (collapsed ? ' is-collapsed' : ''), '" id="g3d-legend">',
|
|
592
|
+
'<button class="g3d-legend-toggle" id="g3d-legend-toggle" ',
|
|
593
|
+
'title="', (collapsed ? 'Show legend' : 'Hide legend'), '" ',
|
|
594
|
+
'aria-label="Toggle legend">',
|
|
595
|
+
(collapsed ? '?' : '×'),
|
|
596
|
+
'</button>',
|
|
597
|
+
'<div class="g3d-legend-body">',
|
|
598
|
+
'<div class="g3d-legend-section">',
|
|
599
|
+
'<div class="g3d-legend-title">Flow</div>',
|
|
600
|
+
legendDot('#fbbf24', 'Ancestor (caller)'),
|
|
601
|
+
legendDot('#22d3ee', 'Descendant (callee)'),
|
|
602
|
+
legendDot('#a78bfa', 'Current focus'),
|
|
603
|
+
legendDotOutline('External / third-party'),
|
|
604
|
+
'</div>',
|
|
605
|
+
'<div class="g3d-legend-section">',
|
|
606
|
+
'<div class="g3d-legend-title">Role</div>',
|
|
607
|
+
legendDot('#fbbf24', 'HANDLER'),
|
|
608
|
+
legendDot('#3b82f6', 'SERVICE'),
|
|
609
|
+
legendDot('#34d399', 'COMPONENT'),
|
|
610
|
+
legendDot('#c084fc', 'REPO'),
|
|
611
|
+
legendDot('#8b9ab8', 'no role'),
|
|
612
|
+
'</div>',
|
|
613
|
+
'<div class="g3d-legend-section">',
|
|
614
|
+
'<div class="g3d-legend-title">Kinds</div>',
|
|
615
|
+
'<div class="g3d-legend-kinds">',
|
|
616
|
+
'<span class="g3d-kind-badge g3d-kind-function">FN</span>',
|
|
617
|
+
'<span class="g3d-kind-badge g3d-kind-method">M</span>',
|
|
618
|
+
'<span class="g3d-kind-badge g3d-kind-class">C</span>',
|
|
619
|
+
'<span class="g3d-kind-badge g3d-kind-module">MOD</span>',
|
|
620
|
+
'</div>',
|
|
621
|
+
'</div>',
|
|
622
|
+
'</div>',
|
|
623
|
+
'</div>',
|
|
624
|
+
].join('');
|
|
625
|
+
}
|
|
626
|
+
function legendDot(color, label) {
|
|
627
|
+
return [
|
|
628
|
+
'<div class="g3d-legend-row">',
|
|
629
|
+
'<span class="g3d-legend-dot" style="background:', ESC(color), ';"></span>',
|
|
630
|
+
'<span class="g3d-legend-lbl">', ESC(label), '</span>',
|
|
631
|
+
'</div>',
|
|
632
|
+
].join('');
|
|
633
|
+
}
|
|
634
|
+
function legendDotOutline(label) {
|
|
635
|
+
return [
|
|
636
|
+
'<div class="g3d-legend-row">',
|
|
637
|
+
'<span class="g3d-legend-dot g3d-legend-dot-outline"></span>',
|
|
638
|
+
'<span class="g3d-legend-lbl">', ESC(label), '</span>',
|
|
639
|
+
'</div>',
|
|
640
|
+
].join('');
|
|
641
|
+
}
|
|
642
|
+
function wireLegend(host) {
|
|
643
|
+
var btn = host.querySelector('#g3d-legend-toggle');
|
|
644
|
+
var legend = host.querySelector('#g3d-legend');
|
|
645
|
+
if (!btn || !legend) return;
|
|
646
|
+
btn.addEventListener('click', function () {
|
|
647
|
+
var nowCollapsed = !legend.classList.contains('is-collapsed');
|
|
648
|
+
legend.classList.toggle('is-collapsed', nowCollapsed);
|
|
649
|
+
btn.textContent = nowCollapsed ? '?' : '×';
|
|
650
|
+
btn.title = nowCollapsed ? 'Show legend' : 'Hide legend';
|
|
651
|
+
setLegendCollapsed(nowCollapsed);
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function refreshGraphData(host) {
|
|
656
|
+
if (!instance || !focusState) return;
|
|
657
|
+
var snap = getTransform().snapshotState(focusState);
|
|
658
|
+
instance.graphData(snap);
|
|
659
|
+
var bar = host && host.querySelector && host.querySelector('#g3d-bar');
|
|
660
|
+
if (bar) {
|
|
661
|
+
var countEl = bar.querySelector('.g3d-node-count');
|
|
662
|
+
if (countEl) countEl.textContent = snap.nodes.length + ' nodes';
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function renderFocusedView(host, hld) {
|
|
667
|
+
destroyScene();
|
|
668
|
+
renderShell(host);
|
|
669
|
+
|
|
670
|
+
var T = getTransform();
|
|
671
|
+
if (!focusState) {
|
|
672
|
+
focusState = T.makeFocusState(
|
|
673
|
+
hld, focus.rootQn, focus.depth, focus.direction, focusOpts()
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
var data = T.snapshotState(focusState);
|
|
677
|
+
|
|
678
|
+
host.querySelector('#g3d-bar').innerHTML = controlsBarHtml(focus, data.nodes.length);
|
|
679
|
+
host.querySelector('#g3d-breadcrumb').innerHTML = breadcrumbHtml();
|
|
680
|
+
|
|
681
|
+
var stage = host.querySelector('#g3d-stage');
|
|
682
|
+
stage.innerHTML = [
|
|
683
|
+
'<div class="g3d-canvas-wrap" id="g3d-canvas">',
|
|
684
|
+
legendHtml(),
|
|
685
|
+
'</div>',
|
|
686
|
+
'<div id="g3d-detail" class="mt-4"></div>',
|
|
687
|
+
].join('');
|
|
688
|
+
wireLegend(host);
|
|
689
|
+
|
|
690
|
+
if (window.lucide) window.lucide.createIcons();
|
|
691
|
+
wireFocusedInputs(host, hld);
|
|
692
|
+
|
|
693
|
+
if (data.nodes.length === 0) {
|
|
694
|
+
var canvas = host.querySelector('#g3d-canvas');
|
|
695
|
+
canvas.textContent = '';
|
|
696
|
+
var msg = document.createElement('div');
|
|
697
|
+
msg.className = 'g3d-empty';
|
|
698
|
+
msg.textContent = 'No nodes — try a different direction or a larger depth.';
|
|
699
|
+
canvas.appendChild(msg);
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
loadLibrary().then(function () {
|
|
704
|
+
bootScene(host, hld, data);
|
|
705
|
+
}).catch(function () {
|
|
706
|
+
host.innerHTML = fallbackHtml('3D Graph library failed to load.');
|
|
707
|
+
wireFallback(host);
|
|
708
|
+
if (window.toast) window.toast('3D graph library unavailable.', 'error');
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function wireFocusedInputs(host, hld) {
|
|
713
|
+
var input = host.querySelector('#g3d-search-input');
|
|
714
|
+
if (input) {
|
|
715
|
+
input.addEventListener('focus', function () { showSearchPopover(host, hld, input.value); });
|
|
716
|
+
input.addEventListener('input', function () { showSearchPopover(host, hld, input.value); });
|
|
717
|
+
input.addEventListener('blur', function () {
|
|
718
|
+
setTimeout(function () { hideSearchPopover(host); }, 150);
|
|
719
|
+
});
|
|
720
|
+
input.addEventListener('keydown', function (e) {
|
|
721
|
+
if (e.key === 'Enter') {
|
|
722
|
+
var first = host.querySelector('.g3d-search-popover .g3d-pick-row');
|
|
723
|
+
if (first) first.click();
|
|
724
|
+
} else if (e.key === 'Escape') {
|
|
725
|
+
hideSearchPopover(host);
|
|
726
|
+
input.blur();
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
var depthInput = host.querySelector('#g3d-depth');
|
|
731
|
+
if (depthInput) {
|
|
732
|
+
depthInput.addEventListener('input', function () {
|
|
733
|
+
var v = Number(depthInput.value) || DEFAULT_DEPTH;
|
|
734
|
+
host.querySelector('#g3d-depth-val').textContent = String(v);
|
|
735
|
+
focus.depth = v;
|
|
736
|
+
// Depth controls initial fold-out only — rebuild state from scratch.
|
|
737
|
+
focusState = getTransform().makeFocusState(hld, focus.rootQn, focus.depth, focus.direction);
|
|
738
|
+
renderFocusedView(host, hld);
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
host.querySelectorAll('.g3d-filter-btn[data-dir]').forEach(function (btn) {
|
|
742
|
+
btn.addEventListener('click', function () {
|
|
743
|
+
focus.direction = btn.dataset.dir;
|
|
744
|
+
focusState = getTransform().makeFocusState(hld, focus.rootQn, focus.depth, focus.direction);
|
|
745
|
+
renderFocusedView(host, hld);
|
|
746
|
+
});
|
|
747
|
+
});
|
|
748
|
+
var resetBtn = host.querySelector('#g3d-reset-btn');
|
|
749
|
+
if (resetBtn) resetBtn.addEventListener('click', function () { clearFocus(host, hld); });
|
|
750
|
+
var demoBtn = host.querySelector('#g3d-demo-btn');
|
|
751
|
+
if (demoBtn) demoBtn.addEventListener('click', function () { startDemoTour(host, hld); });
|
|
752
|
+
var hideBtn = host.querySelector('#g3d-hide-tests-btn');
|
|
753
|
+
if (hideBtn) hideBtn.addEventListener('click', function () {
|
|
754
|
+
// Flip the persisted preference, rebuild the focus subgraph from
|
|
755
|
+
// scratch (cheaper than diffing) so dropped/added nodes settle
|
|
756
|
+
// through the normal force layout instead of popping in mid-flight.
|
|
757
|
+
setHideTests(!getHideTests());
|
|
758
|
+
if (focus && focus.rootQn) {
|
|
759
|
+
focusState = getTransform().makeFocusState(
|
|
760
|
+
hld, focus.rootQn, focus.depth, focus.direction, focusOpts()
|
|
761
|
+
);
|
|
762
|
+
renderFocusedView(host, hld);
|
|
763
|
+
} else {
|
|
764
|
+
renderPickerView(host, hld);
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
host.querySelectorAll('.g3d-crumb').forEach(function (btn) {
|
|
768
|
+
btn.addEventListener('click', function () {
|
|
769
|
+
var qn = btn.dataset.qn;
|
|
770
|
+
if (qn && focus && qn !== focus.rootQn) {
|
|
771
|
+
focus.rootQn = qn;
|
|
772
|
+
pushHistory(qn);
|
|
773
|
+
renderFocusedView(host, hld);
|
|
774
|
+
}
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function showSearchPopover(host, hld, query) {
|
|
780
|
+
var pop = host.querySelector('#g3d-search-popover');
|
|
781
|
+
if (!pop) return;
|
|
782
|
+
var hits = getTransform().searchSymbols(hld, query, 20);
|
|
783
|
+
if (!hits.length) {
|
|
784
|
+
pop.innerHTML = '<div class="g3d-picker-noresults">No matches.</div>';
|
|
785
|
+
} else {
|
|
786
|
+
pop.innerHTML = hits.map(pickerResultRowHtml).join('');
|
|
787
|
+
pop.querySelectorAll('.g3d-pick-row').forEach(function (btn) {
|
|
788
|
+
btn.addEventListener('mousedown', function (e) {
|
|
789
|
+
e.preventDefault();
|
|
790
|
+
setFocus(host, hld, btn.dataset.qn);
|
|
791
|
+
});
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
pop.hidden = false;
|
|
795
|
+
}
|
|
796
|
+
function hideSearchPopover(host) {
|
|
797
|
+
var pop = host.querySelector('#g3d-search-popover');
|
|
798
|
+
if (pop) pop.hidden = true;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function injectLegendOverlay(host) {
|
|
802
|
+
// ForceGraph3D() overwrites the container's contents on mount, so we
|
|
803
|
+
// attach the legend AFTER mount as a positioned sibling overlay inside
|
|
804
|
+
// the same wrap. Idempotent — replaces a stale legend if present.
|
|
805
|
+
var stage = host.querySelector('#g3d-stage');
|
|
806
|
+
if (!stage) return;
|
|
807
|
+
var prior = host.querySelector('#g3d-legend');
|
|
808
|
+
if (prior && prior.parentNode) prior.parentNode.removeChild(prior);
|
|
809
|
+
var temp = document.createElement('div');
|
|
810
|
+
temp.innerHTML = legendHtml(); // trusted, all-internal
|
|
811
|
+
var legend = temp.firstChild;
|
|
812
|
+
if (!legend) return;
|
|
813
|
+
var canvasWrap = host.querySelector('#g3d-canvas');
|
|
814
|
+
var parent = canvasWrap ? canvasWrap.parentElement : stage;
|
|
815
|
+
parent.style.position = parent.style.position || 'relative';
|
|
816
|
+
parent.appendChild(legend);
|
|
817
|
+
wireLegend(host);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function bootScene(host, hld, data) {
|
|
821
|
+
var container = host.querySelector('#g3d-canvas');
|
|
822
|
+
if (!container || typeof window.ForceGraph3D === 'undefined') return;
|
|
823
|
+
var detailEl = host.querySelector('#g3d-detail');
|
|
824
|
+
|
|
825
|
+
try {
|
|
826
|
+
instance = window.ForceGraph3D()(container)
|
|
827
|
+
.backgroundColor(bgColor())
|
|
828
|
+
.nodeRelSize(4)
|
|
829
|
+
.nodeColor(function (n) { return n.color; })
|
|
830
|
+
.nodeVal(function (n) { return n.val; })
|
|
831
|
+
.nodeLabel(function (n) {
|
|
832
|
+
// Library-native HTML hover label (no THREE required).
|
|
833
|
+
// Always renders: name (large), kind+role (small), signature if present.
|
|
834
|
+
if (n.external) {
|
|
835
|
+
return '<div class="g3d-tip"><b>' + escapeBasic(n.name) + '</b>'
|
|
836
|
+
+ '<div class="g3d-tip-meta">' + escapeBasic(n.qualname)
|
|
837
|
+
+ ' · <i>external</i></div></div>';
|
|
838
|
+
}
|
|
839
|
+
var T = (typeof window !== 'undefined' && window.CG_Graph3DTransform) || null;
|
|
840
|
+
var sig = (T && typeof T.formatSignature === 'function')
|
|
841
|
+
? T.formatSignature(n) : '';
|
|
842
|
+
var sigHtml = sig
|
|
843
|
+
? '<div class="g3d-tip-sig">' + escapeBasic(sig) + '</div>' : '';
|
|
844
|
+
var roleHtml = n.symbolRole
|
|
845
|
+
? ' · <span class="g3d-tip-role">' + escapeBasic(n.symbolRole) + '</span>'
|
|
846
|
+
: '';
|
|
847
|
+
return '<div class="g3d-tip"><b>' + escapeBasic(n.name) + '</b>'
|
|
848
|
+
+ '<div class="g3d-tip-meta">' + escapeBasic(n.kind) + roleHtml + '</div>'
|
|
849
|
+
+ sigHtml + '</div>';
|
|
850
|
+
})
|
|
851
|
+
.linkColor(function (l) { return l.color; })
|
|
852
|
+
.linkOpacity(0.6)
|
|
853
|
+
.linkDirectionalArrowLength(4)
|
|
854
|
+
.linkDirectionalArrowRelPos(0.92)
|
|
855
|
+
.linkDirectionalParticles(2)
|
|
856
|
+
.linkDirectionalParticleSpeed(0.006)
|
|
857
|
+
.linkWidth(1.2)
|
|
858
|
+
.linkLabel(function (l) {
|
|
859
|
+
if (!l.argLabel) return '';
|
|
860
|
+
return '<div class="g3d-tip g3d-tip-edge"><b>args</b>: '
|
|
861
|
+
+ escapeBasic(l.argLabel) + '</div>';
|
|
862
|
+
})
|
|
863
|
+
.nodeThreeObjectExtend(true)
|
|
864
|
+
.nodeThreeObject(function (n) { return getOrMakeLabelSprite(n); })
|
|
865
|
+
.linkThreeObjectExtend(true)
|
|
866
|
+
.linkThreeObject(function (l) { return getOrMakeEdgeLabelSprite(l); })
|
|
867
|
+
.linkPositionUpdate(function (sprite, pos) {
|
|
868
|
+
if (!sprite) return false;
|
|
869
|
+
var s = pos.start, e = pos.end;
|
|
870
|
+
if (!s || !e) return false;
|
|
871
|
+
sprite.position.set(
|
|
872
|
+
(s.x + e.x) / 2,
|
|
873
|
+
(s.y + e.y) / 2,
|
|
874
|
+
(s.z + e.z) / 2,
|
|
875
|
+
);
|
|
876
|
+
return true;
|
|
877
|
+
})
|
|
878
|
+
.onEngineStop(function () {
|
|
879
|
+
// Fired when the force simulation cools below alphaMin. Auto-fit
|
|
880
|
+
// the camera once per focus change so the cluster sits centered
|
|
881
|
+
// in the canvas instead of drifting bottom-right when callers
|
|
882
|
+
// outweigh callees (or vice-versa). One-shot guard: an expansion
|
|
883
|
+
// re-runs the simulation; we don't want to snap the camera on
|
|
884
|
+
// every minor adjustment after the user has settled in.
|
|
885
|
+
if (!instance || cameraFitDone) return;
|
|
886
|
+
cameraFitDone = true;
|
|
887
|
+
try { instance.zoomToFit(800, 60); } catch (e) { /* ignore */ }
|
|
888
|
+
})
|
|
889
|
+
.onNodeHover(function (node) {
|
|
890
|
+
container.style.cursor = node ? 'pointer' : 'grab';
|
|
891
|
+
})
|
|
892
|
+
.onNodeClick(function (node, evt) {
|
|
893
|
+
if (!node) return;
|
|
894
|
+
renderDetail(detailEl, node);
|
|
895
|
+
// External (stdlib / third-party) leaves are terminal — show
|
|
896
|
+
// detail but never recenter / expand on them.
|
|
897
|
+
if (node.external) return;
|
|
898
|
+
if (node.role === 'root') {
|
|
899
|
+
// Camera nudge only.
|
|
900
|
+
var dist = 100;
|
|
901
|
+
var len = Math.hypot(node.x || 1, node.y || 1, node.z || 1) || 1;
|
|
902
|
+
instance.cameraPosition(
|
|
903
|
+
{ x: node.x * (1 + dist / len),
|
|
904
|
+
y: node.y * (1 + dist / len),
|
|
905
|
+
z: node.z * (1 + dist / len) },
|
|
906
|
+
node, 800
|
|
907
|
+
);
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
// Shift-click pivots: drop current state, set this node as root.
|
|
911
|
+
if (evt && (evt.shiftKey || evt.metaKey)) {
|
|
912
|
+
setFocus(host, hld, node.id);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
// Default: expand 1-hop neighbors inline (or collapse if already
|
|
916
|
+
// expanded). Root and externals are excluded above.
|
|
917
|
+
toggleExpand(host, hld, node.id);
|
|
918
|
+
})
|
|
919
|
+
.graphData(data);
|
|
920
|
+
|
|
921
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
922
|
+
resizeObs = new ResizeObserver(function () {
|
|
923
|
+
if (instance && container.clientWidth) {
|
|
924
|
+
instance.width(container.clientWidth).height(container.clientHeight);
|
|
925
|
+
}
|
|
926
|
+
});
|
|
927
|
+
resizeObs.observe(container);
|
|
928
|
+
}
|
|
929
|
+
injectLegendOverlay(host);
|
|
930
|
+
} catch (e) {
|
|
931
|
+
host.innerHTML = fallbackHtml('WebGL initialization failed: ' + (e && e.message || e));
|
|
932
|
+
wireFallback(host);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function pickAvailableDemoStops(hld) {
|
|
937
|
+
var T = getTransform();
|
|
938
|
+
var index = {};
|
|
939
|
+
var modules = (hld && hld.modules) || {};
|
|
940
|
+
Object.keys(modules).forEach(function (mqn) {
|
|
941
|
+
((modules[mqn] || {}).symbols || []).forEach(function (s) {
|
|
942
|
+
if (s && s.qualname) index[s.qualname] = true;
|
|
943
|
+
});
|
|
944
|
+
});
|
|
945
|
+
var stops = DEMO_STOPS.filter(function (qn) { return index[qn]; });
|
|
946
|
+
if (stops.length >= 2) return stops;
|
|
947
|
+
return T.searchSymbols(hld, '', 3).map(function (h) { return h.qualname; });
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function startDemoTour(host, hld) {
|
|
951
|
+
var stops = pickAvailableDemoStops(hld);
|
|
952
|
+
if (!stops.length) return;
|
|
953
|
+
if (demoCtl) { try { demoCtl.destroy(); } catch (e) {} demoCtl = null; }
|
|
954
|
+
|
|
955
|
+
var i = 0;
|
|
956
|
+
var stopped = false;
|
|
957
|
+
var ctl = {
|
|
958
|
+
_timer: null,
|
|
959
|
+
destroy: function () {
|
|
960
|
+
stopped = true;
|
|
961
|
+
if (this._timer) clearTimeout(this._timer);
|
|
962
|
+
},
|
|
963
|
+
};
|
|
964
|
+
demoCtl = ctl;
|
|
965
|
+
function step() {
|
|
966
|
+
if (stopped) return;
|
|
967
|
+
var qn = stops[i % stops.length];
|
|
968
|
+
setFocus(host, hld, qn);
|
|
969
|
+
i++;
|
|
970
|
+
ctl._timer = setTimeout(step, 5500);
|
|
971
|
+
}
|
|
972
|
+
step();
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function renderGraph3d(host) {
|
|
976
|
+
currentHost = host;
|
|
977
|
+
destroyScene();
|
|
978
|
+
|
|
979
|
+
var hld = (window.state && window.state.data && window.state.data.hld) || { modules: {} };
|
|
980
|
+
|
|
981
|
+
if (!hasWebGL()) {
|
|
982
|
+
host.innerHTML = fallbackHtml('WebGL is not supported in this browser.');
|
|
983
|
+
wireFallback(host);
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
if (focus && focus.rootQn) {
|
|
988
|
+
renderFocusedView(host, hld);
|
|
989
|
+
} else {
|
|
990
|
+
renderPickerView(host, hld);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
if (new URLSearchParams(location.search).get('demo') === '1' && !focus) {
|
|
994
|
+
setTimeout(function () { startDemoTour(host, hld); }, 200);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
window.renderGraph3d = renderGraph3d;
|
|
999
|
+
})();
|