Spaces:
Sleeping
Sleeping
| import { useEffect, useRef } from 'react' | |
| // Full-viewport triangulated node mesh behind the chat. The cursor acts like a ball | |
| // pushed through the fabric: nodes inside RADIUS displace outward with a squared | |
| // falloff and lerp back when it leaves. Idle cost is zero — the rAF loop parks itself | |
| // once every node settles, and the next pointermove wakes it. prefers-reduced-motion | |
| // gets a single static draw (no listeners, no loop). | |
| const SPACING = 96 // lattice pitch (px) | |
| const JITTER = 20 // random offset so the grid reads organic, not graph paper | |
| const RADIUS = 150 // cursor influence radius | |
| const PUSH = 42 // max displacement (px) | |
| const EASE = 0.14 // spring-back lerp factor | |
| const GLOW_R = RADIUS * 1.6 // proximity-brighten radius | |
| const LINE = '124, 58, 237' // --color-accent violet | |
| const DOT = '196, 181, 253' // lighter violet for nodes | |
| export default function MeshBackground() { | |
| const ref = useRef(null) | |
| useEffect(() => { | |
| const canvas = ref.current | |
| const ctx = canvas.getContext('2d') | |
| const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches | |
| const dpr = Math.min(devicePixelRatio || 1, 1.5) // ponytail: cap DPR, mesh needs no retina crispness | |
| let nodes = [] | |
| let edges = [] | |
| let raf = 0 | |
| let awake = false | |
| const mouse = { x: -1e4, y: -1e4 } | |
| function build() { | |
| canvas.width = innerWidth * dpr | |
| canvas.height = innerHeight * dpr | |
| // A canvas is a replaced element: inset-0 does NOT stretch it, so without an | |
| // explicit CSS size it displays at backing size (innerWidth × dpr) — wider than | |
| // the viewport when dpr > 1, drawing everything offset right of the cursor. | |
| canvas.style.width = `${innerWidth}px` | |
| canvas.style.height = `${innerHeight}px` | |
| ctx.setTransform(dpr, 0, 0, dpr, 0, 0) | |
| nodes = [] | |
| edges = [] | |
| const cols = Math.ceil(innerWidth / SPACING) + 2 | |
| const rows = Math.ceil(innerHeight / (SPACING * 0.866)) + 2 | |
| for (let r = 0; r < rows; r++) { | |
| for (let c = 0; c < cols; c++) { | |
| const bx = c * SPACING + (r % 2 ? SPACING / 2 : 0) - SPACING / 2 + (Math.random() - 0.5) * 2 * JITTER | |
| const by = r * SPACING * 0.866 - SPACING / 2 + (Math.random() - 0.5) * 2 * JITTER | |
| nodes.push({ bx, by, x: bx, y: by }) | |
| } | |
| } | |
| // Triangulate the lattice: right neighbor, the node below, and the row-parity diagonal. | |
| for (let r = 0; r < rows; r++) { | |
| for (let c = 0; c < cols; c++) { | |
| const i = r * cols + c | |
| if (c < cols - 1) edges.push([i, i + 1]) | |
| if (r < rows - 1) { | |
| edges.push([i, i + cols]) | |
| const diag = r % 2 ? i + cols + 1 : i + cols - 1 | |
| if (diag >= (r + 1) * cols && diag < (r + 2) * cols) edges.push([i, diag]) | |
| } | |
| } | |
| } | |
| } | |
| function draw() { | |
| ctx.clearRect(0, 0, innerWidth, innerHeight) | |
| for (const [a, b] of edges) { | |
| const A = nodes[a] | |
| const B = nodes[b] | |
| const d = Math.hypot((A.x + B.x) / 2 - mouse.x, (A.y + B.y) / 2 - mouse.y) | |
| const glow = d < GLOW_R ? 1 - d / GLOW_R : 0 | |
| ctx.strokeStyle = `rgba(${LINE}, ${0.14 + glow * 0.35})` | |
| ctx.beginPath() | |
| ctx.moveTo(A.x, A.y) | |
| ctx.lineTo(B.x, B.y) | |
| ctx.stroke() | |
| } | |
| for (const n of nodes) { | |
| const d = Math.hypot(n.x - mouse.x, n.y - mouse.y) | |
| const glow = d < GLOW_R ? 1 - d / GLOW_R : 0 | |
| ctx.fillStyle = `rgba(${DOT}, ${0.25 + glow * 0.6})` | |
| ctx.beginPath() | |
| ctx.arc(n.x, n.y, 1.6 + glow * 1.2, 0, 7) | |
| ctx.fill() | |
| } | |
| } | |
| function step() { | |
| let energy = 0 | |
| for (const n of nodes) { | |
| const dx = n.bx - mouse.x | |
| const dy = n.by - mouse.y | |
| const d = Math.hypot(dx, dy) | |
| let tx = n.bx | |
| let ty = n.by | |
| if (d < RADIUS && d > 0.01) { | |
| const f = (1 - d / RADIUS) ** 2 * PUSH | |
| tx += (dx / d) * f | |
| ty += (dy / d) * f | |
| } | |
| n.x += (tx - n.x) * EASE | |
| n.y += (ty - n.y) * EASE | |
| energy += Math.abs(tx - n.x) + Math.abs(ty - n.y) | |
| } | |
| draw() | |
| if (energy > 0.5) raf = requestAnimationFrame(step) | |
| else awake = false // parked — zero idle cost until the next pointermove | |
| } | |
| function wake() { | |
| if (!awake) { | |
| awake = true | |
| raf = requestAnimationFrame(step) | |
| } | |
| } | |
| function onMove(e) { | |
| mouse.x = e.clientX | |
| mouse.y = e.clientY | |
| wake() | |
| } | |
| function onLeave() { | |
| mouse.x = -1e4 | |
| mouse.y = -1e4 | |
| wake() // spring everything back | |
| } | |
| // ResizeObserver on <html> instead of window 'resize': catches every viewport change | |
| // (devtools-driven resizes, zoom, panel drags) — some of which never fire the event. | |
| let resizeT | |
| let builtW = 0 | |
| let builtH = 0 | |
| const ro = new ResizeObserver(() => { | |
| if (innerWidth === builtW && innerHeight === builtH) return | |
| clearTimeout(resizeT) | |
| resizeT = setTimeout(() => { | |
| rebuild() | |
| }, 150) | |
| }) | |
| function rebuild() { | |
| builtW = innerWidth | |
| builtH = innerHeight | |
| build() | |
| draw() // paint immediately — rAF doesn't fire in hidden tabs and shouldn't gate first paint | |
| if (!reduced) wake() | |
| } | |
| rebuild() | |
| ro.observe(document.documentElement) | |
| if (reduced) return () => { ro.disconnect(); clearTimeout(resizeT) } | |
| window.addEventListener('pointermove', onMove) | |
| document.documentElement.addEventListener('mouseleave', onLeave) | |
| return () => { | |
| cancelAnimationFrame(raf) | |
| clearTimeout(resizeT) | |
| ro.disconnect() | |
| window.removeEventListener('pointermove', onMove) | |
| document.documentElement.removeEventListener('mouseleave', onLeave) | |
| } | |
| }, []) | |
| return <canvas ref={ref} aria-hidden="true" className="pointer-events-none fixed inset-0 -z-10" /> | |
| } | |