|
158 | 158 | <div class="legend" id="legend"></div> |
159 | 159 |
|
160 | 160 | <div class="hint"> |
161 | | - <kbd>scroll</kbd> zoom <kbd>drag bg</kbd> rotate<br> |
162 | | - <kbd>drag node</kbd> move <kbd>click</kbd> inspect |
| 161 | + <kbd>scroll</kbd> zoom <kbd>drag bg</kbd> rotate <kbd>click</kbd> inspect<br> |
| 162 | + <kbd>drag node</kbd> pull out & pin <kbd>dbl-click</kbd> release |
163 | 163 | </div> |
164 | 164 |
|
165 | 165 | <div class="panel" id="panel"></div> |
|
190 | 190 | ...n, x:0, y:0, z:0, vx:0, vy:0, vz:0, |
191 | 191 | r: 8 + Math.min(10, (n.in || 0) + (n.out || 0)), // radius scales with degree |
192 | 192 | 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) |
194 | 194 | })); |
195 | 195 | const byId = Object.fromEntries(nodes.map(n => [n.id, n])); |
196 | 196 | const edges = GRAPH.edges.filter(e => byId[e.source] && byId[e.target]); |
|
271 | 271 | // smoothly approach the legend-toggle target opacity |
272 | 272 | const target = typeVisible(n.type) ? 1 : 0; |
273 | 273 | 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 |
275 | 275 | n.vx *= 0.84; n.vy *= 0.84; n.vz *= 0.84; // stronger damping dissipates energy fast |
276 | 276 | if(n.vx > VMAX) n.vx = VMAX; else if(n.vx < -VMAX) n.vx = -VMAX; |
277 | 277 | if(n.vy > VMAX) n.vy = VMAX; else if(n.vy < -VMAX) n.vy = -VMAX; |
|
313 | 313 | canvas.addEventListener("wheel", e => { |
314 | 314 | e.preventDefault(); |
315 | 315 | 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; |
317 | 323 | }, {passive:false}); |
318 | 324 |
|
319 | 325 | /* ===================== picking ===================== */ |
|
335 | 341 | const al = Math.min(a.alpha, b.alpha); |
336 | 342 | if(al < 0.02) return; |
337 | 343 | const hot = hover && (e.source===hover || e.target===hover); |
338 | | - const dim = hover && !hot; |
339 | 344 | const ax=a.sx, ay=a.sy, bx=b.sx, by=b.sy; |
340 | 345 | 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})`; |
343 | 349 | ctx.lineWidth = hot ? 2 : 1; |
344 | 350 | ctx.beginPath(); ctx.moveTo(ax,ay); ctx.lineTo(bx,by); ctx.stroke(); |
345 | 351 | // arrowhead at b, backed off by b's projected radius |
346 | 352 | const ang = Math.atan2(by-ay, bx-ax); |
347 | 353 | const off = b.r*b.pp*scale + 4; |
348 | 354 | const hx = bx - Math.cos(ang)*off, hy = by - Math.sin(ang)*off; |
349 | 355 | 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})`; |
351 | 357 | ctx.beginPath(); |
352 | 358 | ctx.moveTo(hx, hy); |
353 | 359 | ctx.lineTo(hx - Math.cos(ang-.4)*ah, hy - Math.sin(ang-.4)*ah); |
|
368 | 374 | function drawNode(n, t){ |
369 | 375 | if(n.alpha < 0.02) return; |
370 | 376 | 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; |
376 | 380 | const r = Math.max(2, n.r * pulse * breathe * n.pp * scale); // perspective: near = larger |
377 | 381 | const fog = Math.max(0.32, Math.min(1, 1 - n.rz/640)); // depth cue: far nodes recede |
378 | 382 | 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 |
380 | 384 | // selected ring |
381 | 385 | if(n.id === selected){ |
382 | 386 | ctx.beginPath(); ctx.arc(x, y, r+6, 0, 6.2832); |
383 | 387 | ctx.strokeStyle = `rgba(${col},.85)`; ctx.lineWidth = 2; ctx.stroke(); |
384 | 388 | } |
| 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 | + } |
385 | 395 | // hover halo |
386 | 396 | if(n.id === hover){ |
387 | 397 | ctx.beginPath(); ctx.arc(x, y, r+11, 0, 6.2832); |
388 | 398 | ctx.fillStyle = `rgba(${col},.16)`; ctx.fill(); |
389 | 399 | } |
390 | | - // disc + glow |
| 400 | + // disc + glow (glow eases with highlight so hovering doesn't flash the whole scene) |
391 | 401 | 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; |
395 | 405 | ctx.fill(); |
396 | 406 | ctx.shadowBlur = 0; |
397 | 407 | // bright inner core |
398 | | - if(!faded){ |
| 408 | + if(hl > 0.35){ |
399 | 409 | 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(); |
401 | 411 | } |
402 | 412 | // 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)); |
404 | 414 | 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})`; |
406 | 416 | ctx.font = `600 11px ui-monospace,monospace`; |
407 | 417 | ctx.textAlign = "center"; |
408 | 418 | ctx.shadowColor = "rgba(4,6,10,.92)"; ctx.shadowBlur = 4; // legibility over the edge web |
|
415 | 425 | function frame(t){ |
416 | 426 | if(alpha > 0.005) step(); // once cooled, stop integrating — the graph holds still |
417 | 427 | 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 | + } |
418 | 434 | ctx.setTransform(DPR,0,0,DPR,0,0); |
419 | 435 | ctx.clearRect(0,0,W(),H()); |
420 | 436 | edges.forEach(e => drawEdge(e, t)); |
|
432 | 448 | return; |
433 | 449 | } |
434 | 450 | 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)); |
437 | 453 | didDrag = true; return; |
438 | 454 | } |
439 | 455 | const n = pick(e.offsetX, e.offsetY); |
|
446 | 462 | else { orbiting = true; oStartX = e.offsetX; oStartY = e.offsetY; yawStart = yaw; pitchStart = pitch; canvas.style.cursor = "grabbing"; } |
447 | 463 | }); |
448 | 464 | 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 |
450 | 467 | drag = null; orbiting = false; |
451 | 468 | canvas.style.cursor = "grab"; |
452 | 469 | }); |
| 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 | +}); |
453 | 474 | canvas.addEventListener("mouseleave", () => { if(!drag && !orbiting) hover = null; }); |
454 | 475 |
|
455 | 476 | /* ===================== inspector panel ===================== */ |
|
0 commit comments