Files
achraf-portfolio/components/GridCanvas.tsx
2026-04-01 00:41:31 +02:00

192 lines
7.3 KiB
TypeScript

"use client";
import { useEffect, useRef } from "react";
/**
* Full-page fixed canvas that draws a distorted grid background.
* The grid warps toward the mouse cursor like a gravitational lens /
* spacetime distortion — grid lines curve inward, intersections glow,
* and a soft mass-glow follows the cursor.
*/
export default function GridCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let raf: number;
// Smoothed mouse position (lerped toward target)
let mx = -3000;
let my = -3000;
// Raw target from events
let tx = -3000;
let ty = -3000;
const GRID = 64; // px between grid lines
const SIGMA = 190; // radius of distortion effect (px)
const STRENGTH = 52; // max pixel pull toward cursor
// ── resize ───────────────────────────────────────────────────────────────
const resize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
resize();
window.addEventListener("resize", resize);
// ── mouse tracking (global so it works across the whole page) ────────────
const onPointerMove = (e: PointerEvent) => { if (e.pointerType === "mouse") { tx = e.clientX; ty = e.clientY; } };
const onPointerLeave = (e: PointerEvent) => { if (e.pointerType === "mouse") { tx = -3000; ty = -3000; } };
window.addEventListener("pointermove", onPointerMove);
document.documentElement.addEventListener("pointerleave", onPointerLeave);
// ── touch tracking (mobile) ───────────────────────────────────────────────
const onTouch = (e: TouchEvent) => {
const t = e.touches[0];
if (t) { tx = t.clientX; ty = t.clientY; }
};
window.addEventListener("touchstart", onTouch, { passive: true });
window.addEventListener("touchmove", onTouch, { passive: true });
// ── displacement: pull a point (px,py) toward the cursor ─────────────────
const warp = (px: number, py: number): [number, number] => {
const dx = px - mx;
const dy = py - my;
const d2 = dx * dx + dy * dy;
if (d2 < 1) return [px, py];
const d = Math.sqrt(d2);
const f = STRENGTH * Math.exp(-d2 / (2 * SIGMA * SIGMA));
return [px - (dx / d) * f, py - (dy / d) * f];
};
// ── main draw loop ────────────────────────────────────────────────────────
const draw = () => {
// Smooth-lerp mouse position (feels weighty / spacetime-like)
mx += (tx - mx) * 0.065;
my += (ty - my) * 0.065;
const W = canvas.width;
const H = canvas.height;
const active = mx > -2000;
ctx.clearRect(0, 0, W, H);
// ── horizontal grid lines ─────────────────────────────────────────────
for (let gy = 0; gy <= H + GRID; gy += GRID) {
const lineDist = active ? Math.abs(gy - my) : 9999;
const lift = active ? Math.max(0, 1 - lineDist / (SIGMA * 1.4)) : 0;
ctx.beginPath();
let first = true;
for (let x = 0; x <= W; x += 5) {
const [wx, wy] = active ? warp(x, gy) : [x, gy];
first ? ctx.moveTo(wx, wy) : ctx.lineTo(wx, wy);
first = false;
}
ctx.strokeStyle = `rgba(200,169,110,${0.048 + lift * 0.12})`;
ctx.lineWidth = 0.5 + lift * 0.55;
ctx.stroke();
}
// ── vertical grid lines ───────────────────────────────────────────────
for (let gx = 0; gx <= W + GRID; gx += GRID) {
const lineDist = active ? Math.abs(gx - mx) : 9999;
const lift = active ? Math.max(0, 1 - lineDist / (SIGMA * 1.4)) : 0;
ctx.beginPath();
let first = true;
for (let y = 0; y <= H; y += 5) {
const [wx, wy] = active ? warp(gx, y) : [gx, y];
first ? ctx.moveTo(wx, wy) : ctx.lineTo(wx, wy);
first = false;
}
ctx.strokeStyle = `rgba(200,169,110,${0.048 + lift * 0.12})`;
ctx.lineWidth = 0.5 + lift * 0.55;
ctx.stroke();
}
// ── intersection dots (lensed stars) ─────────────────────────────────
if (active) {
const cullR2 = (SIGMA * 2.2) * (SIGMA * 2.2);
for (let gx = 0; gx <= W + GRID; gx += GRID) {
for (let gy = 0; gy <= H + GRID; gy += GRID) {
const dx = gx - mx;
const dy = gy - my;
const d2 = dx * dx + dy * dy;
if (d2 > cullR2) continue;
const [wx, wy] = warp(gx, gy);
const intensity = Math.exp(-d2 / (2 * SIGMA * SIGMA * 0.35));
// outer halo
ctx.beginPath();
ctx.arc(wx, wy, 1.5 + intensity * 2, 0, Math.PI * 2);
ctx.fillStyle = `rgba(200,169,110,${0.1 + intensity * 0.5})`;
ctx.fill();
// bright core for very close intersections
if (intensity > 0.4) {
ctx.beginPath();
ctx.arc(wx, wy, 0.8, 0, Math.PI * 2);
ctx.fillStyle = `rgba(232,200,135,${intensity * 0.7})`;
ctx.fill();
}
}
}
}
// ── gravitational mass glow at cursor ─────────────────────────────────
if (active) {
// wide soft halo
const halo = ctx.createRadialGradient(mx, my, 0, mx, my, SIGMA * 0.85);
halo.addColorStop(0, "rgba(200,169,110,0.07)");
halo.addColorStop(0.5, "rgba(200,169,110,0.025)");
halo.addColorStop(1, "rgba(200,169,110,0)");
ctx.fillStyle = halo;
ctx.beginPath();
ctx.arc(mx, my, SIGMA * 0.85, 0, Math.PI * 2);
ctx.fill();
// tight bright core (the "mass")
const core = ctx.createRadialGradient(mx, my, 0, mx, my, 22);
core.addColorStop(0, "rgba(240,210,150,0.22)");
core.addColorStop(1, "rgba(200,169,110,0)");
ctx.fillStyle = core;
ctx.beginPath();
ctx.arc(mx, my, 22, 0, Math.PI * 2);
ctx.fill();
}
raf = requestAnimationFrame(draw);
};
draw();
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", resize);
window.removeEventListener("pointermove", onPointerMove);
document.documentElement.removeEventListener("pointerleave", onPointerLeave);
window.removeEventListener("touchstart", onTouch);
window.removeEventListener("touchmove", onTouch);
};
}, []);
return (
<canvas
ref={canvasRef}
aria-hidden="true"
style={{
position: "fixed",
inset: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
zIndex: 0,
}}
/>
);
}