/* blueyetutor — Hero particles for /login. * * Dandelion seeds drift slowly upward across the navy hero. When the cursor * approaches a seed, the cursor's velocity is *imparted* to nearby seeds — * they get blown away in the direction the mouse is moving, then settle * back to baseline drift. Seeds that fly off the canvas respawn from the * bottom-right (as if a fresh gust pushed more in). */ function HeroParticles() { const ref = React.useRef(null); React.useEffect(() => { const canvas = ref.current; if (!canvas) return; const ctx = canvas.getContext("2d"); let raf = 0, w = 0, h = 0; const dpr = Math.min(window.devicePixelRatio || 1, 2); const mouse = { x: -9999, y: -9999, vx: 0, vy: 0, active: false, lastT: 0 }; const N = 46; // sparse but visible const seeds = Array.from({ length: N }, () => spawn(true)); function spawn(initial) { const fromBottomRight = !initial && Math.random() < 0.55; return { x: fromBottomRight ? 0.9 + Math.random() * 0.15 : Math.random(), y: initial ? Math.random() : fromBottomRight ? 0.8 + Math.random() * 0.25 : 1.08, baseVx: -0.00006 - Math.random() * 0.00008, // gentler leftward base baseVy: -0.00012 - Math.random() * 0.00016, // gentler upward base vx: 0, vy: 0, r: 1.2 + Math.random() * 1.8, spokes: 6 + Math.floor(Math.random() * 4), rot: Math.random() * Math.PI * 2, spin: (Math.random() - 0.5) * 0.0004, phase: Math.random() * Math.PI * 2, wobble: 0.0005 + Math.random() * 0.0007, alpha: 0, life: 0, }; } function resize() { const rect = canvas.getBoundingClientRect(); w = rect.width; h = rect.height; canvas.width = Math.floor(w * dpr); canvas.height = Math.floor(h * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } const onResize = () => resize(); const onMove = e => { const rect = canvas.getBoundingClientRect(); const nx = e.clientX - rect.left; const ny = e.clientY - rect.top; const now = performance.now(); const dt = Math.max(8, now - (mouse.lastT || now)); if (mouse.active) { // smooth mouse velocity (px / ms) const newVx = (nx - mouse.x) / dt; const newVy = (ny - mouse.y) / dt; mouse.vx = mouse.vx * 0.5 + newVx * 0.5; mouse.vy = mouse.vy * 0.5 + newVy * 0.5; } mouse.x = nx; mouse.y = ny; mouse.lastT = now; mouse.active = true; }; const onLeave = () => { mouse.active = false; mouse.vx = 0; mouse.vy = 0; }; resize(); window.addEventListener("resize", onResize); canvas.addEventListener("mousemove", onMove); canvas.addEventListener("mouseleave", onLeave); let last = performance.now(); const tick = (t) => { const dt = Math.min(64, t - last); last = t; // mouse velocity damping over time (so impulse fades when cursor stops) mouse.vx *= 0.9; mouse.vy *= 0.9; ctx.clearRect(0, 0, w, h); // soft inner glow const grad = ctx.createRadialGradient(w * 0.5, h * 0.35, 0, w * 0.5, h * 0.35, Math.max(w, h) * 0.85); grad.addColorStop(0, "rgba(255,255,255,0.06)"); grad.addColorStop(1, "rgba(255,255,255,0)"); ctx.fillStyle = grad; ctx.fillRect(0, 0, w, h); const R = 80; // mouse influence radius (px) const Rsq = R * R; for (let s of seeds) { // wobble + spin s.phase += s.wobble * dt; s.rot += s.spin * dt; const wobbleX = Math.sin(s.phase) * 0.00007 * dt; s.life += dt; // baseline drift settles vx/vy back s.vx += (s.baseVx - s.vx) * 0.030; s.vy += (s.baseVy - s.vy) * 0.030; // mouse "brush" — gentle nudge for nearby seeds. Quartic falloff so // only seeds very close get noticeable motion; far ones barely move. if (mouse.active) { const px = s.x * w, py = s.y * h; const dx = px - mouse.x, dy = py - mouse.y; const dsq = dx * dx + dy * dy; if (dsq < Rsq) { const d = Math.sqrt(dsq) || 1; const fall = 1 - d / R; const sFall = fall * fall * fall; // very mild repulsion away from cursor const repel = sFall * 0.00008; s.vx += (dx / d) * repel; s.vy += (dy / d) * repel; // soft velocity transfer — brushing direction nudges drift s.vx += mouse.vx * sFall * 0.0014; s.vy += mouse.vy * sFall * 0.0014; // tiny tumble s.spin += (Math.random() - 0.5) * sFall * 0.00010; s.flare = Math.max(s.flare || 0, sFall * 0.3); } } s.flare = (s.flare || 0) * 0.94; // integrate s.x += s.vx * dt + wobbleX; s.y += s.vy * dt; // respawn when offscreen if (s.y < -0.1 || s.x < -0.12 || s.x > 1.12) { Object.assign(s, spawn(false)); } // fade by life + edge proximity const lifeFade = Math.min(1, s.life / 600); const edgeFade = Math.min(1, (1 - s.y) * 1.4, s.y * 8, (1 - Math.abs(s.x - 0.5) * 1.7)); s.alpha = Math.max(0, Math.min(1, lifeFade * edgeFade)); const px = s.x * w, py = s.y * h; ctx.save(); ctx.translate(px, py); ctx.rotate(s.rot); // outer spokes ctx.globalAlpha = s.alpha * (0.5 + s.flare * 0.5); ctx.strokeStyle = "rgba(255,255,255,1)"; ctx.lineWidth = 0.7; const spokeLen = s.r * 4.5; for (let k = 0; k < s.spokes; k++) { const a = (k / s.spokes) * Math.PI * 2; ctx.beginPath(); ctx.moveTo(Math.cos(a) * 0.7, Math.sin(a) * 0.7); ctx.lineTo(Math.cos(a) * spokeLen, Math.sin(a) * spokeLen); ctx.stroke(); } // center dot ctx.globalAlpha = s.alpha * (0.85 + s.flare * 0.15); ctx.fillStyle = "rgba(255,255,255,1)"; ctx.beginPath(); ctx.arc(0, 0, s.r * 0.55, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => { cancelAnimationFrame(raf); window.removeEventListener("resize", onResize); canvas.removeEventListener("mousemove", onMove); canvas.removeEventListener("mouseleave", onLeave); }; }, []); return