ProbabilityRAG / web /src /MeshBackground.jsx
Piero7's picture
Add interactive mesh background, glass chrome, and reactive mascot
0411d7c
Raw
History Blame Contribute Delete
5.89 kB
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" />
}