@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.
@@ -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 &lt;alias&gt;</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
+ });