feat(portfolio): add operator profile with banner, publication, and honors
This commit is contained in:
200
components/portfolio/HeroCanvas.tsx
Normal file
200
components/portfolio/HeroCanvas.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
"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]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user