Skip to content

Commit c20f866

Browse files
committed
feat(visualize): 3D orbit graph — rotatable with perspective depth
Upgrade the force-directed graph from 2D to 3D so it can be rotated: - nodes now carry a z coordinate; physics (repulsion, degree-normalized springs, origin pull, annealing, speed cap) all run in 3D - seed on a golden-angle sphere so 3D structure reads immediately - per-frame project(): yaw/pitch rotation + perspective projection, with near=larger discs and depth fog (far nodes dim/recede) - draw nodes far→near (painter's algorithm) so nearer ones occlude - drag the background to orbit (yaw/pitch); scroll still zooms; dragging a node moves it in the current view plane via unproject(); pick() is now screen-space so hover/click stay accurate at any angle - hover highlight + flow particles, click→glass panel, legend filter all preserved and verified in-browser on the real KB. Default zoom bumped (scale 1.35) so the sphere fills more of the canvas.
1 parent 0fab16e commit c20f866

1 file changed

Lines changed: 106 additions & 75 deletions

File tree

openkb/templates/graph.html

Lines changed: 106 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,8 @@
158158
<div class="legend" id="legend"></div>
159159

160160
<div class="hint">
161-
<kbd>scroll</kbd> zoom &nbsp; <kbd>drag bg</kbd> pan<br>
162-
<kbd>drag node</kbd> pin &nbsp; <kbd>click</kbd> inspect
161+
<kbd>scroll</kbd> zoom &nbsp; <kbd>drag bg</kbd> rotate<br>
162+
<kbd>drag node</kbd> move &nbsp; <kbd>click</kbd> inspect
163163
</div>
164164

165165
<div class="panel" id="panel"></div>
@@ -187,9 +187,10 @@
187187

188188
// build working nodes/edges from GRAPH (object-form edges: e.source/e.target)
189189
const nodes = GRAPH.nodes.map(n => ({
190-
...n, x:0, y:0, vx:0, vy:0,
190+
...n, x:0, y:0, z:0, vx:0, vy:0, vz:0,
191191
r: 8 + Math.min(10, (n.in || 0) + (n.out || 0)), // radius scales with degree
192-
alpha: 1 // per-node fade (legend toggle)
192+
alpha: 1, // per-node fade (legend toggle)
193+
sx:0, sy:0, rz:0, pp:1 // per-frame 3D projection cache
193194
}));
194195
const byId = Object.fromEntries(nodes.map(n => [n.id, n]));
195196
const edges = GRAPH.edges.filter(e => byId[e.source] && byId[e.target]);
@@ -211,8 +212,9 @@
211212
const visible = n => n.alpha > 0.02; // for physics/picking (fully-faded skipped)
212213

213214
let hover = null, drag = null, selected = null;
214-
let scale = 1, panX = 0, panY = 0;
215-
let panning = false, panStartX = 0, panStartY = 0, panOrigX = 0, panOrigY = 0;
215+
let scale = 1.35, panX = 0, panY = 0;
216+
let yaw = 0.5, pitch = -0.28; // 3D orbit angles — drag the background to rotate
217+
let orbiting = false, oStartX = 0, oStartY = 0, yawStart = 0, pitchStart = 0;
216218
let didDrag = false;
217219
let alpha = 1; // simulated-annealing "temperature"; cools to 0 so motion stops
218220

@@ -225,80 +227,106 @@
225227
canvas.height = H() * DPR;
226228
}
227229
function seed(){
228-
const cx = W()/2, cy = H()/2;
230+
const R = 250;
229231
nodes.forEach((n,i) => {
230-
const a = i / Math.max(1, nodes.length) * 6.28318;
231-
const rad = 150 + Math.random()*60;
232-
n.x = cx + Math.cos(a)*rad + (Math.random()-.5)*40;
233-
n.y = cy + Math.sin(a)*(rad*0.8) + (Math.random()-.5)*40;
234-
n.vx = 0; n.vy = 0;
232+
// spread nodes over a sphere (golden-angle spiral) so 3D structure shows from the start
233+
const phi = Math.acos(1 - 2*(i+0.5)/Math.max(1,nodes.length));
234+
const theta = 2.399963 * i;
235+
const rad = R * (0.55 + Math.random()*0.45);
236+
n.x = rad*Math.sin(phi)*Math.cos(theta);
237+
n.y = rad*Math.sin(phi)*Math.sin(theta);
238+
n.z = rad*Math.cos(phi);
239+
n.vx = n.vy = n.vz = 0;
235240
});
236241
}
237242

238243
/* ===================== physics (adapted from okf-spec.html step()) ===================== */
239244
const VMAX = 9; // per-frame speed cap → no violent swings, even on a dense hub
240245
function step(){
241-
const cx = W()/2, cy = H()/2;
242246
for(let i=0;i<nodes.length;i++){
243247
const a = nodes[i];
244248
if(!visible(a)) continue;
245249
for(let j=i+1;j<nodes.length;j++){
246250
const b = nodes[j];
247251
if(!visible(b)) continue;
248-
let dx = a.x-b.x, dy = a.y-b.y, d = Math.hypot(dx,dy) || 1;
252+
let dx = a.x-b.x, dy = a.y-b.y, dz = a.z-b.z, d = Math.hypot(dx,dy,dz) || 1;
249253
const dd = Math.max(d, 18); // floor distance so near-coincident nodes don't explode
250254
const f = 2600/(dd*dd) * alpha;
251-
a.vx += dx/d*f; a.vy += dy/d*f;
252-
b.vx -= dx/d*f; b.vy -= dy/d*f;
255+
a.vx += dx/d*f; a.vy += dy/d*f; a.vz += dz/d*f;
256+
b.vx -= dx/d*f; b.vy -= dy/d*f; b.vz -= dz/d*f;
253257
}
254-
a.vx += (cx-a.x)*0.0016*alpha; a.vy += (cy-a.y)*0.0016*alpha;
258+
a.vx += -a.x*0.0016*alpha; a.vy += -a.y*0.0016*alpha; a.vz += -a.z*0.0016*alpha; // pull toward origin
255259
}
256260
edges.forEach(e => {
257261
const a = byId[e.source], b = byId[e.target];
258262
if(!visible(a) || !visible(b)) return;
259-
let dx = b.x-a.x, dy = b.y-a.y, d = Math.hypot(dx,dy) || 1;
263+
let dx = b.x-a.x, dy = b.y-a.y, dz = b.z-a.z, d = Math.hypot(dx,dy,dz) || 1;
260264
// normalize the pull by degree: a hub with N springs shouldn't feel N× the force
261265
const s = 1 / Math.min(adj[e.source].length || 1, adj[e.target].length || 1);
262266
const f = (d-118)*0.012 * s * alpha;
263-
a.vx += dx/d*f; a.vy += dy/d*f;
264-
b.vx -= dx/d*f; b.vy -= dy/d*f;
267+
a.vx += dx/d*f; a.vy += dy/d*f; a.vz += dz/d*f;
268+
b.vx -= dx/d*f; b.vy -= dy/d*f; b.vz -= dz/d*f;
265269
});
266270
nodes.forEach(n => {
267271
// smoothly approach the legend-toggle target opacity
268272
const target = typeVisible(n.type) ? 1 : 0;
269273
n.alpha += (target - n.alpha) * 0.12;
270-
if(n === drag){ n.vx = n.vy = 0; return; } // pinned: position set by drag handler
271-
n.vx *= 0.84; n.vy *= 0.84; // stronger damping dissipates energy fast
274+
if(n === drag){ n.vx = n.vy = n.vz = 0; return; } // pinned: position set by drag handler
275+
n.vx *= 0.84; n.vy *= 0.84; n.vz *= 0.84; // stronger damping dissipates energy fast
272276
if(n.vx > VMAX) n.vx = VMAX; else if(n.vx < -VMAX) n.vx = -VMAX;
273277
if(n.vy > VMAX) n.vy = VMAX; else if(n.vy < -VMAX) n.vy = -VMAX;
274-
n.x += n.vx; n.y += n.vy;
278+
if(n.vz > VMAX) n.vz = VMAX; else if(n.vz < -VMAX) n.vz = -VMAX;
279+
n.x += n.vx; n.y += n.vy; n.z += n.vz;
275280
});
276281
alpha += (0 - alpha) * 0.02; // anneal: forces fade → the layout settles and stops
277282
}
278283

279-
/* ===================== zoom / pan transform ===================== */
280-
function applyView(){ ctx.setTransform(DPR*scale, 0, 0, DPR*scale, DPR*panX, DPR*panY); }
281-
function toWorld(px,py){ return { x:(px-panX)/scale, y:(py-panY)/scale }; }
282-
function centerOn(n){ panX = W()/2 - n.x*scale; panY = H()/2 - n.y*scale; }
284+
/* ===================== 3D orbit projection + perspective ===================== */
285+
const FOCAL = 900;
286+
function project(){
287+
const cyw = Math.cos(yaw), syw = Math.sin(yaw), cpt = Math.cos(pitch), spt = Math.sin(pitch);
288+
const ox = W()/2 + panX, oy = H()/2 + panY;
289+
for(const n of nodes){
290+
const x1 = n.x*cyw + n.z*syw; // rotate about Y (yaw)
291+
const z1 = -n.x*syw + n.z*cyw;
292+
const y2 = n.y*cpt - z1*spt; // then about X (pitch)
293+
const z2 = n.y*spt + z1*cpt;
294+
const pp = FOCAL/(FOCAL + z2); // perspective: near → larger
295+
n.rz = z2; n.pp = pp;
296+
n.sx = ox + x1*pp*scale;
297+
n.sy = oy + y2*pp*scale;
298+
}
299+
}
300+
// inverse of project for one node, holding its current depth — used while dragging
301+
function unproject(sx, sy, n){
302+
const cyw = Math.cos(yaw), syw = Math.sin(yaw), cpt = Math.cos(pitch), spt = Math.sin(pitch);
303+
const ox = W()/2 + panX, oy = H()/2 + panY;
304+
const pp = n.pp || 1, z2 = n.rz || 0;
305+
const x1 = (sx - ox)/(pp*scale);
306+
const y2 = (sy - oy)/(pp*scale);
307+
const y = y2*cpt + z2*spt; // invert pitch
308+
const z1 = -y2*spt + z2*cpt;
309+
return { x: x1*cyw - z1*syw, y, z: x1*syw + z1*cyw }; // invert yaw
310+
}
311+
function centerOn(n){ panX += (W()/2 - n.sx); panY += (H()/2 - n.sy); }
283312

284313
canvas.addEventListener("wheel", e => {
285314
e.preventDefault();
286315
const f = Math.exp(-e.deltaY * 0.0015);
287-
const ns = Math.max(0.25, Math.min(4, scale*f));
288-
const k = ns/scale;
289-
const mx = e.offsetX, my = e.offsetY;
290-
panX = mx - (mx-panX)*k; panY = my - (my-panY)*k;
291-
scale = ns;
316+
scale = Math.max(0.3, Math.min(4.5, scale*f)); // zoom about the screen centre
292317
}, {passive:false});
293318

294319
/* ===================== picking ===================== */
295-
function pick(wx,wy){
296-
for(let i=nodes.length-1;i>=0;i--){
297-
const n = nodes[i];
320+
function pick(sx, sy){
321+
let best = null, bestDepth = Infinity;
322+
for(const n of nodes){
298323
if(!visible(n)) continue;
299-
if(Math.hypot(n.x-wx, n.y-wy) < n.r + 6) return n;
324+
const rr = n.r*n.pp*scale + 6;
325+
if(Math.hypot(n.sx - sx, n.sy - sy) < rr && n.rz < bestDepth){
326+
best = n; bestDepth = n.rz; // nearest (smallest depth) wins where discs overlap
327+
}
300328
}
301-
return null;
329+
return best;
302330
}
303331

304332
/* ===================== rendering ===================== */
@@ -308,16 +336,18 @@
308336
if(al < 0.02) return;
309337
const hot = hover && (e.source===hover || e.target===hover);
310338
const dim = hover && !hot;
311-
ctx.globalAlpha = al;
312-
ctx.strokeStyle = hot ? "rgba(45,212,191,.85)" : (dim ? "rgba(255,255,255,.04)" : "rgba(255,255,255,.13)");
313-
ctx.lineWidth = (hot ? 2 : 1) / scale;
314-
ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke();
315-
// arrowhead at b, backed off by b's radius
316-
const ang = Math.atan2(b.y-a.y, b.x-a.x);
317-
const off = b.r + 4;
318-
const hx = b.x - Math.cos(ang)*off, hy = b.y - Math.sin(ang)*off;
319-
const ah = 8/Math.max(.6,scale);
320-
ctx.fillStyle = hot ? "rgba(45,212,191,.9)" : (dim ? "rgba(255,255,255,.04)" : "rgba(255,255,255,.22)");
339+
const ax=a.sx, ay=a.sy, bx=b.sx, by=b.sy;
340+
const fog = Math.max(0.2, Math.min(1, 1 - (a.rz+b.rz)/1300)); // far edges recede
341+
ctx.globalAlpha = al * (hot ? 1 : fog);
342+
ctx.strokeStyle = hot ? "rgba(45,212,191,.85)" : (dim ? "rgba(255,255,255,.035)" : "rgba(255,255,255,.12)");
343+
ctx.lineWidth = hot ? 2 : 1;
344+
ctx.beginPath(); ctx.moveTo(ax,ay); ctx.lineTo(bx,by); ctx.stroke();
345+
// arrowhead at b, backed off by b's projected radius
346+
const ang = Math.atan2(by-ay, bx-ax);
347+
const off = b.r*b.pp*scale + 4;
348+
const hx = bx - Math.cos(ang)*off, hy = by - Math.sin(ang)*off;
349+
const ah = 7;
350+
ctx.fillStyle = hot ? "rgba(45,212,191,.9)" : (dim ? "rgba(255,255,255,.035)" : "rgba(255,255,255,.2)");
321351
ctx.beginPath();
322352
ctx.moveTo(hx, hy);
323353
ctx.lineTo(hx - Math.cos(ang-.4)*ah, hy - Math.sin(ang-.4)*ah);
@@ -326,10 +356,10 @@
326356
// animated flow particle along highlighted edges
327357
if(hot){
328358
const k = (t*0.0006) % 1;
329-
const fx = a.x + (b.x-a.x)*k, fy = a.y + (b.y-a.y)*k;
359+
const fx = ax + (bx-ax)*k, fy = ay + (by-ay)*k;
330360
ctx.shadowColor = "rgba(45,212,191,.9)"; ctx.shadowBlur = 8;
331361
ctx.fillStyle = "rgba(170,255,240,.98)";
332-
ctx.beginPath(); ctx.arc(fx, fy, 2.6/Math.max(.6,scale), 0, 6.2832); ctx.fill();
362+
ctx.beginPath(); ctx.arc(fx, fy, 2.8, 0, 6.2832); ctx.fill();
333363
ctx.shadowBlur = 0;
334364
}
335365
ctx.globalAlpha = 1;
@@ -343,83 +373,84 @@
343373
// idle breathing for hubs (when nothing is hovered)
344374
const breathe = (!hover && hubIds.has(n.id)) ? (1 + 0.10*Math.sin(t*0.0024)) : 1;
345375
const pulse = (n.id===hover) ? (1.32 + 0.10*Math.sin(t*0.006)) : 1;
346-
const r = n.r * pulse * breathe;
347-
ctx.globalAlpha = n.alpha;
376+
const r = Math.max(2, n.r * pulse * breathe * n.pp * scale); // perspective: near = larger
377+
const fog = Math.max(0.32, Math.min(1, 1 - n.rz/640)); // depth cue: far nodes recede
378+
const x = n.sx, y = n.sy;
379+
ctx.globalAlpha = n.alpha * (faded ? 0.9 : fog);
348380
// selected ring
349381
if(n.id === selected){
350-
ctx.beginPath(); ctx.arc(n.x, n.y, r+6, 0, 6.2832);
351-
ctx.strokeStyle = `rgba(${col},.85)`; ctx.lineWidth = 2/scale; ctx.stroke();
382+
ctx.beginPath(); ctx.arc(x, y, r+6, 0, 6.2832);
383+
ctx.strokeStyle = `rgba(${col},.85)`; ctx.lineWidth = 2; ctx.stroke();
352384
}
353385
// hover halo
354386
if(n.id === hover){
355-
ctx.beginPath(); ctx.arc(n.x, n.y, r+11, 0, 6.2832);
387+
ctx.beginPath(); ctx.arc(x, y, r+11, 0, 6.2832);
356388
ctx.fillStyle = `rgba(${col},.16)`; ctx.fill();
357389
}
358390
// disc + glow
359-
ctx.beginPath(); ctx.arc(n.x, n.y, r, 0, 6.2832);
391+
ctx.beginPath(); ctx.arc(x, y, r, 0, 6.2832);
360392
ctx.fillStyle = `rgba(${col},${faded ? .25 : 1})`;
361393
ctx.shadowColor = `rgba(${col},.85)`;
362-
ctx.shadowBlur = (faded ? 0 : (n.id===hover ? 22 : 14)) / Math.max(.6, scale);
394+
ctx.shadowBlur = (faded ? 0 : (n.id===hover ? 22 : 14)) * fog;
363395
ctx.fill();
364396
ctx.shadowBlur = 0;
365397
// bright inner core
366398
if(!faded){
367-
ctx.beginPath(); ctx.arc(n.x-r*0.28, n.y-r*0.28, r*0.34, 0, 6.2832);
399+
ctx.beginPath(); ctx.arc(x-r*0.28, y-r*0.28, r*0.34, 0, 6.2832);
368400
ctx.fillStyle = "rgba(255,255,255,.55)"; ctx.fill();
369401
}
370-
// label — declutter: hubs at rest; hovered node + neighbors; selection; all when zoomed in
402+
// label — declutter: hubs at rest; hovered node + neighbors; selection; zoomed in; near depth only
371403
const showLabel = (scale > 1.4) || (n.id === selected) || (hover ? near : labelIds.has(n.id));
372-
if(showLabel){
404+
if(showLabel && fog > 0.55){
373405
ctx.fillStyle = faded ? "rgba(170,184,199,.3)" : "rgba(238,242,247,.95)";
374-
ctx.font = `600 ${11/Math.max(.7,scale)}px ui-monospace,monospace`;
406+
ctx.font = `600 11px ui-monospace,monospace`;
375407
ctx.textAlign = "center";
376408
ctx.shadowColor = "rgba(4,6,10,.92)"; ctx.shadowBlur = 4; // legibility over the edge web
377-
ctx.fillText(n.label, n.x, n.y + r + 13/Math.max(.7,scale));
409+
ctx.fillText(n.label, x, y + r + 12);
378410
ctx.shadowBlur = 0;
379411
}
380412
ctx.globalAlpha = 1;
381413
}
382414

383415
function frame(t){
384416
if(alpha > 0.005) step(); // once cooled, stop integrating — the graph holds still
417+
project(); // 3D → screen coords for this frame
385418
ctx.setTransform(DPR,0,0,DPR,0,0);
386419
ctx.clearRect(0,0,W(),H());
387-
applyView();
388420
edges.forEach(e => drawEdge(e, t));
389-
nodes.forEach(n => drawNode(n, t));
421+
// draw nodes far → near so nearer discs occlude farther ones (painter's algorithm)
422+
nodes.filter(visible).sort((a,b) => b.rz - a.rz).forEach(n => drawNode(n, t));
390423
requestAnimationFrame(frame);
391424
}
392425

393426
/* ===================== hover / drag / pan ===================== */
394427
canvas.addEventListener("mousemove", e => {
395-
const w = toWorld(e.offsetX, e.offsetY);
396428
if(drag){
397-
drag.x = w.x; drag.y = w.y; drag.vx = drag.vy = 0; didDrag = true;
429+
const w = unproject(e.offsetX, e.offsetY, drag); // move the node within the current view plane
430+
drag.x = w.x; drag.y = w.y; drag.z = w.z; drag.vx = drag.vy = drag.vz = 0; didDrag = true;
398431
alpha = Math.max(alpha, 0.5); // reheat so neighbors re-settle around the dragged node
399432
return;
400433
}
401-
if(panning){
402-
panX = panOrigX + (e.offsetX - panStartX);
403-
panY = panOrigY + (e.offsetY - panStartY);
434+
if(orbiting){
435+
yaw = yawStart + (e.offsetX - oStartX) * 0.01;
436+
pitch = Math.max(-1.45, Math.min(1.45, pitchStart - (e.offsetY - oStartY) * 0.01));
404437
didDrag = true; return;
405438
}
406-
const n = pick(w.x, w.y);
439+
const n = pick(e.offsetX, e.offsetY);
407440
hover = n ? n.id : null;
408-
canvas.style.cursor = n ? "grab" : "grab";
409441
});
410442
canvas.addEventListener("mousedown", e => {
411-
const w = toWorld(e.offsetX, e.offsetY);
412-
const n = pick(w.x, w.y);
443+
const n = pick(e.offsetX, e.offsetY);
413444
didDrag = false;
414445
if(n){ drag = n; hover = n.id; canvas.style.cursor = "grabbing"; }
415-
else { panning = true; panStartX = e.offsetX; panStartY = e.offsetY; panOrigX = panX; panOrigY = panY; }
446+
else { orbiting = true; oStartX = e.offsetX; oStartY = e.offsetY; yawStart = yaw; pitchStart = pitch; canvas.style.cursor = "grabbing"; }
416447
});
417448
window.addEventListener("mouseup", e => {
418449
if(drag && !didDrag){ openPanel(drag); } // click (no drag) on a node → inspect
419-
drag = null; panning = false;
450+
drag = null; orbiting = false;
420451
canvas.style.cursor = "grab";
421452
});
422-
canvas.addEventListener("mouseleave", () => { if(!drag && !panning) hover = null; });
453+
canvas.addEventListener("mouseleave", () => { if(!drag && !orbiting) hover = null; });
423454

424455
/* ===================== inspector panel ===================== */
425456
function esc(s){ return String(s == null ? "" : s).replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c])); }

0 commit comments

Comments
 (0)