201 lines
5.1 KiB
TypeScript
201 lines
5.1 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef } from "react";
|
|
import { useReducedMotion } from "motion/react";
|
|
|
|
const COLORS = {
|
|
bg: "#030506",
|
|
line: "rgba(44, 53, 58, 0.46)",
|
|
dot: "rgba(244, 247, 248, 0.38)",
|
|
cyan: "rgba(0, 240, 255, 0.82)",
|
|
};
|
|
|
|
const GRID_COLUMNS = 14;
|
|
const GRID_ROWS = 13;
|
|
const PULSE_PERIOD_MS = 5200;
|
|
|
|
function projectX(width: number, xIndex: number, yIndex: number, time: number) {
|
|
const normalizedX = (xIndex / (GRID_COLUMNS - 1) - 0.5) * 2;
|
|
const normalizedY = yIndex / (GRID_ROWS - 1);
|
|
const depth = normalizedY * normalizedY;
|
|
|
|
return (
|
|
width * 0.5 +
|
|
normalizedX * width * (0.13 + depth * 0.5) +
|
|
Math.sin(time * 0.00008 + yIndex * 0.45) * 7
|
|
);
|
|
}
|
|
|
|
function projectY(height: number, yIndex: number, time: number) {
|
|
const normalizedY = yIndex / (GRID_ROWS - 1);
|
|
const drift = Math.sin(time * 0.00012 + yIndex * 0.7) * 5;
|
|
|
|
return height * 0.24 + height * 0.82 * normalizedY * normalizedY + drift;
|
|
}
|
|
|
|
export function HeroCanvas() {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const shouldReduceMotion = useReducedMotion();
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) {
|
|
return;
|
|
}
|
|
|
|
const context = canvas.getContext("2d", { alpha: true });
|
|
if (!context) {
|
|
return;
|
|
}
|
|
|
|
let width = 0;
|
|
let height = 0;
|
|
let dpr = 1;
|
|
let frameId = 0;
|
|
let isMounted = true;
|
|
|
|
const resize = () => {
|
|
const rect = canvas.getBoundingClientRect();
|
|
dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
width = Math.max(1, rect.width);
|
|
height = Math.max(1, rect.height);
|
|
canvas.width = Math.floor(width * dpr);
|
|
canvas.height = Math.floor(height * dpr);
|
|
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
|
|
if (shouldReduceMotion) {
|
|
draw(0);
|
|
}
|
|
};
|
|
|
|
const drawGridLines = (time: number) => {
|
|
context.lineWidth = 1;
|
|
context.strokeStyle = COLORS.line;
|
|
|
|
for (let column = 0; column < GRID_COLUMNS; column += 1) {
|
|
context.beginPath();
|
|
for (let row = 0; row < GRID_ROWS; row += 1) {
|
|
const x = projectX(width, column, row, time);
|
|
const y = projectY(height, row, time);
|
|
if (row === 0) {
|
|
context.moveTo(x, y);
|
|
} else {
|
|
context.lineTo(x, y);
|
|
}
|
|
}
|
|
context.stroke();
|
|
}
|
|
|
|
for (let row = 0; row < GRID_ROWS; row += 1) {
|
|
context.beginPath();
|
|
for (let column = 0; column < GRID_COLUMNS; column += 1) {
|
|
const x = projectX(width, column, row, time);
|
|
const y = projectY(height, row, time);
|
|
if (column === 0) {
|
|
context.moveTo(x, y);
|
|
} else {
|
|
context.lineTo(x, y);
|
|
}
|
|
}
|
|
context.stroke();
|
|
}
|
|
};
|
|
|
|
const drawDots = (time: number) => {
|
|
context.fillStyle = COLORS.dot;
|
|
|
|
for (let row = 0; row < GRID_ROWS; row += 1) {
|
|
const normalizedY = row / (GRID_ROWS - 1);
|
|
const radius = 0.9 + normalizedY * 1.15;
|
|
|
|
for (let column = 0; column < GRID_COLUMNS; column += 1) {
|
|
context.beginPath();
|
|
context.arc(
|
|
projectX(width, column, row, time),
|
|
projectY(height, row, time),
|
|
radius,
|
|
0,
|
|
Math.PI * 2,
|
|
);
|
|
context.fill();
|
|
}
|
|
}
|
|
};
|
|
|
|
const drawPulse = (time: number) => {
|
|
if (shouldReduceMotion) {
|
|
return;
|
|
}
|
|
|
|
const progress = (time % PULSE_PERIOD_MS) / PULSE_PERIOD_MS;
|
|
const row = 8;
|
|
const maxColumn = Math.min(
|
|
GRID_COLUMNS - 1,
|
|
Math.max(1, Math.floor(progress * (GRID_COLUMNS + 2))),
|
|
);
|
|
|
|
context.save();
|
|
context.globalAlpha = progress > 0.84 ? (1 - progress) / 0.16 : 1;
|
|
context.lineWidth = 2;
|
|
context.strokeStyle = COLORS.cyan;
|
|
context.shadowBlur = 14;
|
|
context.shadowColor = COLORS.cyan;
|
|
context.beginPath();
|
|
|
|
for (let column = 0; column <= maxColumn; column += 1) {
|
|
const x = projectX(width, column, row, time);
|
|
const y = projectY(height, row, time);
|
|
if (column === 0) {
|
|
context.moveTo(x, y);
|
|
} else {
|
|
context.lineTo(x, y);
|
|
}
|
|
}
|
|
|
|
context.stroke();
|
|
context.restore();
|
|
};
|
|
|
|
const draw = (time: number) => {
|
|
context.clearRect(0, 0, width, height);
|
|
context.fillStyle = COLORS.bg;
|
|
context.fillRect(0, 0, width, height);
|
|
drawGridLines(time);
|
|
drawDots(time);
|
|
drawPulse(time);
|
|
};
|
|
|
|
const tick = (time: number) => {
|
|
if (!isMounted) {
|
|
return;
|
|
}
|
|
|
|
draw(time);
|
|
frameId = window.requestAnimationFrame(tick);
|
|
};
|
|
|
|
resize();
|
|
window.addEventListener("resize", resize, { passive: true });
|
|
|
|
if (!shouldReduceMotion) {
|
|
frameId = window.requestAnimationFrame(tick);
|
|
}
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
window.removeEventListener("resize", resize);
|
|
if (frameId) {
|
|
window.cancelAnimationFrame(frameId);
|
|
}
|
|
};
|
|
}, [shouldReduceMotion]);
|
|
|
|
return (
|
|
<canvas
|
|
ref={canvasRef}
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute inset-0 h-full w-full opacity-[0.32]"
|
|
/>
|
|
);
|
|
}
|