@ryanstark24/sfgraph-web 1.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 +57 -0
- package/dist/__tests__/search-bounds.test.d.ts +2 -0
- package/dist/__tests__/search-bounds.test.d.ts.map +1 -0
- package/dist/__tests__/search-bounds.test.js +45 -0
- package/dist/__tests__/search-bounds.test.js.map +1 -0
- package/dist/api.d.ts +74 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +355 -0
- package/dist/api.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/public/app.js +1001 -0
- package/dist/public/index.html +224 -0
- package/dist/public/styles.css +1063 -0
- package/dist/server.d.ts +17 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +179 -0
- package/dist/server.js.map +1 -0
- package/package.json +47 -0
|
@@ -0,0 +1,1001 @@
|
|
|
1
|
+
/* sfgraph explorer — galactic visualisation (three.js + 3d-force-graph).
|
|
2
|
+
*
|
|
3
|
+
* Goals:
|
|
4
|
+
* 1. Cluster nodes by label so similar things orbit together → galaxy feel.
|
|
5
|
+
* 2. Bigger spheres for high-degree hubs. Stars vs planets.
|
|
6
|
+
* 3. Per-node text labels that fade in only when the camera is close enough
|
|
7
|
+
* to read them — no permanent text spam at distance.
|
|
8
|
+
* 4. Double-click = fly in close; single-click = inspector + soft zoom.
|
|
9
|
+
* 5. UnrealBloomPass over emissive materials → real glowing orbs.
|
|
10
|
+
* 6. Background starfield so empty space isn't empty.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import ForceGraph3D from "https://esm.sh/3d-force-graph@1.73.4";
|
|
14
|
+
import * as THREE from "https://esm.sh/three@0.160.0";
|
|
15
|
+
// NOTE: UnrealBloomPass was tried as a render layer for "real" glow, but it
|
|
16
|
+
// silently breaks the postProcessingComposer's output (entire canvas blacks
|
|
17
|
+
// out) when the addon's three.js version differs subtly from the one
|
|
18
|
+
// 3d-force-graph bundles. Sticking with stronger emissive + halo sprites,
|
|
19
|
+
// which is 95% of the look at zero render-pipeline risk.
|
|
20
|
+
|
|
21
|
+
/* ── per-label color map ── */
|
|
22
|
+
const LABEL_COLOR = {
|
|
23
|
+
ApexClass: "#ff8a4c",
|
|
24
|
+
ApexTrigger: "#ff8a4c",
|
|
25
|
+
ApexMethod: "#ffb380",
|
|
26
|
+
TestMethod: "#ffd6b3",
|
|
27
|
+
ApexPage: "#ff8a4c",
|
|
28
|
+
LightningComponentBundle: "#5eecff",
|
|
29
|
+
LWC: "#5eecff",
|
|
30
|
+
LWCBundle: "#5eecff",
|
|
31
|
+
AuraDefinitionBundle: "#5eecff",
|
|
32
|
+
Flow: "#b794f4",
|
|
33
|
+
FlowVersion: "#d4b6ff",
|
|
34
|
+
CustomField: "#4ade80",
|
|
35
|
+
CustomObject: "#f5d76e",
|
|
36
|
+
Profile: "#ff5ec8",
|
|
37
|
+
PermissionSet: "#ff5ec8",
|
|
38
|
+
PermissionSetGroup: "#ff5ec8",
|
|
39
|
+
SharingRule: "#ff96d8",
|
|
40
|
+
NamedCredential: "#b9ff5e",
|
|
41
|
+
ExternalServiceRegistration: "#b9ff5e",
|
|
42
|
+
StaticResource: "#8b95b8",
|
|
43
|
+
CustomLabel: "#8b95b8",
|
|
44
|
+
Workflow: "#b794f4",
|
|
45
|
+
};
|
|
46
|
+
const colorFor = (lbl) => LABEL_COLOR[lbl] ?? "#8b95b8";
|
|
47
|
+
const shortName = (qn) => {
|
|
48
|
+
const i = qn.indexOf(":");
|
|
49
|
+
return i >= 0 ? qn.slice(i + 1) : qn;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/* ── galactic cluster centres. Each label gets its own region of 3D space.
|
|
53
|
+
* Initial node positions are seeded near these centres + jitter; d3-force
|
|
54
|
+
* then relaxes within the cluster. Coordinates are tuned to feel like a
|
|
55
|
+
* sparse galaxy at default camera distance (~500 units). ── */
|
|
56
|
+
// Cluster centres in a tighter ±180 cube — keeps the whole graph inside the
|
|
57
|
+
// camera's natural framing even with 1500 nodes. Wider spread caused the
|
|
58
|
+
// layout to balloon outward and zoomToFit had to pull the camera so far
|
|
59
|
+
// back everything became unreadable specks.
|
|
60
|
+
const CLUSTER_CENTERS = {
|
|
61
|
+
ApexClass: { x: -180, y: 30, z: 0 },
|
|
62
|
+
ApexTrigger: { x: -170, y: 90, z: -40 },
|
|
63
|
+
ApexMethod: { x: -110, y: 0, z: -20 },
|
|
64
|
+
TestMethod: { x: -140, y: -80, z: 30 },
|
|
65
|
+
ApexPage: { x: -200, y: -20, z: 40 },
|
|
66
|
+
LightningComponentBundle: { x: 180, y: 40, z: 30 },
|
|
67
|
+
LWC: { x: 180, y: 40, z: 30 },
|
|
68
|
+
LWCBundle: { x: 180, y: 40, z: 30 },
|
|
69
|
+
AuraDefinitionBundle: { x: 200, y: -20, z: 50 },
|
|
70
|
+
Flow: { x: 0, y: 170, z: -50 },
|
|
71
|
+
FlowVersion: { x: 40, y: 170, z: -20 },
|
|
72
|
+
CustomObject: { x: 0, y: -170, z: 60 },
|
|
73
|
+
CustomField: { x: 45, y: -160, z: 30 },
|
|
74
|
+
Profile: { x: -180, y: -120, z: -100 },
|
|
75
|
+
PermissionSet: { x: -160, y: -140, z: -80 },
|
|
76
|
+
PermissionSetGroup: { x: -150, y: -120, z: -120 },
|
|
77
|
+
SharingRule: { x: -190, y: -90, z: -60 },
|
|
78
|
+
NamedCredential: { x: 180, y: -110, z: -90 },
|
|
79
|
+
ExternalServiceRegistration: { x: 200, y: -90, z: -120 },
|
|
80
|
+
StaticResource: { x: 100, y: 100, z: 120 },
|
|
81
|
+
CustomLabel: { x: 50, y: 50, z: 140 },
|
|
82
|
+
Workflow: { x: -50, y: 150, z: -100 },
|
|
83
|
+
};
|
|
84
|
+
const ORPHAN_CENTER = { x: 0, y: 0, z: 0 };
|
|
85
|
+
const jitter = (n = 50) => (Math.random() - 0.5) * n;
|
|
86
|
+
|
|
87
|
+
/* ── api ── */
|
|
88
|
+
const api = async (p) => {
|
|
89
|
+
const r = await fetch(p);
|
|
90
|
+
if (!r.ok) throw new Error(`${p}: ${r.status} ${await r.text()}`);
|
|
91
|
+
return r.json();
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/* ── state ── */
|
|
95
|
+
let currentOrgId = "";
|
|
96
|
+
let relTypes = [];
|
|
97
|
+
const labelOptions = [
|
|
98
|
+
"ApexClass",
|
|
99
|
+
"ApexTrigger",
|
|
100
|
+
"ApexMethod",
|
|
101
|
+
"LightningComponentBundle",
|
|
102
|
+
"LWC",
|
|
103
|
+
"LWCBundle",
|
|
104
|
+
"Flow",
|
|
105
|
+
"FlowVersion",
|
|
106
|
+
"CustomObject",
|
|
107
|
+
"CustomField",
|
|
108
|
+
"Profile",
|
|
109
|
+
"PermissionSet",
|
|
110
|
+
"NamedCredential",
|
|
111
|
+
];
|
|
112
|
+
let hoveredNode = null;
|
|
113
|
+
let inspectorNodeId = null;
|
|
114
|
+
let alwaysShowLabels = false;
|
|
115
|
+
/** Degree threshold above which a node is considered a "hub" and gets its
|
|
116
|
+
* label permanently visible regardless of camera distance. Recomputed in
|
|
117
|
+
* setData() from the top ~5% of the degree distribution. */
|
|
118
|
+
let hubDegreeThreshold = Infinity;
|
|
119
|
+
/** id -> { group, core, halo, label, _id, _isHub } — populated in
|
|
120
|
+
* nodeThreeObject so the per-frame label updater never has to depend on
|
|
121
|
+
* 3d-force-graph's internal `__threeObj` property (which has renamed
|
|
122
|
+
* between versions). */
|
|
123
|
+
const nodeRegistry = new Map();
|
|
124
|
+
|
|
125
|
+
/* ── label sprite factory — canvas-textured Sprite so labels billboard to
|
|
126
|
+
* the camera and stay legible from any angle ── */
|
|
127
|
+
function makeLabelSprite(text, color) {
|
|
128
|
+
const canvas = document.createElement("canvas");
|
|
129
|
+
const ctx = canvas.getContext("2d");
|
|
130
|
+
// Render at 2x for crispness on retina.
|
|
131
|
+
const fontPx = 36;
|
|
132
|
+
const padX = 18;
|
|
133
|
+
const padY = 10;
|
|
134
|
+
ctx.font = `500 ${fontPx}px "JetBrains Mono", ui-monospace, monospace`;
|
|
135
|
+
const textW = Math.ceil(ctx.measureText(text).width);
|
|
136
|
+
canvas.width = textW + padX * 2;
|
|
137
|
+
canvas.height = fontPx + padY * 2;
|
|
138
|
+
// Re-set font (canvas resize resets context state).
|
|
139
|
+
ctx.font = `500 ${fontPx}px "JetBrains Mono", ui-monospace, monospace`;
|
|
140
|
+
// Dark capsule background.
|
|
141
|
+
ctx.fillStyle = "rgba(8, 12, 24, 0.85)";
|
|
142
|
+
const r = 10;
|
|
143
|
+
const w = canvas.width;
|
|
144
|
+
const h = canvas.height;
|
|
145
|
+
ctx.beginPath();
|
|
146
|
+
ctx.moveTo(r, 0);
|
|
147
|
+
ctx.lineTo(w - r, 0);
|
|
148
|
+
ctx.quadraticCurveTo(w, 0, w, r);
|
|
149
|
+
ctx.lineTo(w, h - r);
|
|
150
|
+
ctx.quadraticCurveTo(w, h, w - r, h);
|
|
151
|
+
ctx.lineTo(r, h);
|
|
152
|
+
ctx.quadraticCurveTo(0, h, 0, h - r);
|
|
153
|
+
ctx.lineTo(0, r);
|
|
154
|
+
ctx.quadraticCurveTo(0, 0, r, 0);
|
|
155
|
+
ctx.closePath();
|
|
156
|
+
ctx.fill();
|
|
157
|
+
// Subtle inner stroke matching node colour.
|
|
158
|
+
ctx.strokeStyle = `${color}44`;
|
|
159
|
+
ctx.lineWidth = 1.5;
|
|
160
|
+
ctx.stroke();
|
|
161
|
+
// Text.
|
|
162
|
+
ctx.fillStyle = color;
|
|
163
|
+
ctx.textBaseline = "middle";
|
|
164
|
+
ctx.fillText(text, padX, h / 2);
|
|
165
|
+
|
|
166
|
+
const texture = new THREE.CanvasTexture(canvas);
|
|
167
|
+
texture.anisotropy = 4;
|
|
168
|
+
texture.minFilter = THREE.LinearFilter;
|
|
169
|
+
texture.needsUpdate = true;
|
|
170
|
+
const material = new THREE.SpriteMaterial({
|
|
171
|
+
map: texture,
|
|
172
|
+
transparent: true,
|
|
173
|
+
depthWrite: false,
|
|
174
|
+
opacity: 0,
|
|
175
|
+
});
|
|
176
|
+
const sprite = new THREE.Sprite(material);
|
|
177
|
+
// 1 canvas pixel ≈ 0.18 world units. Tweak if labels feel huge/tiny.
|
|
178
|
+
sprite.scale.set(canvas.width * 0.18, canvas.height * 0.18, 1);
|
|
179
|
+
sprite.position.set(0, 14, 0); // hover above node
|
|
180
|
+
sprite.userData.isLabel = true;
|
|
181
|
+
sprite.userData.color = color;
|
|
182
|
+
return sprite;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/* ── 3d-force-graph singleton ── */
|
|
186
|
+
const graphEl = document.getElementById("graph");
|
|
187
|
+
const Graph = ForceGraph3D({ controlType: "orbit" })(graphEl)
|
|
188
|
+
.backgroundColor("rgba(0,0,0,0)")
|
|
189
|
+
.showNavInfo(false)
|
|
190
|
+
.nodeRelSize(5)
|
|
191
|
+
.nodeOpacity(1)
|
|
192
|
+
.nodeResolution(18)
|
|
193
|
+
.nodeColor((n) => colorFor(n.label))
|
|
194
|
+
.nodeLabel(() => "") // disable HTML tooltip — we have 3D sprite labels
|
|
195
|
+
.linkColor(() => "rgba(168, 197, 255, 0.22)")
|
|
196
|
+
.linkWidth(0.7)
|
|
197
|
+
.linkOpacity(0.55)
|
|
198
|
+
.linkDirectionalParticles(2)
|
|
199
|
+
.linkDirectionalParticleSpeed(0.0042)
|
|
200
|
+
.linkDirectionalParticleWidth(1.6)
|
|
201
|
+
.linkDirectionalParticleColor(() => "#5eecff")
|
|
202
|
+
.linkDirectionalArrowLength(2.4)
|
|
203
|
+
.linkDirectionalArrowRelPos(0.94)
|
|
204
|
+
.linkDirectionalArrowColor(() => "rgba(94, 236, 255, 0.55)")
|
|
205
|
+
.onNodeHover((n) => {
|
|
206
|
+
graphEl.style.cursor = n ? "pointer" : "grab";
|
|
207
|
+
hoveredNode = n;
|
|
208
|
+
})
|
|
209
|
+
.cooldownTicks(120) // simulation stops settling after this many ticks
|
|
210
|
+
.warmupTicks(20); // pre-bake some ticks before the first frame
|
|
211
|
+
|
|
212
|
+
// Weaken the default charge so 1500 disconnected nodes don't fly off into a
|
|
213
|
+
// vast sphere the camera can't frame. Add a stronger centering force so the
|
|
214
|
+
// whole graph stays roughly inside the cluster cube.
|
|
215
|
+
try {
|
|
216
|
+
Graph.d3Force("charge").strength(-18); // default ~ -30
|
|
217
|
+
Graph.d3Force("center").strength(0.4); // default 0.1
|
|
218
|
+
} catch {
|
|
219
|
+
/* d3-force API not available — accept defaults */
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/* size scaling: every node has a visible minimum size even when zoomed
|
|
223
|
+
* out; hubs get an extra log-scaled bump so the eye can pick them out as
|
|
224
|
+
* stars amongst planets. */
|
|
225
|
+
const baseRadius = (n) => 4 + Math.min(9, Math.log2(1 + (n.degree ?? 1)) * 2);
|
|
226
|
+
|
|
227
|
+
Graph.nodeThreeObject((n) => {
|
|
228
|
+
const colorHex = colorFor(n.label);
|
|
229
|
+
const c = new THREE.Color(colorHex);
|
|
230
|
+
const group = new THREE.Group();
|
|
231
|
+
|
|
232
|
+
const radius = baseRadius(n);
|
|
233
|
+
const geo = new THREE.SphereGeometry(radius, 22, 22);
|
|
234
|
+
const mat = new THREE.MeshStandardMaterial({
|
|
235
|
+
color: c,
|
|
236
|
+
emissive: c,
|
|
237
|
+
emissiveIntensity: 1.05,
|
|
238
|
+
roughness: 0.35,
|
|
239
|
+
metalness: 0.15,
|
|
240
|
+
});
|
|
241
|
+
const mesh = new THREE.Mesh(geo, mat);
|
|
242
|
+
mesh.userData.isCore = true;
|
|
243
|
+
group.add(mesh);
|
|
244
|
+
|
|
245
|
+
// Soft additive halo — much of the "glow" effect comes from this since
|
|
246
|
+
// we don't run a bloom post-pass.
|
|
247
|
+
const haloMat = new THREE.SpriteMaterial({
|
|
248
|
+
map: haloTexture(colorHex),
|
|
249
|
+
color: c,
|
|
250
|
+
transparent: true,
|
|
251
|
+
depthWrite: false,
|
|
252
|
+
blending: THREE.AdditiveBlending,
|
|
253
|
+
opacity: 0.6,
|
|
254
|
+
});
|
|
255
|
+
const halo = new THREE.Sprite(haloMat);
|
|
256
|
+
const haloSize = radius * 7;
|
|
257
|
+
halo.scale.set(haloSize, haloSize, 1);
|
|
258
|
+
halo.userData.isHalo = true;
|
|
259
|
+
group.add(halo);
|
|
260
|
+
|
|
261
|
+
// Text label sprite — opacity driven per-frame by camera distance.
|
|
262
|
+
const lbl = makeLabelSprite(shortName(n.id), colorHex);
|
|
263
|
+
lbl.position.y = radius + 11;
|
|
264
|
+
group.add(lbl);
|
|
265
|
+
|
|
266
|
+
// Track for the label updater. Cleared in setData() when a new graph
|
|
267
|
+
// loads so stale entries don't linger. `_isHub` lets the per-frame loop
|
|
268
|
+
// force-show the label for high-degree nodes (galactic-core stars in
|
|
269
|
+
// the metaphor) without recomputing the threshold every frame.
|
|
270
|
+
nodeRegistry.set(n.id, {
|
|
271
|
+
_id: n.id,
|
|
272
|
+
_isHub: (n.degree ?? 0) >= hubDegreeThreshold,
|
|
273
|
+
group,
|
|
274
|
+
core: mesh,
|
|
275
|
+
halo,
|
|
276
|
+
label: lbl,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
return group;
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
/* halo texture — a radial gradient on a canvas, cached per colour ── */
|
|
283
|
+
const HALO_CACHE = new Map();
|
|
284
|
+
function haloTexture(colorHex) {
|
|
285
|
+
if (HALO_CACHE.has(colorHex)) return HALO_CACHE.get(colorHex);
|
|
286
|
+
const size = 128;
|
|
287
|
+
const canvas = document.createElement("canvas");
|
|
288
|
+
canvas.width = canvas.height = size;
|
|
289
|
+
const ctx = canvas.getContext("2d");
|
|
290
|
+
const g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
|
|
291
|
+
g.addColorStop(0, "rgba(255,255,255,0.85)");
|
|
292
|
+
g.addColorStop(0.25, `${colorHex}cc`);
|
|
293
|
+
g.addColorStop(0.6, `${colorHex}33`);
|
|
294
|
+
g.addColorStop(1, "rgba(0,0,0,0)");
|
|
295
|
+
ctx.fillStyle = g;
|
|
296
|
+
ctx.fillRect(0, 0, size, size);
|
|
297
|
+
const tex = new THREE.CanvasTexture(canvas);
|
|
298
|
+
tex.needsUpdate = true;
|
|
299
|
+
HALO_CACHE.set(colorHex, tex);
|
|
300
|
+
return tex;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/* ── scene: lighting, fog, starfield, bloom ── */
|
|
304
|
+
const scene = Graph.scene();
|
|
305
|
+
// Bump ambient + add accent point lights so the spheres pop.
|
|
306
|
+
for (const c of scene.children) if (c.isAmbientLight) c.intensity = 0.55;
|
|
307
|
+
scene.add(makeLight(0xffffff, 0.55, [200, 300, 200]));
|
|
308
|
+
scene.add(makePointLight(0x5eecff, 1.4, 1200, [0, 0, 0]));
|
|
309
|
+
scene.add(makePointLight(0xff5ec8, 0.5, 1500, [-400, -300, -400]));
|
|
310
|
+
|
|
311
|
+
// Soft depth haze far in the distance. Earlier values (far=2400) were
|
|
312
|
+
// killing the entire scene when the user scrolled out — every node ended
|
|
313
|
+
// up past the fog plane and the canvas blacked out. With far=9000 the
|
|
314
|
+
// fog only affects the starfield shell, never the data.
|
|
315
|
+
scene.fog = new THREE.Fog(0x05070d, 2500, 9000);
|
|
316
|
+
|
|
317
|
+
// Starfield backdrop — 3000 points on a sphere shell beyond the data.
|
|
318
|
+
// Pushed to radius 6000 so the user can scroll out a long way without
|
|
319
|
+
// flying through the shell.
|
|
320
|
+
addStarfield(scene, { count: 3000, radius: 6000 });
|
|
321
|
+
|
|
322
|
+
function makeLight(color, intensity, pos) {
|
|
323
|
+
const l = new THREE.DirectionalLight(color, intensity);
|
|
324
|
+
l.position.set(...pos);
|
|
325
|
+
return l;
|
|
326
|
+
}
|
|
327
|
+
function makePointLight(color, intensity, range, pos) {
|
|
328
|
+
const l = new THREE.PointLight(color, intensity, range);
|
|
329
|
+
l.position.set(...pos);
|
|
330
|
+
return l;
|
|
331
|
+
}
|
|
332
|
+
function addStarfield(scene, { count, radius }) {
|
|
333
|
+
const geo = new THREE.BufferGeometry();
|
|
334
|
+
const positions = new Float32Array(count * 3);
|
|
335
|
+
for (let i = 0; i < count; i++) {
|
|
336
|
+
// Point on a sphere via spherical → cartesian.
|
|
337
|
+
const u = Math.random() * 2 - 1;
|
|
338
|
+
const t = Math.random() * Math.PI * 2;
|
|
339
|
+
const r = radius * (0.9 + Math.random() * 0.1);
|
|
340
|
+
const s = Math.sqrt(1 - u * u);
|
|
341
|
+
positions[i * 3] = r * s * Math.cos(t);
|
|
342
|
+
positions[i * 3 + 1] = r * s * Math.sin(t);
|
|
343
|
+
positions[i * 3 + 2] = r * u;
|
|
344
|
+
}
|
|
345
|
+
geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
|
346
|
+
const mat = new THREE.PointsMaterial({
|
|
347
|
+
color: 0xa8c5ff,
|
|
348
|
+
size: 1.6,
|
|
349
|
+
sizeAttenuation: true,
|
|
350
|
+
transparent: true,
|
|
351
|
+
opacity: 0.65,
|
|
352
|
+
depthWrite: false,
|
|
353
|
+
});
|
|
354
|
+
const points = new THREE.Points(geo, mat);
|
|
355
|
+
// Stars don't move with the simulation.
|
|
356
|
+
points.userData.skipLayout = true;
|
|
357
|
+
scene.add(points);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// (no post-processing — see import note at top)
|
|
361
|
+
|
|
362
|
+
/* ── orbit controls: smoother rotate/pan/zoom ── */
|
|
363
|
+
const controls = Graph.controls();
|
|
364
|
+
if (controls) {
|
|
365
|
+
controls.enableDamping = true;
|
|
366
|
+
controls.dampingFactor = 0.08;
|
|
367
|
+
controls.rotateSpeed = 0.55;
|
|
368
|
+
controls.panSpeed = 0.85;
|
|
369
|
+
controls.zoomSpeed = 0.85;
|
|
370
|
+
controls.enablePan = true;
|
|
371
|
+
// Mac trackpad: two-finger drag = pan, pinch = zoom. Mouse: right-drag = pan.
|
|
372
|
+
controls.screenSpacePanning = true;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/* ── resize ── */
|
|
376
|
+
const resize = () => Graph.width(graphEl.clientWidth).height(graphEl.clientHeight);
|
|
377
|
+
new ResizeObserver(resize).observe(graphEl);
|
|
378
|
+
resize();
|
|
379
|
+
|
|
380
|
+
/* ── label visibility: top-N nearest + always-show overrides ──
|
|
381
|
+
*
|
|
382
|
+
* The old purely-distance approach used fixed world-units thresholds. That
|
|
383
|
+
* worked when the camera was close, but on a 1500-node graph the user
|
|
384
|
+
* zooms out to see everything — every node falls outside the threshold
|
|
385
|
+
* and ALL labels disappear at once. The screen goes label-free exactly
|
|
386
|
+
* when the user needs anchor points the most.
|
|
387
|
+
*
|
|
388
|
+
* New rule: at any moment, show labels for
|
|
389
|
+
* - the `TOP_LABEL_COUNT` nodes nearest the camera (fades by rank)
|
|
390
|
+
* - any "hub" node (top ~5% by degree — set in setData)
|
|
391
|
+
* - the hovered node
|
|
392
|
+
* - the node currently in the inspector
|
|
393
|
+
* - everything, if the user pressed `L` (alwaysShowLabels)
|
|
394
|
+
*
|
|
395
|
+
* This keeps a stable ~30 readable anchor labels visible at every zoom
|
|
396
|
+
* level. As you scroll in, the set rotates to favour what's actually
|
|
397
|
+
* under your nose.
|
|
398
|
+
*/
|
|
399
|
+
const TOP_LABEL_COUNT = 35;
|
|
400
|
+
function tickLabels() {
|
|
401
|
+
const cam = Graph.camera();
|
|
402
|
+
const visible = [];
|
|
403
|
+
for (const entry of nodeRegistry.values()) {
|
|
404
|
+
const { group, label } = entry;
|
|
405
|
+
if (!group || !group.parent || !label) continue;
|
|
406
|
+
const dx = cam.position.x - group.position.x;
|
|
407
|
+
const dy = cam.position.y - group.position.y;
|
|
408
|
+
const dz = cam.position.z - group.position.z;
|
|
409
|
+
entry._dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
410
|
+
visible.push(entry);
|
|
411
|
+
}
|
|
412
|
+
// Sort by camera distance ascending; closest first.
|
|
413
|
+
visible.sort((a, b) => a._dist - b._dist);
|
|
414
|
+
|
|
415
|
+
for (let i = 0; i < visible.length; i++) {
|
|
416
|
+
const e = visible[i];
|
|
417
|
+
const isHovered = hoveredNode && hoveredNode.id === e._id;
|
|
418
|
+
const isInspecting = inspectorNodeId && inspectorNodeId === e._id;
|
|
419
|
+
const isHub = e._isHub;
|
|
420
|
+
const inTopN = i < TOP_LABEL_COUNT;
|
|
421
|
+
|
|
422
|
+
let opacity;
|
|
423
|
+
if (alwaysShowLabels || isHovered || isInspecting || isHub) {
|
|
424
|
+
opacity = 1;
|
|
425
|
+
} else if (inTopN) {
|
|
426
|
+
// Fade from 1.0 at the nearest down to 0.55 at the cutoff — soft
|
|
427
|
+
// boundary so the ring of just-out-of-frame labels doesn't visibly
|
|
428
|
+
// snap on/off as the camera moves.
|
|
429
|
+
opacity = 1 - (i / TOP_LABEL_COUNT) * 0.45;
|
|
430
|
+
} else {
|
|
431
|
+
opacity = 0;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (e.label) {
|
|
435
|
+
e.label.material.opacity = opacity;
|
|
436
|
+
e.label.visible = opacity > 0.02;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (isHovered || isInspecting) {
|
|
440
|
+
if (e.core) e.core.scale.setScalar(1.3);
|
|
441
|
+
if (e.halo) e.halo.material.opacity = 0.9;
|
|
442
|
+
} else {
|
|
443
|
+
if (e.core) e.core.scale.setScalar(1);
|
|
444
|
+
if (e.halo) e.halo.material.opacity = 0.6;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
requestAnimationFrame(tickLabels);
|
|
448
|
+
}
|
|
449
|
+
tickLabels();
|
|
450
|
+
|
|
451
|
+
/* ── data ingestion ── */
|
|
452
|
+
function setData(payload, centerId = null) {
|
|
453
|
+
// Wipe the registry — old node entries are about to be garbage from the
|
|
454
|
+
// scene as 3d-force-graph replaces the graph data.
|
|
455
|
+
nodeRegistry.clear();
|
|
456
|
+
inspectorNodeId = null;
|
|
457
|
+
hubDegreeThreshold = Infinity; // reset; recomputed below
|
|
458
|
+
// De-dupe edges, compute degree.
|
|
459
|
+
const links = [];
|
|
460
|
+
const seen = new Set();
|
|
461
|
+
const degree = new Map();
|
|
462
|
+
for (const e of payload.edges) {
|
|
463
|
+
const k = `${e.source}→${e.target}|${e.relType}`;
|
|
464
|
+
if (seen.has(k)) continue;
|
|
465
|
+
seen.add(k);
|
|
466
|
+
links.push({ source: e.source, target: e.target, relType: e.relType });
|
|
467
|
+
degree.set(e.source, (degree.get(e.source) ?? 0) + 1);
|
|
468
|
+
degree.set(e.target, (degree.get(e.target) ?? 0) + 1);
|
|
469
|
+
}
|
|
470
|
+
// Top ~5% of nodes by degree are "hubs" — their labels stay on even at
|
|
471
|
+
// far zoom so the eye has anchor points across the whole graph.
|
|
472
|
+
const degreesSorted = [...degree.values()].sort((a, b) => b - a);
|
|
473
|
+
hubDegreeThreshold =
|
|
474
|
+
degreesSorted.length > 20
|
|
475
|
+
? (degreesSorted[Math.floor(degreesSorted.length * 0.05)] ?? Infinity)
|
|
476
|
+
: Infinity;
|
|
477
|
+
|
|
478
|
+
const nodes = payload.nodes.map((n) => {
|
|
479
|
+
const c = CLUSTER_CENTERS[n.label] ?? ORPHAN_CENTER;
|
|
480
|
+
return {
|
|
481
|
+
id: n.id,
|
|
482
|
+
label: n.label,
|
|
483
|
+
short: shortName(n.id),
|
|
484
|
+
degree: degree.get(n.id) ?? 0,
|
|
485
|
+
// Pre-seed initial positions inside the cluster — d3-force then relaxes
|
|
486
|
+
// within the local neighbourhood, preserving the cluster shape.
|
|
487
|
+
x: c.x + jitter(110),
|
|
488
|
+
y: c.y + jitter(110),
|
|
489
|
+
z: c.z + jitter(110),
|
|
490
|
+
};
|
|
491
|
+
});
|
|
492
|
+
Graph.graphData({ nodes, links });
|
|
493
|
+
document.getElementById("canvasHint").classList.toggle("hidden", nodes.length > 0);
|
|
494
|
+
document.getElementById("statNodes").textContent = nodes.length.toLocaleString();
|
|
495
|
+
document.getElementById("statEdges").textContent = links.length.toLocaleString();
|
|
496
|
+
document.getElementById("truncBadge").classList.toggle("hidden", !payload.truncated);
|
|
497
|
+
// Two-stage framing: first cheap fit at 1.5s once the simulation has
|
|
498
|
+
// done its initial relaxation, then a second pass at 3s after the
|
|
499
|
+
// cooldown ticks finish — by then positions are stable and the framing
|
|
500
|
+
// is final. Without the second pass on big data sets, the graph
|
|
501
|
+
// sometimes settles outside the initial frame and looks empty.
|
|
502
|
+
setTimeout(() => Graph.zoomToFit(800, 60), 1500);
|
|
503
|
+
setTimeout(() => Graph.zoomToFit(800, 60), 3000);
|
|
504
|
+
if (centerId) {
|
|
505
|
+
setTimeout(() => {
|
|
506
|
+
const n = Graph.graphData().nodes.find((x) => x.id === centerId);
|
|
507
|
+
if (n) flyTo(n, 140);
|
|
508
|
+
}, 2200);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/* ── camera fly-to ── */
|
|
513
|
+
function flyTo(node, distance = 100, durationMs = 1000) {
|
|
514
|
+
const r = Math.hypot(node.x ?? 0, node.y ?? 0, node.z ?? 0) || 1;
|
|
515
|
+
const scale = (r + distance) / r;
|
|
516
|
+
Graph.cameraPosition(
|
|
517
|
+
{
|
|
518
|
+
x: (node.x ?? 0) * scale,
|
|
519
|
+
y: (node.y ?? 0) * scale,
|
|
520
|
+
z: (node.z ?? 0) * scale,
|
|
521
|
+
},
|
|
522
|
+
{ x: node.x ?? 0, y: node.y ?? 0, z: node.z ?? 0 },
|
|
523
|
+
durationMs,
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/* ── click + double-click handling ──
|
|
528
|
+
*
|
|
529
|
+
* Single-click: open inspector + soft zoom.
|
|
530
|
+
* Double-click: fly in really close, no inspector spawn (you're already
|
|
531
|
+
* focused on it visually).
|
|
532
|
+
*/
|
|
533
|
+
let lastClickAt = 0;
|
|
534
|
+
let lastClickNode = null;
|
|
535
|
+
const DOUBLE_CLICK_MS = 320;
|
|
536
|
+
Graph.onNodeClick((node) => {
|
|
537
|
+
const now = Date.now();
|
|
538
|
+
if (lastClickNode === node && now - lastClickAt < DOUBLE_CLICK_MS) {
|
|
539
|
+
// Double-click: zoom way in.
|
|
540
|
+
flyTo(node, 35, 1100);
|
|
541
|
+
lastClickAt = 0;
|
|
542
|
+
lastClickNode = null;
|
|
543
|
+
} else {
|
|
544
|
+
flyTo(node, 110, 900);
|
|
545
|
+
showInspector(node);
|
|
546
|
+
lastClickAt = now;
|
|
547
|
+
lastClickNode = node;
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
/* ── bootstrap ── */
|
|
552
|
+
async function bootstrap() {
|
|
553
|
+
const orgsResp = await api("/api/orgs");
|
|
554
|
+
const orgs = Array.isArray(orgsResp) ? orgsResp : orgsResp.orgs ?? [];
|
|
555
|
+
const errors = (orgsResp && orgsResp.errors) || [];
|
|
556
|
+
const sel = document.getElementById("orgSel");
|
|
557
|
+
for (const o of orgs) {
|
|
558
|
+
const opt = document.createElement("option");
|
|
559
|
+
opt.value = o.orgId;
|
|
560
|
+
opt.textContent = `${o.alias} — ${o.nodeCount.toLocaleString()} nodes`;
|
|
561
|
+
opt.dataset.meta = `api v${o.apiVersion ?? "?"} · ${o.edgeCount.toLocaleString()} edges`;
|
|
562
|
+
sel.appendChild(opt);
|
|
563
|
+
}
|
|
564
|
+
sel.addEventListener("change", () => {
|
|
565
|
+
currentOrgId = sel.value;
|
|
566
|
+
const opt = sel.selectedOptions[0];
|
|
567
|
+
document.getElementById("orgMeta").textContent = opt?.dataset?.meta ?? "";
|
|
568
|
+
});
|
|
569
|
+
if (orgs.length === 1) {
|
|
570
|
+
sel.value = orgs[0].orgId;
|
|
571
|
+
sel.dispatchEvent(new Event("change"));
|
|
572
|
+
}
|
|
573
|
+
if (orgs.length === 0 && errors.length > 0) showOrgError(errors);
|
|
574
|
+
|
|
575
|
+
relTypes = await api("/api/rel-types");
|
|
576
|
+
const relList = document.getElementById("relList");
|
|
577
|
+
for (const r of relTypes) {
|
|
578
|
+
const lab = document.createElement("label");
|
|
579
|
+
const cb = document.createElement("input");
|
|
580
|
+
cb.type = "checkbox";
|
|
581
|
+
cb.value = r;
|
|
582
|
+
cb.checked = true;
|
|
583
|
+
cb.addEventListener("change", updateRelCount);
|
|
584
|
+
lab.append(cb, document.createTextNode(r));
|
|
585
|
+
relList.appendChild(lab);
|
|
586
|
+
}
|
|
587
|
+
updateRelCount();
|
|
588
|
+
|
|
589
|
+
const labList = document.getElementById("labelList");
|
|
590
|
+
const defaults = new Set(["ApexClass", "LightningComponentBundle", "Flow"]);
|
|
591
|
+
for (const l of labelOptions) {
|
|
592
|
+
const lab = document.createElement("label");
|
|
593
|
+
const cb = document.createElement("input");
|
|
594
|
+
cb.type = "checkbox";
|
|
595
|
+
cb.value = l;
|
|
596
|
+
cb.checked = defaults.has(l);
|
|
597
|
+
const dot = document.createElement("span");
|
|
598
|
+
dot.className = "dot";
|
|
599
|
+
dot.style.background = colorFor(l);
|
|
600
|
+
dot.style.color = colorFor(l);
|
|
601
|
+
lab.append(cb, dot, document.createTextNode(l));
|
|
602
|
+
labList.appendChild(lab);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Legend.
|
|
606
|
+
const legendList = document.getElementById("legendList");
|
|
607
|
+
const legendLabels = [
|
|
608
|
+
["Apex", "ApexClass"],
|
|
609
|
+
["LWC", "LightningComponentBundle"],
|
|
610
|
+
["Flow", "Flow"],
|
|
611
|
+
["Field", "CustomField"],
|
|
612
|
+
["Object", "CustomObject"],
|
|
613
|
+
["Profile/Perm", "Profile"],
|
|
614
|
+
["Cred", "NamedCredential"],
|
|
615
|
+
["Other", "Unknown"],
|
|
616
|
+
];
|
|
617
|
+
for (const [name, key] of legendLabels) {
|
|
618
|
+
const row = document.createElement("div");
|
|
619
|
+
row.className = "li";
|
|
620
|
+
const dot = document.createElement("span");
|
|
621
|
+
dot.className = "dot";
|
|
622
|
+
dot.style.background = colorFor(key);
|
|
623
|
+
dot.style.color = colorFor(key);
|
|
624
|
+
row.append(dot, document.createTextNode(name));
|
|
625
|
+
legendList.appendChild(row);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
setTabGlow(document.querySelector(".tab.active"));
|
|
629
|
+
window.addEventListener("resize", () => setTabGlow(document.querySelector(".tab.active")));
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function showOrgError(errors) {
|
|
633
|
+
const existing = document.getElementById("orgErrBanner");
|
|
634
|
+
if (existing) existing.remove();
|
|
635
|
+
const isAbi = errors.some((e) =>
|
|
636
|
+
/NODE_MODULE_VERSION|MODULE_NOT_FOUND|better-sqlite3|was compiled against/i.test(e.error),
|
|
637
|
+
);
|
|
638
|
+
const banner = document.createElement("div");
|
|
639
|
+
banner.id = "orgErrBanner";
|
|
640
|
+
banner.style.cssText = `
|
|
641
|
+
position: fixed; top: 96px; left: 50%; transform: translateX(-50%);
|
|
642
|
+
z-index: 12; max-width: 720px; padding: 18px 22px;
|
|
643
|
+
background: rgba(40, 10, 30, 0.85); backdrop-filter: blur(20px);
|
|
644
|
+
border: 1px solid rgba(255, 94, 200, 0.4);
|
|
645
|
+
border-radius: 12px; color: #ffd8eb;
|
|
646
|
+
box-shadow: 0 12px 40px rgba(0,0,0,0.6);
|
|
647
|
+
font: 13px/1.5 Inter, sans-serif;
|
|
648
|
+
`;
|
|
649
|
+
const isDataDir = errors.length === 1 && errors[0].orgId === "(data-dir)";
|
|
650
|
+
if (isDataDir) {
|
|
651
|
+
banner.innerHTML = `
|
|
652
|
+
<strong style="color:#ff5ec8;display:block;margin-bottom:4px;">No ingested orgs found</strong>
|
|
653
|
+
<span style="color:#aab4d4;">${errors[0].error}</span>
|
|
654
|
+
<div style="margin-top:10px;font-family:'JetBrains Mono',monospace;font-size:11px;color:#aab4d4;">
|
|
655
|
+
Run <span style="color:#5eecff;">sfgraph ingest --org <alias></span> from a shell first.
|
|
656
|
+
</div>`;
|
|
657
|
+
} else if (isAbi) {
|
|
658
|
+
banner.innerHTML = `
|
|
659
|
+
<strong style="color:#ff5ec8;display:block;margin-bottom:4px;">better-sqlite3 native binding mismatch</strong>
|
|
660
|
+
<span style="color:#aab4d4;">The binding was built for a different Node ABI than the one running <code>sfgraph serve</code>.</span>
|
|
661
|
+
<div style="margin-top:10px;font-family:'JetBrains Mono',monospace;font-size:11px;color:#aab4d4;">
|
|
662
|
+
Fix: <span style="color:#5eecff;">pnpm rebuild better-sqlite3</span> from the project root, then re-run.
|
|
663
|
+
</div>`;
|
|
664
|
+
} else {
|
|
665
|
+
banner.innerHTML = `
|
|
666
|
+
<strong style="color:#ff5ec8;display:block;margin-bottom:4px;">Failed to load ${errors.length} org${errors.length > 1 ? "s" : ""}</strong>
|
|
667
|
+
<pre style="white-space:pre-wrap;color:#ffb86b;font-size:11px;margin:6px 0 0;font-family:'JetBrains Mono',monospace;">${errors.map((e) => `${e.orgId}: ${e.error}`).join("\n")}</pre>`;
|
|
668
|
+
}
|
|
669
|
+
document.body.appendChild(banner);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function updateRelCount() {
|
|
673
|
+
const all = document.querySelectorAll("#relList input");
|
|
674
|
+
const on = document.querySelectorAll("#relList input:checked").length;
|
|
675
|
+
document.getElementById("relCount").textContent =
|
|
676
|
+
on === all.length ? "all" : `${on}/${all.length}`;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/* ── tabs ── */
|
|
680
|
+
function setTabGlow(activeTab) {
|
|
681
|
+
if (!activeTab) return;
|
|
682
|
+
const glow = document.getElementById("tabGlow");
|
|
683
|
+
const r = activeTab.getBoundingClientRect();
|
|
684
|
+
const parentR = activeTab.parentElement.getBoundingClientRect();
|
|
685
|
+
glow.style.left = `${r.left - parentR.left}px`;
|
|
686
|
+
glow.style.width = `${r.width}px`;
|
|
687
|
+
}
|
|
688
|
+
document.querySelectorAll(".tab").forEach((btn) => {
|
|
689
|
+
btn.addEventListener("click", () => {
|
|
690
|
+
document.querySelectorAll(".tab").forEach((b) => b.classList.remove("active"));
|
|
691
|
+
btn.classList.add("active");
|
|
692
|
+
setTabGlow(btn);
|
|
693
|
+
document.querySelectorAll(".ctrl-panel").forEach((p) => p.classList.remove("active"));
|
|
694
|
+
document.querySelector(`.ctrl-panel[data-ctrl="${btn.dataset.tab}"]`).classList.add("active");
|
|
695
|
+
});
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
/* ── drawer toggle — collapses the left controls panel and keeps the toggle
|
|
699
|
+
* visible at the viewport edge (it's a sibling, not a child, of the
|
|
700
|
+
* drawer). The .collapsed class is applied to BOTH so each can run its
|
|
701
|
+
* own transition. ── */
|
|
702
|
+
document.getElementById("drawerToggle").addEventListener("click", () => {
|
|
703
|
+
const drawer = document.getElementById("drawer");
|
|
704
|
+
const toggle = document.getElementById("drawerToggle");
|
|
705
|
+
drawer.classList.toggle("collapsed");
|
|
706
|
+
toggle.classList.toggle("collapsed");
|
|
707
|
+
// Canvas occupies full viewport already, but trigger resize anyway so any
|
|
708
|
+
// future layout-bound canvas math stays in sync with the transition end.
|
|
709
|
+
setTimeout(resize, 480);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
/* ── trace search ── */
|
|
713
|
+
const searchBox = document.getElementById("searchBox");
|
|
714
|
+
const autoEl = document.getElementById("autocomplete");
|
|
715
|
+
let autoIdx = -1;
|
|
716
|
+
let acHits = [];
|
|
717
|
+
let acTimer;
|
|
718
|
+
|
|
719
|
+
searchBox.addEventListener("input", () => {
|
|
720
|
+
clearTimeout(acTimer);
|
|
721
|
+
const q = searchBox.value.trim();
|
|
722
|
+
if (!currentOrgId || q.length < 2) {
|
|
723
|
+
autoEl.classList.remove("show");
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
acTimer = setTimeout(async () => {
|
|
727
|
+
try {
|
|
728
|
+
acHits = await api(`/api/search?org=${currentOrgId}&q=${encodeURIComponent(q)}&limit=20`);
|
|
729
|
+
renderAutocomplete();
|
|
730
|
+
} catch (e) {
|
|
731
|
+
console.error(e);
|
|
732
|
+
}
|
|
733
|
+
}, 150);
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
function renderAutocomplete() {
|
|
737
|
+
autoEl.innerHTML = "";
|
|
738
|
+
if (acHits.length === 0) {
|
|
739
|
+
autoEl.classList.remove("show");
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
acHits.forEach((h, i) => {
|
|
743
|
+
const li = document.createElement("li");
|
|
744
|
+
if (i === autoIdx) li.classList.add("active");
|
|
745
|
+
const dot = document.createElement("span");
|
|
746
|
+
dot.className = "node-dot";
|
|
747
|
+
dot.style.background = colorFor(h.label);
|
|
748
|
+
dot.style.color = colorFor(h.label);
|
|
749
|
+
li.append(dot, document.createTextNode(h.qname));
|
|
750
|
+
const lab = document.createElement("span");
|
|
751
|
+
lab.className = "lab";
|
|
752
|
+
lab.textContent = h.label;
|
|
753
|
+
li.appendChild(lab);
|
|
754
|
+
li.addEventListener("click", () => pickHit(h));
|
|
755
|
+
autoEl.appendChild(li);
|
|
756
|
+
});
|
|
757
|
+
autoEl.classList.add("show");
|
|
758
|
+
}
|
|
759
|
+
searchBox.addEventListener("keydown", (e) => {
|
|
760
|
+
if (!autoEl.classList.contains("show")) {
|
|
761
|
+
if (e.key === "Enter" && searchBox.value.trim()) renderTrace();
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
if (e.key === "ArrowDown") {
|
|
765
|
+
autoIdx = Math.min(autoIdx + 1, acHits.length - 1);
|
|
766
|
+
renderAutocomplete();
|
|
767
|
+
e.preventDefault();
|
|
768
|
+
} else if (e.key === "ArrowUp") {
|
|
769
|
+
autoIdx = Math.max(autoIdx - 1, 0);
|
|
770
|
+
renderAutocomplete();
|
|
771
|
+
e.preventDefault();
|
|
772
|
+
} else if (e.key === "Enter" && autoIdx >= 0) {
|
|
773
|
+
pickHit(acHits[autoIdx]);
|
|
774
|
+
e.preventDefault();
|
|
775
|
+
} else if (e.key === "Enter") {
|
|
776
|
+
autoEl.classList.remove("show");
|
|
777
|
+
renderTrace();
|
|
778
|
+
e.preventDefault();
|
|
779
|
+
} else if (e.key === "Escape") {
|
|
780
|
+
autoEl.classList.remove("show");
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
function pickHit(h) {
|
|
784
|
+
searchBox.value = h.qname;
|
|
785
|
+
autoEl.classList.remove("show");
|
|
786
|
+
autoIdx = -1;
|
|
787
|
+
renderTrace();
|
|
788
|
+
}
|
|
789
|
+
document.addEventListener("click", (e) => {
|
|
790
|
+
if (!autoEl.contains(e.target) && e.target !== searchBox) autoEl.classList.remove("show");
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
let depth = 2;
|
|
794
|
+
document.querySelectorAll("#depthGroup button").forEach((b) => {
|
|
795
|
+
b.addEventListener("click", () => {
|
|
796
|
+
document.querySelectorAll("#depthGroup button").forEach((x) => x.classList.remove("active"));
|
|
797
|
+
b.classList.add("active");
|
|
798
|
+
depth = Number(b.dataset.depth);
|
|
799
|
+
});
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
/* ── render commands ── */
|
|
803
|
+
async function renderTrace() {
|
|
804
|
+
if (!currentOrgId) return alert("pick an org first");
|
|
805
|
+
const qname = searchBox.value.trim();
|
|
806
|
+
if (!qname) return;
|
|
807
|
+
const enabled = [...document.querySelectorAll("#relList input:checked")].map((i) => i.value);
|
|
808
|
+
const allRels = document.querySelectorAll("#relList input");
|
|
809
|
+
const relsParam = enabled.length === allRels.length ? "" : `&rels=${enabled.join(",")}`;
|
|
810
|
+
setHint("traceHint", "querying…");
|
|
811
|
+
try {
|
|
812
|
+
const payload = await api(
|
|
813
|
+
`/api/neighborhood?org=${currentOrgId}&qname=${encodeURIComponent(qname)}&depth=${depth}${relsParam}`,
|
|
814
|
+
);
|
|
815
|
+
setData(payload, qname);
|
|
816
|
+
setHint(
|
|
817
|
+
"traceHint",
|
|
818
|
+
`${payload.nodes.length} nodes · ${payload.edges.length} edges${payload.truncated ? " · truncated" : ""}`,
|
|
819
|
+
);
|
|
820
|
+
} catch (e) {
|
|
821
|
+
setHint("traceHint", `error: ${e.message}`);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
document.getElementById("traceGo").addEventListener("click", renderTrace);
|
|
825
|
+
|
|
826
|
+
async function renderOverview() {
|
|
827
|
+
if (!currentOrgId) return alert("pick an org first");
|
|
828
|
+
const labels = [...document.querySelectorAll("#labelList input:checked")].map((i) => i.value);
|
|
829
|
+
if (labels.length === 0) return alert("pick at least one label");
|
|
830
|
+
const limit = document.getElementById("overviewLimit").value;
|
|
831
|
+
setHint("overviewHint", "querying…");
|
|
832
|
+
try {
|
|
833
|
+
const payload = await api(
|
|
834
|
+
`/api/overview?org=${currentOrgId}&labels=${labels.join(",")}&limit=${limit}`,
|
|
835
|
+
);
|
|
836
|
+
setData(payload);
|
|
837
|
+
setHint(
|
|
838
|
+
"overviewHint",
|
|
839
|
+
`${payload.nodes.length} nodes · ${payload.edges.length} edges${payload.truncated ? " · truncated" : ""}`,
|
|
840
|
+
);
|
|
841
|
+
} catch (e) {
|
|
842
|
+
setHint("overviewHint", `error: ${e.message}`);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
document.getElementById("overviewGo").addEventListener("click", renderOverview);
|
|
846
|
+
|
|
847
|
+
async function renderSchema() {
|
|
848
|
+
if (!currentOrgId) return alert("pick an org first");
|
|
849
|
+
const limit = document.getElementById("schemaLimit").value;
|
|
850
|
+
setHint("schemaHint", "querying…");
|
|
851
|
+
try {
|
|
852
|
+
const payload = await api(`/api/schema?org=${currentOrgId}&limit=${limit}`);
|
|
853
|
+
setData(payload);
|
|
854
|
+
setHint(
|
|
855
|
+
"schemaHint",
|
|
856
|
+
`${payload.nodes.length} nodes · ${payload.edges.length} edges${payload.truncated ? " · truncated" : ""}`,
|
|
857
|
+
);
|
|
858
|
+
} catch (e) {
|
|
859
|
+
setHint("schemaHint", `error: ${e.message}`);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
document.getElementById("schemaGo").addEventListener("click", renderSchema);
|
|
863
|
+
function setHint(id, text) {
|
|
864
|
+
document.getElementById(id).textContent = text;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/* ── inspector ── */
|
|
868
|
+
async function showInspector(node) {
|
|
869
|
+
const id = node.id;
|
|
870
|
+
const lbl = node.label;
|
|
871
|
+
inspectorNodeId = id;
|
|
872
|
+
document.getElementById("inspName").textContent = id;
|
|
873
|
+
document.getElementById("inspLabel").textContent = lbl;
|
|
874
|
+
const box = document.getElementById("inspector");
|
|
875
|
+
// A fresh click should always reveal the full detail — auto-expand if the
|
|
876
|
+
// user had collapsed the inspector from a previous interaction.
|
|
877
|
+
box.classList.remove("hidden");
|
|
878
|
+
box.classList.remove("collapsed");
|
|
879
|
+
const body = document.getElementById("inspEdges");
|
|
880
|
+
body.innerHTML = `<p style="color:var(--fg-mute);font-size:11px;font-family:'JetBrains Mono',monospace;">loading edges…</p>`;
|
|
881
|
+
try {
|
|
882
|
+
const payload = await api(
|
|
883
|
+
`/api/neighborhood?org=${currentOrgId}&qname=${encodeURIComponent(id)}&depth=1`,
|
|
884
|
+
);
|
|
885
|
+
body.innerHTML = "";
|
|
886
|
+
const out = payload.edges.filter((e) => e.source === id);
|
|
887
|
+
const inn = payload.edges.filter((e) => e.target === id);
|
|
888
|
+
if (out.length) body.appendChild(edgeGroup("outgoing", out, "target"));
|
|
889
|
+
if (inn.length) body.appendChild(edgeGroup("incoming", inn, "source"));
|
|
890
|
+
if (!out.length && !inn.length) {
|
|
891
|
+
body.innerHTML = `<p style="color:var(--fg-mute);font-size:11px;">no edges</p>`;
|
|
892
|
+
}
|
|
893
|
+
} catch (e) {
|
|
894
|
+
body.innerHTML = `<p style="color:var(--magenta);font-size:11px;">${e.message}</p>`;
|
|
895
|
+
}
|
|
896
|
+
document.getElementById("recenter").onclick = () => {
|
|
897
|
+
document.querySelector('[data-tab="trace"]').click();
|
|
898
|
+
searchBox.value = id;
|
|
899
|
+
renderTrace();
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
function edgeGroup(title, list, dirKey) {
|
|
903
|
+
const g = document.createElement("div");
|
|
904
|
+
g.className = "edge-grp";
|
|
905
|
+
const h = document.createElement("h4");
|
|
906
|
+
h.append(document.createTextNode(title));
|
|
907
|
+
const c = document.createElement("span");
|
|
908
|
+
c.className = "count";
|
|
909
|
+
c.textContent = list.length;
|
|
910
|
+
h.appendChild(c);
|
|
911
|
+
g.appendChild(h);
|
|
912
|
+
const ul = document.createElement("ul");
|
|
913
|
+
for (const e of list.slice(0, 30)) {
|
|
914
|
+
const li = document.createElement("li");
|
|
915
|
+
const rel = document.createElement("span");
|
|
916
|
+
rel.className = "rel";
|
|
917
|
+
rel.textContent = e.relType;
|
|
918
|
+
li.append(rel, document.createTextNode(e[dirKey]));
|
|
919
|
+
ul.appendChild(li);
|
|
920
|
+
}
|
|
921
|
+
g.appendChild(ul);
|
|
922
|
+
return g;
|
|
923
|
+
}
|
|
924
|
+
/* ── inspector controls — collapse to a thin rail OR fully close. ──
|
|
925
|
+
*
|
|
926
|
+
* Collapsed: width shrinks to ~52px, body content hidden, label pill rotates
|
|
927
|
+
* vertically so context stays visible. Clicking anywhere on the collapsed
|
|
928
|
+
* rail (or the chevron) expands it back. Close (×) only visible when
|
|
929
|
+
* expanded.
|
|
930
|
+
*/
|
|
931
|
+
const inspector = document.getElementById("inspector");
|
|
932
|
+
document.getElementById("inspectorClose").addEventListener("click", (e) => {
|
|
933
|
+
e.stopPropagation();
|
|
934
|
+
inspector.classList.add("hidden");
|
|
935
|
+
inspector.classList.remove("collapsed");
|
|
936
|
+
inspectorNodeId = null;
|
|
937
|
+
});
|
|
938
|
+
document.getElementById("inspectorCollapse").addEventListener("click", (e) => {
|
|
939
|
+
e.stopPropagation();
|
|
940
|
+
inspector.classList.toggle("collapsed");
|
|
941
|
+
});
|
|
942
|
+
// Click anywhere on the collapsed rail to expand.
|
|
943
|
+
inspector.addEventListener("click", () => {
|
|
944
|
+
if (inspector.classList.contains("collapsed")) inspector.classList.remove("collapsed");
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
/* ── keyboard shortcuts ──
|
|
948
|
+
* L toggle "always show labels"
|
|
949
|
+
* F zoomToFit (reframe the whole graph)
|
|
950
|
+
* Escape close inspector / hide autocomplete
|
|
951
|
+
* (Skip if focus is inside an input so typing isn't hijacked.)
|
|
952
|
+
*/
|
|
953
|
+
window.addEventListener("keydown", (e) => {
|
|
954
|
+
const tag = e.target?.tagName;
|
|
955
|
+
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
|
956
|
+
if (e.key === "l" || e.key === "L") {
|
|
957
|
+
alwaysShowLabels = !alwaysShowLabels;
|
|
958
|
+
showShortcutToast(alwaysShowLabels ? "labels: always on" : "labels: distance-based");
|
|
959
|
+
} else if (e.key === "f" || e.key === "F") {
|
|
960
|
+
Graph.zoomToFit(800, 60);
|
|
961
|
+
showShortcutToast("fit to view");
|
|
962
|
+
} else if (e.key === "Escape") {
|
|
963
|
+
document.getElementById("inspector").classList.add("hidden");
|
|
964
|
+
document.getElementById("autocomplete").classList.remove("show");
|
|
965
|
+
inspectorNodeId = null;
|
|
966
|
+
}
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
/** Brief floating toast for keyboard-driven actions. */
|
|
970
|
+
let toastTimer;
|
|
971
|
+
function showShortcutToast(text) {
|
|
972
|
+
let el = document.getElementById("shortcutToast");
|
|
973
|
+
if (!el) {
|
|
974
|
+
el = document.createElement("div");
|
|
975
|
+
el.id = "shortcutToast";
|
|
976
|
+
el.style.cssText = `
|
|
977
|
+
position: fixed; bottom: 64px; left: 50%; transform: translateX(-50%);
|
|
978
|
+
background: rgba(8, 12, 24, 0.92); backdrop-filter: blur(14px);
|
|
979
|
+
border: 1px solid rgba(94, 236, 255, 0.35);
|
|
980
|
+
color: #5eecff; font: 500 12px/1 "JetBrains Mono", monospace;
|
|
981
|
+
letter-spacing: 0.4px; padding: 9px 14px; border-radius: 999px;
|
|
982
|
+
box-shadow: 0 6px 18px rgba(0,0,0,0.5);
|
|
983
|
+
pointer-events: none; opacity: 0;
|
|
984
|
+
transition: opacity 0.18s ease-out, transform 0.25s cubic-bezier(0.16,1,0.3,1);
|
|
985
|
+
z-index: 30;
|
|
986
|
+
`;
|
|
987
|
+
document.body.appendChild(el);
|
|
988
|
+
}
|
|
989
|
+
el.textContent = text;
|
|
990
|
+
el.style.opacity = "1";
|
|
991
|
+
el.style.transform = "translateX(-50%) translateY(0)";
|
|
992
|
+
clearTimeout(toastTimer);
|
|
993
|
+
toastTimer = setTimeout(() => {
|
|
994
|
+
el.style.opacity = "0";
|
|
995
|
+
el.style.transform = "translateX(-50%) translateY(8px)";
|
|
996
|
+
}, 1200);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
bootstrap().catch((e) => {
|
|
1000
|
+
document.body.innerHTML = `<pre style="padding:40px;color:#ff5ec8;font-family:'JetBrains Mono',monospace;">bootstrap failed:\n${e.message}</pre>`;
|
|
1001
|
+
});
|