coderaph 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -0
- package/dist/cli/analyzer.d.ts +13 -0
- package/dist/cli/analyzer.js +68 -0
- package/dist/cli/file-graph.d.ts +6 -0
- package/dist/cli/file-graph.js +32 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +69 -0
- package/dist/cli/server.d.ts +1 -0
- package/dist/cli/server.js +68 -0
- package/dist/cli/symbol-graph.d.ts +6 -0
- package/dist/cli/symbol-graph.js +112 -0
- package/dist/types/graph.d.ts +22 -0
- package/dist/types/graph.js +1 -0
- package/package.json +25 -0
- package/src/web/app.js +790 -0
- package/src/web/controls.js +732 -0
- package/src/web/graph.js +245 -0
- package/src/web/index.html +93 -0
- package/src/web/style.css +102 -0
package/src/web/app.js
ADDED
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
3
|
+
import { LineSegments2 } from 'three/addons/lines/LineSegments2.js';
|
|
4
|
+
import { LineSegmentsGeometry } from 'three/addons/lines/LineSegmentsGeometry.js';
|
|
5
|
+
import { LineMaterial } from 'three/addons/lines/LineMaterial.js';
|
|
6
|
+
import { ConvexGeometry } from 'three/addons/geometries/ConvexGeometry.js';
|
|
7
|
+
import { ForceGraph } from './graph.js';
|
|
8
|
+
|
|
9
|
+
// ── State ──────────────────────────────────────────────────────────
|
|
10
|
+
let scene, camera, renderer, controls;
|
|
11
|
+
let graphData = null;
|
|
12
|
+
let forceGraph = null;
|
|
13
|
+
let nodeMeshes = new Map();
|
|
14
|
+
let edgeGeometry = null;
|
|
15
|
+
let edgeLine = null;
|
|
16
|
+
let currentMode = 'file';
|
|
17
|
+
let currentDimension = '3d';
|
|
18
|
+
const raycaster = new THREE.Raycaster();
|
|
19
|
+
const mouse = new THREE.Vector2();
|
|
20
|
+
let hoveredNode = null;
|
|
21
|
+
let selectedNode = null;
|
|
22
|
+
let isDragging = false;
|
|
23
|
+
const dragPlane = new THREE.Plane();
|
|
24
|
+
|
|
25
|
+
// Labels
|
|
26
|
+
let labelElements = new Map(); // id → HTMLElement
|
|
27
|
+
let labelsVisible = true;
|
|
28
|
+
let currentLabelFontSize = 10;
|
|
29
|
+
|
|
30
|
+
// Shared geometry
|
|
31
|
+
let sharedSphereGeometry = new THREE.SphereGeometry(1, 16, 16);
|
|
32
|
+
let currentNodeScale = 1.0;
|
|
33
|
+
|
|
34
|
+
// Color map for symbol / file kinds
|
|
35
|
+
const KIND_COLORS = {
|
|
36
|
+
class: 0x4488ff,
|
|
37
|
+
function: 0x44cc66,
|
|
38
|
+
interface: 0xaa66cc,
|
|
39
|
+
type: 0xff8844,
|
|
40
|
+
enum: 0xee4444,
|
|
41
|
+
variable: 0x888888,
|
|
42
|
+
file: 0x4488ff,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Theme colors
|
|
46
|
+
const THEME = {
|
|
47
|
+
dark: { bg: 0x0a0a0a, edgeDim: 0x333333, edgeBright: 0x666666, edgeFilter: 0x111111 },
|
|
48
|
+
light: { bg: 0xf0f0f0, edgeDim: 0x999999, edgeBright: 0xcccccc, edgeFilter: 0xdddddd },
|
|
49
|
+
};
|
|
50
|
+
let currentTheme = 'dark';
|
|
51
|
+
|
|
52
|
+
// Node visibility (for filter)
|
|
53
|
+
let currentVisibleIds = null; // null = all visible, Set<string> = only these
|
|
54
|
+
|
|
55
|
+
// Arrows
|
|
56
|
+
let arrowMesh = null; // InstancedMesh
|
|
57
|
+
let showArrows = false;
|
|
58
|
+
let currentArrowScale = 1.0;
|
|
59
|
+
const arrowDummy = new THREE.Object3D();
|
|
60
|
+
const arrowUp = new THREE.Vector3(0, 1, 0);
|
|
61
|
+
|
|
62
|
+
// Grouping (multi-group)
|
|
63
|
+
let groupHulls = []; // {mesh, nodeIds, color}[]
|
|
64
|
+
let activeGroups = []; // [{query, color}]
|
|
65
|
+
|
|
66
|
+
// Tooltip
|
|
67
|
+
const tooltip = document.getElementById('tooltip');
|
|
68
|
+
const labelsContainer = document.getElementById('labels-container');
|
|
69
|
+
|
|
70
|
+
// ── Initialisation ─────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
function init() {
|
|
73
|
+
scene = new THREE.Scene();
|
|
74
|
+
scene.background = new THREE.Color(THEME[currentTheme].bg);
|
|
75
|
+
|
|
76
|
+
camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 10000);
|
|
77
|
+
camera.position.set(0, 0, 150);
|
|
78
|
+
|
|
79
|
+
const canvas = document.getElementById('canvas');
|
|
80
|
+
renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
|
81
|
+
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
82
|
+
renderer.setPixelRatio(window.devicePixelRatio);
|
|
83
|
+
|
|
84
|
+
controls = new OrbitControls(camera, renderer.domElement);
|
|
85
|
+
controls.enableDamping = true;
|
|
86
|
+
|
|
87
|
+
controls.addEventListener('start', () => {
|
|
88
|
+
const dir = new THREE.Vector3();
|
|
89
|
+
camera.getWorldDirection(dir);
|
|
90
|
+
const dist = camera.position.distanceTo(controls.target);
|
|
91
|
+
controls.target.copy(camera.position).addScaledVector(dir, dist);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const ambient = new THREE.AmbientLight(0xffffff, 0.6);
|
|
95
|
+
scene.add(ambient);
|
|
96
|
+
const directional = new THREE.DirectionalLight(0xffffff, 0.8);
|
|
97
|
+
directional.position.set(50, 50, 50);
|
|
98
|
+
scene.add(directional);
|
|
99
|
+
|
|
100
|
+
window.addEventListener('resize', onResize);
|
|
101
|
+
canvas.addEventListener('pointermove', onPointerMove);
|
|
102
|
+
canvas.addEventListener('pointerdown', onPointerDown);
|
|
103
|
+
canvas.addEventListener('pointerup', onPointerUp);
|
|
104
|
+
canvas.addEventListener('click', onClick);
|
|
105
|
+
canvas.addEventListener('dblclick', onDblClick);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function onResize() {
|
|
109
|
+
camera.aspect = window.innerWidth / window.innerHeight;
|
|
110
|
+
camera.updateProjectionMatrix();
|
|
111
|
+
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
112
|
+
if (edgeLine) {
|
|
113
|
+
edgeLine.material.resolution.set(window.innerWidth, window.innerHeight);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Data loading ───────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
async function loadData() {
|
|
120
|
+
const res = await fetch('/api/graph');
|
|
121
|
+
graphData = await res.json();
|
|
122
|
+
buildScene(currentMode);
|
|
123
|
+
document.getElementById('canvas').dispatchEvent(new CustomEvent('graph-ready'));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Scene construction ─────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
function buildScene(mode) {
|
|
129
|
+
for (const mesh of nodeMeshes.values()) {
|
|
130
|
+
scene.remove(mesh);
|
|
131
|
+
mesh.material.dispose();
|
|
132
|
+
}
|
|
133
|
+
nodeMeshes.clear();
|
|
134
|
+
|
|
135
|
+
if (edgeLine) {
|
|
136
|
+
scene.remove(edgeLine);
|
|
137
|
+
edgeGeometry.dispose();
|
|
138
|
+
edgeLine.material.dispose();
|
|
139
|
+
edgeLine = null;
|
|
140
|
+
edgeGeometry = null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
clearLabels();
|
|
144
|
+
|
|
145
|
+
let nodes, edges;
|
|
146
|
+
if (mode === 'file') {
|
|
147
|
+
nodes = graphData.files;
|
|
148
|
+
edges = graphData.fileEdges;
|
|
149
|
+
} else {
|
|
150
|
+
nodes = graphData.symbols;
|
|
151
|
+
edges = graphData.symbolEdges;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
forceGraph = new ForceGraph(nodes, edges, currentDimension === '2d');
|
|
155
|
+
|
|
156
|
+
for (const node of nodes) {
|
|
157
|
+
const kind = node.kind || 'file';
|
|
158
|
+
const color = KIND_COLORS[kind] ?? KIND_COLORS.file;
|
|
159
|
+
const material = new THREE.MeshStandardMaterial({ color });
|
|
160
|
+
const mesh = new THREE.Mesh(sharedSphereGeometry, material);
|
|
161
|
+
mesh.scale.setScalar(currentNodeScale);
|
|
162
|
+
mesh.position.set(node.x, node.y, node.z);
|
|
163
|
+
mesh.userData.nodeId = node.id;
|
|
164
|
+
scene.add(mesh);
|
|
165
|
+
nodeMeshes.set(node.id, mesh);
|
|
166
|
+
createLabel(node);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Build positions and colors arrays for LineSegments2
|
|
170
|
+
const posArr = new Float32Array(edges.length * 6); // 2 points × 3 components per segment
|
|
171
|
+
const colArr = new Float32Array(edges.length * 6);
|
|
172
|
+
const dimColor = new THREE.Color(THEME[currentTheme].edgeDim);
|
|
173
|
+
const brightColor = new THREE.Color(THEME[currentTheme].edgeBright);
|
|
174
|
+
|
|
175
|
+
for (let i = 0; i < edges.length; i++) {
|
|
176
|
+
const edge = edges[i];
|
|
177
|
+
const srcNode = forceGraph.nodeMap.get(edge.source);
|
|
178
|
+
const tgtNode = forceGraph.nodeMap.get(edge.target);
|
|
179
|
+
const base = i * 6;
|
|
180
|
+
if (srcNode && tgtNode) {
|
|
181
|
+
posArr[base] = srcNode.x; posArr[base + 1] = srcNode.y; posArr[base + 2] = srcNode.z;
|
|
182
|
+
posArr[base + 3] = tgtNode.x; posArr[base + 4] = tgtNode.y; posArr[base + 5] = tgtNode.z;
|
|
183
|
+
}
|
|
184
|
+
colArr[base] = dimColor.r; colArr[base + 1] = dimColor.g; colArr[base + 2] = dimColor.b;
|
|
185
|
+
colArr[base + 3] = brightColor.r; colArr[base + 4] = brightColor.g; colArr[base + 5] = brightColor.b;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
edgeGeometry = new LineSegmentsGeometry();
|
|
189
|
+
edgeGeometry.setPositions(posArr);
|
|
190
|
+
edgeGeometry.setColors(colArr);
|
|
191
|
+
|
|
192
|
+
const edgeMaterial = new LineMaterial({
|
|
193
|
+
vertexColors: true,
|
|
194
|
+
transparent: true,
|
|
195
|
+
opacity: 0.4,
|
|
196
|
+
linewidth: 1,
|
|
197
|
+
resolution: new THREE.Vector2(window.innerWidth, window.innerHeight),
|
|
198
|
+
});
|
|
199
|
+
edgeLine = new LineSegments2(edgeGeometry, edgeMaterial);
|
|
200
|
+
scene.add(edgeLine);
|
|
201
|
+
|
|
202
|
+
// Arrows
|
|
203
|
+
if (arrowMesh) { scene.remove(arrowMesh); arrowMesh.geometry.dispose(); arrowMesh.material.dispose(); arrowMesh = null; }
|
|
204
|
+
const arrowGeo = new THREE.ConeGeometry(0.4, 1.2, 6);
|
|
205
|
+
const arrowMat = new THREE.MeshBasicMaterial({ color: THEME[currentTheme].edgeBright, transparent: true, opacity: 0.6 });
|
|
206
|
+
arrowMesh = new THREE.InstancedMesh(arrowGeo, arrowMat, edges.length);
|
|
207
|
+
arrowMesh.visible = showArrows;
|
|
208
|
+
scene.add(arrowMesh);
|
|
209
|
+
|
|
210
|
+
buildGroupHulls();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── Labels ─────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
function createLabel(node) {
|
|
216
|
+
const el = document.createElement('div');
|
|
217
|
+
el.className = 'node-label';
|
|
218
|
+
el.textContent = node.name || node.id.split('/').pop();
|
|
219
|
+
el.style.display = labelsVisible ? '' : 'none';
|
|
220
|
+
el.style.fontSize = currentLabelFontSize + 'px';
|
|
221
|
+
labelsContainer.appendChild(el);
|
|
222
|
+
labelElements.set(node.id, el);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function clearLabels() {
|
|
226
|
+
for (const el of labelElements.values()) el.remove();
|
|
227
|
+
labelElements.clear();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function updateLabelPositions() {
|
|
231
|
+
if (!labelsVisible) return;
|
|
232
|
+
const halfW = window.innerWidth / 2;
|
|
233
|
+
const halfH = window.innerHeight / 2;
|
|
234
|
+
const vec = new THREE.Vector3();
|
|
235
|
+
|
|
236
|
+
for (const [id, el] of labelElements) {
|
|
237
|
+
const mesh = nodeMeshes.get(id);
|
|
238
|
+
if (!mesh) continue;
|
|
239
|
+
// Respect filter visibility
|
|
240
|
+
if (currentVisibleIds && !currentVisibleIds.has(id)) { el.style.display = 'none'; continue; }
|
|
241
|
+
vec.copy(mesh.position);
|
|
242
|
+
vec.project(camera);
|
|
243
|
+
if (vec.z > 1) { el.style.display = 'none'; continue; }
|
|
244
|
+
el.style.display = '';
|
|
245
|
+
el.style.left = ((vec.x * halfW) + halfW) + 'px';
|
|
246
|
+
el.style.top = (-(vec.y * halfH) + halfH) + 'px';
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── Tooltip ────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
function showTooltip(node, event) {
|
|
253
|
+
const name = node.name || node.id;
|
|
254
|
+
const kind = node.kind ? node.kind : 'file';
|
|
255
|
+
const file = node.fileId || node.id;
|
|
256
|
+
|
|
257
|
+
tooltip.textContent = '';
|
|
258
|
+
const strong = document.createElement('strong');
|
|
259
|
+
strong.textContent = name;
|
|
260
|
+
tooltip.appendChild(strong);
|
|
261
|
+
tooltip.appendChild(document.createElement('br'));
|
|
262
|
+
tooltip.appendChild(document.createTextNode('Kind: ' + kind));
|
|
263
|
+
tooltip.appendChild(document.createElement('br'));
|
|
264
|
+
tooltip.appendChild(document.createTextNode('File: ' + file));
|
|
265
|
+
tooltip.classList.remove('hidden');
|
|
266
|
+
positionTooltip(event);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function positionTooltip(event) {
|
|
270
|
+
tooltip.style.left = (event.clientX + 14) + 'px';
|
|
271
|
+
tooltip.style.top = (event.clientY + 14) + 'px';
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function hideTooltip() {
|
|
275
|
+
tooltip.classList.add('hidden');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── Animation loop ─────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
function animate() {
|
|
281
|
+
requestAnimationFrame(animate);
|
|
282
|
+
|
|
283
|
+
const simRunning = forceGraph && !forceGraph.isStable();
|
|
284
|
+
if (simRunning) forceGraph.tick();
|
|
285
|
+
|
|
286
|
+
if (forceGraph) {
|
|
287
|
+
for (const node of forceGraph.nodes) {
|
|
288
|
+
const mesh = nodeMeshes.get(node.id);
|
|
289
|
+
if (mesh) mesh.position.set(node.x, node.y, node.z);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (edgeGeometry) {
|
|
293
|
+
const edges = currentMode === 'file' ? graphData.fileEdges : graphData.symbolEdges;
|
|
294
|
+
const posArr = new Float32Array(edges.length * 6);
|
|
295
|
+
for (let i = 0; i < edges.length; i++) {
|
|
296
|
+
const edge = edges[i];
|
|
297
|
+
const srcNode = forceGraph.nodeMap.get(edge.source);
|
|
298
|
+
const tgtNode = forceGraph.nodeMap.get(edge.target);
|
|
299
|
+
if (!srcNode || !tgtNode) continue;
|
|
300
|
+
// Respect filter visibility
|
|
301
|
+
if (currentVisibleIds && (!currentVisibleIds.has(edge.source) || !currentVisibleIds.has(edge.target))) continue;
|
|
302
|
+
const base = i * 6;
|
|
303
|
+
posArr[base] = srcNode.x; posArr[base + 1] = srcNode.y; posArr[base + 2] = srcNode.z;
|
|
304
|
+
posArr[base + 3] = tgtNode.x; posArr[base + 4] = tgtNode.y; posArr[base + 5] = tgtNode.z;
|
|
305
|
+
}
|
|
306
|
+
edgeGeometry.setPositions(posArr);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Update arrow positions
|
|
310
|
+
if (arrowMesh && showArrows) {
|
|
311
|
+
const edges = currentMode === 'file' ? graphData.fileEdges : graphData.symbolEdges;
|
|
312
|
+
const dir = new THREE.Vector3();
|
|
313
|
+
for (let i = 0; i < edges.length; i++) {
|
|
314
|
+
const edge = edges[i];
|
|
315
|
+
const sn = forceGraph.nodeMap.get(edge.source);
|
|
316
|
+
const tn = forceGraph.nodeMap.get(edge.target);
|
|
317
|
+
if (!sn || !tn) { arrowDummy.scale.setScalar(0); arrowDummy.updateMatrix(); arrowMesh.setMatrixAt(i, arrowDummy.matrix); continue; }
|
|
318
|
+
if (currentVisibleIds && (!currentVisibleIds.has(edge.source) || !currentVisibleIds.has(edge.target))) {
|
|
319
|
+
arrowDummy.scale.setScalar(0); arrowDummy.updateMatrix(); arrowMesh.setMatrixAt(i, arrowDummy.matrix); continue;
|
|
320
|
+
}
|
|
321
|
+
dir.set(tn.x - sn.x, tn.y - sn.y, tn.z - sn.z);
|
|
322
|
+
const len = dir.length();
|
|
323
|
+
if (len < 0.01) { arrowDummy.scale.setScalar(0); arrowDummy.updateMatrix(); arrowMesh.setMatrixAt(i, arrowDummy.matrix); continue; }
|
|
324
|
+
dir.divideScalar(len);
|
|
325
|
+
const offset = currentNodeScale + 0.8;
|
|
326
|
+
arrowDummy.position.set(tn.x - dir.x * offset, tn.y - dir.y * offset, tn.z - dir.z * offset);
|
|
327
|
+
arrowDummy.quaternion.setFromUnitVectors(arrowUp, dir);
|
|
328
|
+
arrowDummy.scale.setScalar(currentNodeScale * 0.6 * currentArrowScale);
|
|
329
|
+
arrowDummy.updateMatrix();
|
|
330
|
+
arrowMesh.setMatrixAt(i, arrowDummy.matrix);
|
|
331
|
+
}
|
|
332
|
+
arrowMesh.instanceMatrix.needsUpdate = true;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (simRunning && groupHulls.length > 0) updateGroupHulls();
|
|
337
|
+
|
|
338
|
+
updateLabelPositions();
|
|
339
|
+
controls.update();
|
|
340
|
+
renderer.render(scene, camera);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ── Mouse interaction ──────────────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
function getIntersectedMesh(event) {
|
|
346
|
+
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
|
|
347
|
+
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
|
|
348
|
+
raycaster.setFromCamera(mouse, camera);
|
|
349
|
+
const meshes = Array.from(nodeMeshes.values());
|
|
350
|
+
const hits = raycaster.intersectObjects(meshes);
|
|
351
|
+
return hits.length > 0 ? hits[0].object : null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function findNodeData(nodeId) {
|
|
355
|
+
if (!graphData) return null;
|
|
356
|
+
if (currentMode === 'file') return graphData.files.find(f => f.id === nodeId);
|
|
357
|
+
return graphData.symbols.find(s => s.id === nodeId);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function onPointerMove(event) {
|
|
361
|
+
if (isDragging && hoveredNode) {
|
|
362
|
+
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
|
|
363
|
+
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
|
|
364
|
+
raycaster.setFromCamera(mouse, camera);
|
|
365
|
+
const intersection = new THREE.Vector3();
|
|
366
|
+
if (raycaster.ray.intersectPlane(dragPlane, intersection)) {
|
|
367
|
+
const nodeId = hoveredNode.userData.nodeId;
|
|
368
|
+
const node = forceGraph.nodeMap.get(nodeId);
|
|
369
|
+
if (node) {
|
|
370
|
+
node.x = intersection.x; node.y = intersection.y; node.z = intersection.z;
|
|
371
|
+
forceGraph.pinNode(nodeId, intersection.x, intersection.y, intersection.z);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
positionTooltip(event);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const mesh = getIntersectedMesh(event);
|
|
379
|
+
if (hoveredNode && hoveredNode !== mesh) hoveredNode.material.emissive.setHex(0x000000);
|
|
380
|
+
|
|
381
|
+
if (mesh) {
|
|
382
|
+
renderer.domElement.style.cursor = 'pointer';
|
|
383
|
+
mesh.material.emissive.setHex(0x222222);
|
|
384
|
+
hoveredNode = mesh;
|
|
385
|
+
const nodeData = findNodeData(mesh.userData.nodeId);
|
|
386
|
+
if (nodeData) showTooltip(nodeData, event);
|
|
387
|
+
} else {
|
|
388
|
+
renderer.domElement.style.cursor = 'default';
|
|
389
|
+
hoveredNode = null;
|
|
390
|
+
hideTooltip();
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function onPointerDown(event) {
|
|
395
|
+
if (!hoveredNode) return;
|
|
396
|
+
isDragging = true;
|
|
397
|
+
controls.enabled = false;
|
|
398
|
+
const nodeId = hoveredNode.userData.nodeId;
|
|
399
|
+
const node = forceGraph.nodeMap.get(nodeId);
|
|
400
|
+
if (node) forceGraph.pinNode(nodeId, node.x, node.y, node.z);
|
|
401
|
+
const cameraDir = new THREE.Vector3();
|
|
402
|
+
camera.getWorldDirection(cameraDir);
|
|
403
|
+
dragPlane.setFromNormalAndCoplanarPoint(cameraDir, hoveredNode.position);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function onPointerUp() {
|
|
407
|
+
if (!isDragging) return;
|
|
408
|
+
isDragging = false;
|
|
409
|
+
controls.enabled = true;
|
|
410
|
+
if (hoveredNode) {
|
|
411
|
+
forceGraph.unpinNode(hoveredNode.userData.nodeId);
|
|
412
|
+
forceGraph.reheat();
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function onClick(event) {
|
|
417
|
+
if (isDragging) return;
|
|
418
|
+
const mesh = getIntersectedMesh(event);
|
|
419
|
+
if (mesh) {
|
|
420
|
+
selectedNode = mesh;
|
|
421
|
+
const nodeId = mesh.userData.nodeId;
|
|
422
|
+
highlightConnected(nodeId);
|
|
423
|
+
renderer.domElement.dispatchEvent(new CustomEvent('node-click', { detail: { nodeId } }));
|
|
424
|
+
} else {
|
|
425
|
+
clearConnectedHighlight();
|
|
426
|
+
renderer.domElement.dispatchEvent(new CustomEvent('node-deselect'));
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function onDblClick(event) {
|
|
431
|
+
const mesh = getIntersectedMesh(event);
|
|
432
|
+
if (mesh) {
|
|
433
|
+
renderer.domElement.dispatchEvent(
|
|
434
|
+
new CustomEvent('node-dblclick', { detail: { nodeId: mesh.userData.nodeId } }),
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ── Edge color helpers ─────────────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
function updateEdgeColors(visibleSet) {
|
|
442
|
+
if (!edgeLine || !edgeGeometry) return;
|
|
443
|
+
const edges = getCurrentEdges();
|
|
444
|
+
const colArr = new Float32Array(edges.length * 6);
|
|
445
|
+
const dimColor = new THREE.Color(visibleSet ? THEME[currentTheme].edgeFilter : THEME[currentTheme].edgeDim);
|
|
446
|
+
const srcColor = new THREE.Color(THEME[currentTheme].edgeDim);
|
|
447
|
+
const tgtColor = new THREE.Color(THEME[currentTheme].edgeBright);
|
|
448
|
+
for (let i = 0; i < edges.length; i++) {
|
|
449
|
+
const edge = edges[i];
|
|
450
|
+
const visible = !visibleSet || (visibleSet.has(edge.source) && visibleSet.has(edge.target));
|
|
451
|
+
const base = i * 6;
|
|
452
|
+
const s = visible ? srcColor : dimColor;
|
|
453
|
+
const t = visible ? tgtColor : dimColor;
|
|
454
|
+
colArr[base] = s.r; colArr[base + 1] = s.g; colArr[base + 2] = s.b;
|
|
455
|
+
colArr[base + 3] = t.r; colArr[base + 4] = t.g; colArr[base + 5] = t.b;
|
|
456
|
+
}
|
|
457
|
+
edgeGeometry.setColors(colArr);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ── Connected node highlight (click) ───────────────────────────────
|
|
461
|
+
|
|
462
|
+
let connectedHighlightActive = false;
|
|
463
|
+
|
|
464
|
+
function highlightConnected(nodeId) {
|
|
465
|
+
const edges = getCurrentEdges();
|
|
466
|
+
const connected = new Set([nodeId]);
|
|
467
|
+
for (const edge of edges) {
|
|
468
|
+
if (edge.source === nodeId) connected.add(edge.target);
|
|
469
|
+
if (edge.target === nodeId) connected.add(edge.source);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
for (const [id, mesh] of nodeMeshes) {
|
|
473
|
+
if (connected.has(id)) {
|
|
474
|
+
mesh.material.opacity = 1.0;
|
|
475
|
+
mesh.material.transparent = false;
|
|
476
|
+
mesh.material.emissive.setHex(id === nodeId ? 0x444444 : 0x222222);
|
|
477
|
+
} else {
|
|
478
|
+
mesh.material.opacity = 0.08;
|
|
479
|
+
mesh.material.transparent = true;
|
|
480
|
+
mesh.material.emissive.setHex(0x000000);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
updateEdgeColors(connected);
|
|
485
|
+
|
|
486
|
+
for (const [id, el] of labelElements) {
|
|
487
|
+
el.style.opacity = connected.has(id) ? '1' : '0.1';
|
|
488
|
+
}
|
|
489
|
+
connectedHighlightActive = true;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function clearConnectedHighlight() {
|
|
493
|
+
if (!connectedHighlightActive) return;
|
|
494
|
+
for (const mesh of nodeMeshes.values()) {
|
|
495
|
+
mesh.material.opacity = 1.0;
|
|
496
|
+
mesh.material.transparent = false;
|
|
497
|
+
mesh.material.emissive.setHex(0x000000);
|
|
498
|
+
}
|
|
499
|
+
updateEdgeColors(null);
|
|
500
|
+
for (const el of labelElements.values()) el.style.opacity = '1';
|
|
501
|
+
connectedHighlightActive = false;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ── Exports for controls.js ────────────────────────────────────────
|
|
505
|
+
|
|
506
|
+
export function switchMode(mode) { currentMode = mode; buildScene(mode); }
|
|
507
|
+
export function getGraphData() { return graphData; }
|
|
508
|
+
export function getCurrentEdges() {
|
|
509
|
+
if (!graphData) return [];
|
|
510
|
+
return currentMode === 'file' ? graphData.fileEdges : graphData.symbolEdges;
|
|
511
|
+
}
|
|
512
|
+
export function getNodeMeshes() { return nodeMeshes; }
|
|
513
|
+
|
|
514
|
+
export function focusNode(nodeId) {
|
|
515
|
+
const mesh = nodeMeshes.get(nodeId);
|
|
516
|
+
if (!mesh) return;
|
|
517
|
+
const target = mesh.position.clone();
|
|
518
|
+
controls.target.copy(target);
|
|
519
|
+
camera.position.copy(target.clone().add(new THREE.Vector3(0, 0, 80)));
|
|
520
|
+
controls.update();
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export function highlightNodes(nodeIds, focusedId) {
|
|
524
|
+
const idSet = nodeIds instanceof Set ? nodeIds : new Set(nodeIds);
|
|
525
|
+
for (const [id, mesh] of nodeMeshes) {
|
|
526
|
+
if (id === focusedId) mesh.material.emissive.setHex(0x666666);
|
|
527
|
+
else if (idSet.has(id)) mesh.material.emissive.setHex(0x444444);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export function clearHighlights() {
|
|
532
|
+
for (const mesh of nodeMeshes.values()) mesh.material.emissive.setHex(0x000000);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export function setFilteredView(visibleNodeIds) {
|
|
536
|
+
const idSet = visibleNodeIds instanceof Set ? visibleNodeIds : new Set(visibleNodeIds);
|
|
537
|
+
for (const [id, mesh] of nodeMeshes) {
|
|
538
|
+
mesh.material.opacity = idSet.has(id) ? 1.0 : 0.05;
|
|
539
|
+
mesh.material.transparent = true;
|
|
540
|
+
}
|
|
541
|
+
updateEdgeColors(idSet);
|
|
542
|
+
for (const [id, el] of labelElements) el.style.opacity = idSet.has(id) ? '1' : '0.05';
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
export function clearFilter() {
|
|
546
|
+
for (const mesh of nodeMeshes.values()) {
|
|
547
|
+
mesh.material.opacity = 1.0;
|
|
548
|
+
mesh.material.transparent = false;
|
|
549
|
+
}
|
|
550
|
+
updateEdgeColors(null);
|
|
551
|
+
for (const el of labelElements.values()) el.style.opacity = '1';
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// ── Settings API ───────────────────────────────────────────────────
|
|
555
|
+
|
|
556
|
+
// ── Grouping ────────────────────────────────────────────────────────
|
|
557
|
+
|
|
558
|
+
function matchGroupQuery(query, node) {
|
|
559
|
+
if (!query) return false;
|
|
560
|
+
const q = query.trim().toLowerCase();
|
|
561
|
+
if (q.startsWith('path:')) {
|
|
562
|
+
const val = q.slice(5);
|
|
563
|
+
const path = (node.fileId || node.id).toLowerCase();
|
|
564
|
+
return path.includes(val);
|
|
565
|
+
}
|
|
566
|
+
if (q.startsWith('file:')) {
|
|
567
|
+
const val = q.slice(5);
|
|
568
|
+
const fileName = (node.fileId || node.id).split('/').pop().toLowerCase();
|
|
569
|
+
return fileName.includes(val);
|
|
570
|
+
}
|
|
571
|
+
if (q.startsWith('kind:')) {
|
|
572
|
+
const val = q.slice(5);
|
|
573
|
+
return (node.kind || 'file').toLowerCase() === val;
|
|
574
|
+
}
|
|
575
|
+
// Plain text: match name, id, fileId
|
|
576
|
+
const name = (node.name || '').toLowerCase();
|
|
577
|
+
const id = node.id.toLowerCase();
|
|
578
|
+
const file = (node.fileId || '').toLowerCase();
|
|
579
|
+
return name.includes(q) || id.includes(q) || file.includes(q);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function computeGroupNodeIds(query) {
|
|
583
|
+
if (!graphData || !query.trim()) return [];
|
|
584
|
+
const nodes = currentMode === 'file' ? graphData.files : graphData.symbols;
|
|
585
|
+
const ids = [];
|
|
586
|
+
for (const node of nodes) {
|
|
587
|
+
if (matchGroupQuery(query, node)) ids.push(node.id);
|
|
588
|
+
}
|
|
589
|
+
return ids;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function padHullPoints(points) {
|
|
593
|
+
// ConvexGeometry needs at least 4 non-coplanar points
|
|
594
|
+
if (points.length === 0) return points;
|
|
595
|
+
const centroid = new THREE.Vector3();
|
|
596
|
+
for (const p of points) centroid.add(p);
|
|
597
|
+
centroid.divideScalar(points.length);
|
|
598
|
+
const padded = [...points];
|
|
599
|
+
const margin = 2;
|
|
600
|
+
const offsets = [
|
|
601
|
+
new THREE.Vector3(margin, 0, 0), new THREE.Vector3(0, margin, 0),
|
|
602
|
+
new THREE.Vector3(0, 0, margin), new THREE.Vector3(-margin, -margin, -margin),
|
|
603
|
+
];
|
|
604
|
+
let idx = 0;
|
|
605
|
+
while (padded.length < 4) {
|
|
606
|
+
padded.push(centroid.clone().add(offsets[idx % offsets.length]));
|
|
607
|
+
idx++;
|
|
608
|
+
}
|
|
609
|
+
// Ensure non-coplanar in 2D mode
|
|
610
|
+
if (currentDimension === '2d') {
|
|
611
|
+
for (const p of padded) p.z += (Math.random() - 0.5) * 0.5;
|
|
612
|
+
}
|
|
613
|
+
return padded;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function clearGroupHulls() {
|
|
617
|
+
for (const h of groupHulls) {
|
|
618
|
+
scene.remove(h.mesh);
|
|
619
|
+
h.mesh.geometry.dispose();
|
|
620
|
+
h.mesh.material.dispose();
|
|
621
|
+
}
|
|
622
|
+
groupHulls = [];
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function buildGroupHulls() {
|
|
626
|
+
clearGroupHulls();
|
|
627
|
+
if (!forceGraph || activeGroups.length === 0) return;
|
|
628
|
+
|
|
629
|
+
for (const group of activeGroups) {
|
|
630
|
+
const nodeIds = computeGroupNodeIds(group.query);
|
|
631
|
+
if (nodeIds.length === 0) continue;
|
|
632
|
+
|
|
633
|
+
const rawPoints = nodeIds.map(id => {
|
|
634
|
+
const n = forceGraph.nodeMap.get(id);
|
|
635
|
+
return new THREE.Vector3(n.x, n.y, n.z);
|
|
636
|
+
});
|
|
637
|
+
const points = padHullPoints(rawPoints);
|
|
638
|
+
|
|
639
|
+
try {
|
|
640
|
+
const geo = new ConvexGeometry(points);
|
|
641
|
+
const mat = new THREE.MeshBasicMaterial({
|
|
642
|
+
color: group.color, transparent: true, opacity: 0.08, side: THREE.DoubleSide, depthWrite: false,
|
|
643
|
+
});
|
|
644
|
+
const mesh = new THREE.Mesh(geo, mat);
|
|
645
|
+
scene.add(mesh);
|
|
646
|
+
groupHulls.push({ mesh, nodeIds, query: group.query, color: group.color });
|
|
647
|
+
} catch { /* degenerate hull, skip */ }
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function updateGroupHulls() {
|
|
652
|
+
if (groupHulls.length === 0 || !forceGraph) return;
|
|
653
|
+
|
|
654
|
+
for (const h of groupHulls) {
|
|
655
|
+
const rawPoints = h.nodeIds.map(id => {
|
|
656
|
+
const n = forceGraph.nodeMap.get(id);
|
|
657
|
+
return new THREE.Vector3(n.x, n.y, n.z);
|
|
658
|
+
});
|
|
659
|
+
const points = padHullPoints(rawPoints);
|
|
660
|
+
|
|
661
|
+
try {
|
|
662
|
+
const oldGeo = h.mesh.geometry;
|
|
663
|
+
h.mesh.geometry = new ConvexGeometry(points);
|
|
664
|
+
oldGeo.dispose();
|
|
665
|
+
} catch { /* degenerate, keep old */ }
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
export function setGroups(groups) {
|
|
670
|
+
activeGroups = groups; // [{query, color}]
|
|
671
|
+
buildGroupHulls();
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
export function setShowArrows(visible) {
|
|
675
|
+
showArrows = visible;
|
|
676
|
+
if (arrowMesh) arrowMesh.visible = visible;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
export function setDimension(dim) {
|
|
680
|
+
currentDimension = dim;
|
|
681
|
+
if (dim === '2d') {
|
|
682
|
+
if (forceGraph) forceGraph.setIs2D(true);
|
|
683
|
+
camera.position.set(0, 0, 150);
|
|
684
|
+
controls.target.set(0, 0, 0);
|
|
685
|
+
controls.enableRotate = false;
|
|
686
|
+
controls.mouseButtons = { LEFT: THREE.MOUSE.PAN, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.PAN };
|
|
687
|
+
} else {
|
|
688
|
+
if (forceGraph) forceGraph.setIs2D(false);
|
|
689
|
+
controls.enableRotate = true;
|
|
690
|
+
controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.PAN };
|
|
691
|
+
}
|
|
692
|
+
controls.update();
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
export function setTheme(theme) {
|
|
696
|
+
currentTheme = theme;
|
|
697
|
+
if (scene) scene.background = new THREE.Color(THEME[theme].bg);
|
|
698
|
+
updateEdgeColors(null);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
export function setNodeSize(scale) {
|
|
702
|
+
currentNodeScale = scale;
|
|
703
|
+
for (const mesh of nodeMeshes.values()) mesh.scale.setScalar(scale);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
export function setEdgeThickness(width) {
|
|
707
|
+
if (edgeLine) {
|
|
708
|
+
edgeLine.material.linewidth = width;
|
|
709
|
+
}
|
|
710
|
+
currentArrowScale = width;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
export function setNodeDistance(distance) {
|
|
714
|
+
if (forceGraph) {
|
|
715
|
+
forceGraph.idealDistance = distance;
|
|
716
|
+
forceGraph.reheat();
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
export function setLabelsVisible(visible) {
|
|
721
|
+
labelsVisible = visible;
|
|
722
|
+
for (const el of labelElements.values()) el.style.display = visible ? '' : 'none';
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
export function setLabelFontSize(px) {
|
|
726
|
+
currentLabelFontSize = px;
|
|
727
|
+
for (const el of labelElements.values()) el.style.fontSize = px + 'px';
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function rebuildFilteredEdges() {
|
|
731
|
+
if (!edgeLine || !edgeGeometry || !forceGraph) return;
|
|
732
|
+
const edges = getCurrentEdges();
|
|
733
|
+
const posArr = new Float32Array(edges.length * 6);
|
|
734
|
+
const colArr = new Float32Array(edges.length * 6);
|
|
735
|
+
const dimColor = new THREE.Color(THEME[currentTheme].edgeDim);
|
|
736
|
+
const brightColor = new THREE.Color(THEME[currentTheme].edgeBright);
|
|
737
|
+
|
|
738
|
+
for (let i = 0; i < edges.length; i++) {
|
|
739
|
+
const edge = edges[i];
|
|
740
|
+
if (currentVisibleIds && (!currentVisibleIds.has(edge.source) || !currentVisibleIds.has(edge.target))) continue;
|
|
741
|
+
const srcNode = forceGraph.nodeMap.get(edge.source);
|
|
742
|
+
const tgtNode = forceGraph.nodeMap.get(edge.target);
|
|
743
|
+
if (!srcNode || !tgtNode) continue;
|
|
744
|
+
const base = i * 6;
|
|
745
|
+
posArr[base] = srcNode.x; posArr[base + 1] = srcNode.y; posArr[base + 2] = srcNode.z;
|
|
746
|
+
posArr[base + 3] = tgtNode.x; posArr[base + 4] = tgtNode.y; posArr[base + 5] = tgtNode.z;
|
|
747
|
+
colArr[base] = dimColor.r; colArr[base + 1] = dimColor.g; colArr[base + 2] = dimColor.b;
|
|
748
|
+
colArr[base + 3] = brightColor.r; colArr[base + 4] = brightColor.g; colArr[base + 5] = brightColor.b;
|
|
749
|
+
}
|
|
750
|
+
edgeGeometry.setPositions(posArr);
|
|
751
|
+
edgeGeometry.setColors(colArr);
|
|
752
|
+
|
|
753
|
+
// Also hide arrows for filtered edges
|
|
754
|
+
if (arrowMesh) {
|
|
755
|
+
for (let i = 0; i < edges.length; i++) {
|
|
756
|
+
const edge = edges[i];
|
|
757
|
+
if (currentVisibleIds && (!currentVisibleIds.has(edge.source) || !currentVisibleIds.has(edge.target))) {
|
|
758
|
+
arrowDummy.scale.setScalar(0);
|
|
759
|
+
arrowDummy.updateMatrix();
|
|
760
|
+
arrowMesh.setMatrixAt(i, arrowDummy.matrix);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
arrowMesh.instanceMatrix.needsUpdate = true;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
export function setNodeVisibility(visibleIds) {
|
|
768
|
+
currentVisibleIds = visibleIds;
|
|
769
|
+
for (const [id, mesh] of nodeMeshes) {
|
|
770
|
+
const vis = !visibleIds || visibleIds.has(id);
|
|
771
|
+
if (!vis) {
|
|
772
|
+
scene.remove(mesh);
|
|
773
|
+
} else if (!mesh.parent) {
|
|
774
|
+
scene.add(mesh);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
for (const [id, el] of labelElements) {
|
|
778
|
+
const vis = !visibleIds || visibleIds.has(id);
|
|
779
|
+
el.style.display = (vis && labelsVisible) ? '' : 'none';
|
|
780
|
+
}
|
|
781
|
+
// Rebuild edge geometry with only visible edges
|
|
782
|
+
rebuildFilteredEdges();
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
export { clearConnectedHighlight };
|
|
786
|
+
|
|
787
|
+
// ── Bootstrap ──────────────────────────────────────────────────────
|
|
788
|
+
|
|
789
|
+
init();
|
|
790
|
+
loadData().then(() => animate());
|