|
270 | 270 | let didDrag = false; |
271 | 271 | let alpha = 1; // simulated-annealing "temperature"; cools to 0 so motion stops |
272 | 272 | let spread = 1; // spacing knob: >1 spreads nodes apart, <1 packs them tighter |
273 | | -let mode = "treemap"; // "treemap" = document/concept infographic (default), "graph" = 3D force |
| 273 | +let mode = "graph"; // "graph" = 3D nebula showpiece (default), "treemap" = infographic |
| 274 | +let idleFrames = 0; // frames since last interaction → drives idle auto-rotation |
274 | 275 |
|
275 | 276 | document.getElementById("ncount").textContent = nodes.length; |
276 | 277 | document.getElementById("ecount").textContent = edges.length; |
|
478 | 479 |
|
479 | 480 | canvas.addEventListener("wheel", e => { |
480 | 481 | e.preventDefault(); |
| 482 | + idleFrames = 0; // pause auto-rotation while the user is interacting |
481 | 483 | const f = Math.exp(-e.deltaY * 0.0015); |
482 | 484 | const ns = Math.max(0.3, Math.min(9, scale*f)); // wider range so you can dive deep in |
483 | 485 | const k = ns/scale; |
|
538 | 540 | ctx.globalAlpha = 1; |
539 | 541 | } |
540 | 542 |
|
| 543 | +// ambient "comets" drifting along random filaments → the nebula feels alive even at rest |
| 544 | +const pulses = Array.from({length: Math.min(10, Math.max(0, edges.length))}, |
| 545 | + () => ({ ei: Math.floor(Math.random()*edges.length), k: Math.random(), sp: 0.003 + Math.random()*0.004 })); |
| 546 | +function drawPulses(t){ |
| 547 | + for(const p of pulses){ |
| 548 | + let e = edges[p.ei], a = e && byId[e.source], b = e && byId[e.target]; |
| 549 | + if(!e || a.alpha < 0.06 || b.alpha < 0.06){ p.ei = Math.floor(Math.random()*edges.length); p.k = 0; continue; } |
| 550 | + p.k += p.sp; |
| 551 | + if(p.k >= 1){ p.k = 0; p.ei = Math.floor(Math.random()*edges.length); continue; } |
| 552 | + const fade = Math.sin(p.k*Math.PI); // fade in/out along the filament |
| 553 | + const fog = Math.max(0.25, Math.min(1, 1 - (a.rz+b.rz)/1300)); |
| 554 | + const fx = a.sx + (b.sx-a.sx)*p.k, fy = a.sy + (b.sy-a.sy)*p.k; |
| 555 | + ctx.globalAlpha = 0.85 * fade * fog; |
| 556 | + ctx.shadowColor = "rgba(120,200,255,.9)"; ctx.shadowBlur = 7; |
| 557 | + ctx.fillStyle = "rgba(190,225,255,.95)"; |
| 558 | + ctx.beginPath(); ctx.arc(fx, fy, 1.7, 0, TAU); ctx.fill(); |
| 559 | + ctx.shadowBlur = 0; ctx.globalAlpha = 1; |
| 560 | + } |
| 561 | +} |
| 562 | + |
541 | 563 | function drawNode(n, t){ |
542 | 564 | if(n.alpha < 0.02) return; |
543 | 565 | const col = n.col; |
|
566 | 588 | } |
567 | 589 | // disc + glow (glow eases with highlight so hovering doesn't flash the whole scene) |
568 | 590 | ctx.beginPath(); ctx.arc(x, y, r, 0, TAU); |
| 591 | + const twinkle = 0.82 + 0.18*Math.sin(t*0.0013 + n.r*3.1); // gentle per-node shimmer |
569 | 592 | ctx.fillStyle = `rgba(${col},${0.5 + 0.5*hl})`; |
570 | | - ctx.shadowColor = `rgba(${col},.7)`; |
571 | | - ctx.shadowBlur = (n.id===hover ? 9 : 0) * fog * hl; // flat crisp discs at rest; a touch of glow only on hover |
| 593 | + ctx.shadowColor = `rgba(${col},.9)`; |
| 594 | + ctx.shadowBlur = (n.id===hover ? 18 : 8) * fog * hl * twinkle; // soft bloom → nebula glow |
572 | 595 | ctx.fill(); |
573 | 596 | ctx.shadowBlur = 0; |
| 597 | + // bright core spark for the nearer/brighter nodes |
| 598 | + if(hl > 0.5 && fog > 0.5){ |
| 599 | + ctx.beginPath(); ctx.arc(x - r*0.28, y - r*0.28, r*0.3, 0, TAU); |
| 600 | + ctx.fillStyle = `rgba(255,255,255,${0.35*fog})`; ctx.fill(); |
| 601 | + } |
574 | 602 | // (flat discs — no glossy core highlight) |
575 | 603 | // label — declutter: hubs at rest; hovered node + neighbors; selection; zoomed in; near depth only |
576 | 604 | const showLabel = (scale > 1.4) || (n.id === selected) || (hover ? hl > 0.55 : labelIds.has(n.id)); |
|
589 | 617 | function frame(t){ |
590 | 618 | if(mode === "graph"){ // treemap is DOM — the canvas idles |
591 | 619 | if(alpha > 0.005) step(); |
| 620 | + idleFrames = (orbiting || drag) ? 0 : idleFrames + 1; |
| 621 | + if(idleFrames > 150) yaw += 0.0011; // gentle auto-rotation once idle → a living nebula |
592 | 622 | project(); |
593 | 623 | const focus = hover || selected; // hover/selection spotlights a node + its neighbours |
594 | 624 | for(const n of nodes){ |
|
598 | 628 | ctx.setTransform(DPR,0,0,DPR,0,0); |
599 | 629 | ctx.clearRect(0,0,W(),H()); |
600 | 630 | edges.forEach(e => drawEdge(e, t)); |
| 631 | + drawPulses(t); // ambient light travelling the filaments |
601 | 632 | // draw nodes far → near so nearer discs occlude farther ones (painter's algorithm) |
602 | 633 | nodes.filter(visible).sort((a,b) => b.rz - a.rz).forEach(n => drawNode(n, t)); |
603 | 634 | } |
|
625 | 656 | }); |
626 | 657 | canvas.addEventListener("mousedown", e => { |
627 | 658 | const n = pick(e.offsetX, e.offsetY); |
628 | | - didDrag = false; |
| 659 | + didDrag = false; idleFrames = 0; |
629 | 660 | oStartX = e.offsetX; oStartY = e.offsetY; // press origin — for the drag threshold and orbit |
630 | 661 | if(n){ drag = n; hover = n.id; canvas.style.cursor = "grabbing"; } |
631 | 662 | else { orbiting = true; yawStart = yaw; pitchStart = pitch; canvas.style.cursor = "grabbing"; } |
|
734 | 765 |
|
735 | 766 | /* ===================== boot ===================== */ |
736 | 767 | window.addEventListener("resize", () => { size(); if(mode === "treemap") buildTreemap(); }); |
737 | | -size(); seed(); // seed positions so a later switch to 3D has a layout to settle |
738 | | -setMode("treemap"); // open on the treemap infographic |
| 768 | +size(); seed(); |
| 769 | +setMode("graph"); // open on the 3D nebula |
739 | 770 | requestAnimationFrame(frame); |
740 | 771 | </script> |
741 | 772 | </body> |
|
0 commit comments