Skip to content

Commit 4e4d1f3

Browse files
committed
fix(visualize): gentler hover + slower orbit + deep zoom + pull-out pins
Address feedback that hovering flashed the scene and that exploring was hard: - smooth, eased highlight (per-node hl lerp) replaces the hard faded snap; non-neighbors dim gently (never to black) and the hover pulse/ glow are toned down — no more flicker - an open inspector now keeps its node's connections lit (focus = hover || selected), so you can study one node's links at leisure - orbit sensitivity halved (0.0045) — rotation is no longer twitchy - zoom range widened to 9x and anchored on the cursor, so you can drill into a local cluster - drag a node to pull it out; releasing pins it (dashed ring, won't spring back) so its links fan out for inspection; double-click releases - hint line updated to match
1 parent c20f866 commit 4e4d1f3

1 file changed

Lines changed: 47 additions & 26 deletions

File tree

openkb/templates/graph.html

Lines changed: 47 additions & 26 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> rotate<br>
162-
<kbd>drag node</kbd> move &nbsp; <kbd>click</kbd> inspect
161+
<kbd>scroll</kbd> zoom &nbsp; <kbd>drag bg</kbd> rotate &nbsp; <kbd>click</kbd> inspect<br>
162+
<kbd>drag node</kbd> pull out &amp; pin &nbsp; <kbd>dbl-click</kbd> release
163163
</div>
164164

165165
<div class="panel" id="panel"></div>
@@ -190,7 +190,7 @@
190190
...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
192192
alpha: 1, // per-node fade (legend toggle)
193-
sx:0, sy:0, rz:0, pp:1 // per-frame 3D projection cache
193+
sx:0, sy:0, rz:0, pp:1, hl:1, pinned:false // projection cache + smoothed highlight + pinned (drag to pull out)
194194
}));
195195
const byId = Object.fromEntries(nodes.map(n => [n.id, n]));
196196
const edges = GRAPH.edges.filter(e => byId[e.source] && byId[e.target]);
@@ -271,7 +271,7 @@
271271
// smoothly approach the legend-toggle target opacity
272272
const target = typeVisible(n.type) ? 1 : 0;
273273
n.alpha += (target - n.alpha) * 0.12;
274-
if(n === drag){ n.vx = n.vy = n.vz = 0; return; } // pinned: position set by drag handler
274+
if(n === drag || n.pinned){ n.vx = n.vy = n.vz = 0; return; } // dragged or pinned: held in place
275275
n.vx *= 0.84; n.vy *= 0.84; n.vz *= 0.84; // stronger damping dissipates energy fast
276276
if(n.vx > VMAX) n.vx = VMAX; else if(n.vx < -VMAX) n.vx = -VMAX;
277277
if(n.vy > VMAX) n.vy = VMAX; else if(n.vy < -VMAX) n.vy = -VMAX;
@@ -313,7 +313,13 @@
313313
canvas.addEventListener("wheel", e => {
314314
e.preventDefault();
315315
const f = Math.exp(-e.deltaY * 0.0015);
316-
scale = Math.max(0.3, Math.min(4.5, scale*f)); // zoom about the screen centre
316+
const ns = Math.max(0.3, Math.min(9, scale*f)); // wider range so you can dive deep in
317+
const k = ns/scale;
318+
// anchor zoom on the cursor → drill into whatever cluster you point at
319+
const dx = e.offsetX - W()/2, dy = e.offsetY - H()/2;
320+
panX = dx*(1-k) + panX*k;
321+
panY = dy*(1-k) + panY*k;
322+
scale = ns;
317323
}, {passive:false});
318324

319325
/* ===================== picking ===================== */
@@ -335,19 +341,19 @@
335341
const al = Math.min(a.alpha, b.alpha);
336342
if(al < 0.02) return;
337343
const hot = hover && (e.source===hover || e.target===hover);
338-
const dim = hover && !hot;
339344
const ax=a.sx, ay=a.sy, bx=b.sx, by=b.sy;
340345
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)");
346+
const ehl = Math.max(a.hl, b.hl); // edge eases with its endpoints (smooth dim)
347+
ctx.globalAlpha = al * fog * (hot ? 1 : (0.18 + 0.82*ehl));
348+
ctx.strokeStyle = hot ? "rgba(45,212,191,.85)" : `rgba(255,255,255,${0.06 + 0.07*ehl})`;
343349
ctx.lineWidth = hot ? 2 : 1;
344350
ctx.beginPath(); ctx.moveTo(ax,ay); ctx.lineTo(bx,by); ctx.stroke();
345351
// arrowhead at b, backed off by b's projected radius
346352
const ang = Math.atan2(by-ay, bx-ax);
347353
const off = b.r*b.pp*scale + 4;
348354
const hx = bx - Math.cos(ang)*off, hy = by - Math.sin(ang)*off;
349355
const ah = 7;
350-
ctx.fillStyle = hot ? "rgba(45,212,191,.9)" : (dim ? "rgba(255,255,255,.035)" : "rgba(255,255,255,.2)");
356+
ctx.fillStyle = hot ? "rgba(45,212,191,.9)" : `rgba(255,255,255,${0.08 + 0.12*ehl})`;
351357
ctx.beginPath();
352358
ctx.moveTo(hx, hy);
353359
ctx.lineTo(hx - Math.cos(ang-.4)*ah, hy - Math.sin(ang-.4)*ah);
@@ -368,41 +374,45 @@
368374
function drawNode(n, t){
369375
if(n.alpha < 0.02) return;
370376
const col = colorOf(n.type);
371-
const near = hover ? (n.id===hover || adj[hover].includes(n.id)) : true;
372-
const faded = hover && !near;
373-
// idle breathing for hubs (when nothing is hovered)
374-
const breathe = (!hover && hubIds.has(n.id)) ? (1 + 0.10*Math.sin(t*0.0024)) : 1;
375-
const pulse = (n.id===hover) ? (1.32 + 0.10*Math.sin(t*0.006)) : 1;
377+
const hl = n.hl; // smoothed highlight: 1 = lit; eases down (not snaps) when another node is hovered
378+
const breathe = (!hover && hubIds.has(n.id)) ? (1 + 0.05*Math.sin(t*0.0024)) : 1;
379+
const pulse = (n.id===hover) ? (1.16 + 0.03*Math.sin(t*0.006)) : 1;
376380
const r = Math.max(2, n.r * pulse * breathe * n.pp * scale); // perspective: near = larger
377381
const fog = Math.max(0.32, Math.min(1, 1 - n.rz/640)); // depth cue: far nodes recede
378382
const x = n.sx, y = n.sy;
379-
ctx.globalAlpha = n.alpha * (faded ? 0.9 : fog);
383+
ctx.globalAlpha = n.alpha * fog * (0.34 + 0.66*hl); // dim non-neighbors gently, never to black
380384
// selected ring
381385
if(n.id === selected){
382386
ctx.beginPath(); ctx.arc(x, y, r+6, 0, 6.2832);
383387
ctx.strokeStyle = `rgba(${col},.85)`; ctx.lineWidth = 2; ctx.stroke();
384388
}
389+
// pinned marker (dashed ring → "pulled out and held")
390+
if(n.pinned){
391+
ctx.beginPath(); ctx.arc(x, y, r+4, 0, 6.2832);
392+
ctx.strokeStyle = "rgba(255,255,255,.65)"; ctx.lineWidth = 1.5;
393+
ctx.setLineDash([3,3]); ctx.stroke(); ctx.setLineDash([]);
394+
}
385395
// hover halo
386396
if(n.id === hover){
387397
ctx.beginPath(); ctx.arc(x, y, r+11, 0, 6.2832);
388398
ctx.fillStyle = `rgba(${col},.16)`; ctx.fill();
389399
}
390-
// disc + glow
400+
// disc + glow (glow eases with highlight so hovering doesn't flash the whole scene)
391401
ctx.beginPath(); ctx.arc(x, y, r, 0, 6.2832);
392-
ctx.fillStyle = `rgba(${col},${faded ? .25 : 1})`;
393-
ctx.shadowColor = `rgba(${col},.85)`;
394-
ctx.shadowBlur = (faded ? 0 : (n.id===hover ? 22 : 14)) * fog;
402+
ctx.fillStyle = `rgba(${col},${0.5 + 0.5*hl})`;
403+
ctx.shadowColor = `rgba(${col},.8)`;
404+
ctx.shadowBlur = (n.id===hover ? 17 : 11) * fog * hl;
395405
ctx.fill();
396406
ctx.shadowBlur = 0;
397407
// bright inner core
398-
if(!faded){
408+
if(hl > 0.35){
399409
ctx.beginPath(); ctx.arc(x-r*0.28, y-r*0.28, r*0.34, 0, 6.2832);
400-
ctx.fillStyle = "rgba(255,255,255,.55)"; ctx.fill();
410+
ctx.fillStyle = `rgba(255,255,255,${0.5*hl})`; ctx.fill();
401411
}
402412
// label — declutter: hubs at rest; hovered node + neighbors; selection; zoomed in; near depth only
403-
const showLabel = (scale > 1.4) || (n.id === selected) || (hover ? near : labelIds.has(n.id));
413+
const showLabel = (scale > 1.4) || (n.id === selected) || (hover ? hl > 0.55 : labelIds.has(n.id));
404414
if(showLabel && fog > 0.55){
405-
ctx.fillStyle = faded ? "rgba(170,184,199,.3)" : "rgba(238,242,247,.95)";
415+
ctx.fillStyle = `rgba(238,242,247,${0.4 + 0.55*hl})`;
406416
ctx.font = `600 11px ui-monospace,monospace`;
407417
ctx.textAlign = "center";
408418
ctx.shadowColor = "rgba(4,6,10,.92)"; ctx.shadowBlur = 4; // legibility over the edge web
@@ -415,6 +425,12 @@
415425
function frame(t){
416426
if(alpha > 0.005) step(); // once cooled, stop integrating — the graph holds still
417427
project(); // 3D → screen coords for this frame
428+
// ease each node's highlight toward its target so hover brightens/dims smoothly (no flash)
429+
const focus = hover || selected; // hovering OR an open inspector keeps that node's connections lit
430+
for(const n of nodes){
431+
const near = !focus || n.id===focus || adj[focus].includes(n.id);
432+
n.hl += ((near ? 1 : 0.22) - n.hl) * 0.12;
433+
}
418434
ctx.setTransform(DPR,0,0,DPR,0,0);
419435
ctx.clearRect(0,0,W(),H());
420436
edges.forEach(e => drawEdge(e, t));
@@ -432,8 +448,8 @@
432448
return;
433449
}
434450
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));
451+
yaw = yawStart + (e.offsetX - oStartX) * 0.0045;
452+
pitch = Math.max(-1.45, Math.min(1.45, pitchStart - (e.offsetY - oStartY) * 0.0045));
437453
didDrag = true; return;
438454
}
439455
const n = pick(e.offsetX, e.offsetY);
@@ -446,10 +462,15 @@
446462
else { orbiting = true; oStartX = e.offsetX; oStartY = e.offsetY; yawStart = yaw; pitchStart = pitch; canvas.style.cursor = "grabbing"; }
447463
});
448464
window.addEventListener("mouseup", e => {
449-
if(drag && !didDrag){ openPanel(drag); } // click (no drag) on a node → inspect
465+
if(drag && !didDrag){ openPanel(drag); } // click (no drag) → inspect
466+
else if(drag && didDrag){ drag.pinned = true; alpha = Math.max(alpha, 0.5); } // pulled out → pin it
450467
drag = null; orbiting = false;
451468
canvas.style.cursor = "grab";
452469
});
470+
canvas.addEventListener("dblclick", e => {
471+
const n = pick(e.offsetX, e.offsetY);
472+
if(n && n.pinned){ n.pinned = false; alpha = Math.max(alpha, 0.5); } // double-click a pinned node → release
473+
});
453474
canvas.addEventListener("mouseleave", () => { if(!drag && !orbiting) hover = null; });
454475

455476
/* ===================== inspector panel ===================== */

0 commit comments

Comments
 (0)