You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
334 lines
11 KiB
334 lines
11 KiB
"use client";
|
|
|
|
import React, { useEffect, useRef } from "react";
|
|
|
|
interface HeroDotCanvasProps {
|
|
sectionRef?: React.RefObject<HTMLElement | null>;
|
|
}
|
|
|
|
export function HeroDotCanvas({ sectionRef }: HeroDotCanvasProps) {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return;
|
|
|
|
// Grid coordinates & dimensions matching hero_back.svg
|
|
const SVG_WIDTH = 1856;
|
|
const SVG_HEIGHT = 566;
|
|
const rowsize = 12;
|
|
const minX = 6;
|
|
const maxX = 1854;
|
|
const minY = 3; // Grid extends to the top of the card
|
|
const maxY = 555;
|
|
|
|
// Scaling constants per original reference:
|
|
const dotmin = 1.5;
|
|
const dotsizebase = 4.5;
|
|
const decay = 0.3;
|
|
const DOT_HOVER_BRIGHTNESS = 0.03; // Extra brightness boost when hovered
|
|
const LOGO_HOVER_BRIGHTNESS = 0.28; // Peak brightness multiplier for logos
|
|
|
|
// Reaction delay & trailing lag constants:
|
|
const LERP_LAG = 0.07;
|
|
const FADE_IN_LAG = 0.07;
|
|
const FADE_OUT_LAG = 0.04;
|
|
|
|
// Pre-calculate dot grid coordinates
|
|
const dots: { x: number; y: number }[] = [];
|
|
for (let y = minY; y <= maxY; y += rowsize) {
|
|
for (let x = minX; x <= maxX; x += rowsize) {
|
|
dots.push({ x, y });
|
|
}
|
|
}
|
|
|
|
const maxRadius = ((dotsizebase - dotmin) / decay) * rowsize; // ~120px
|
|
|
|
// Cursor tracking state
|
|
let targetX = -1000;
|
|
let targetY = -1000;
|
|
let currentX = -1000;
|
|
let currentY = -1000;
|
|
let targetStrength = 0;
|
|
let currentStrength = 0;
|
|
let isHovering = false;
|
|
let animFrameId: number | null = null;
|
|
|
|
// Sizing & scaling state
|
|
let width = 0;
|
|
let height = 0;
|
|
let scale = 1;
|
|
let offsetX = 0;
|
|
|
|
const resize = () => {
|
|
const rect = canvas.getBoundingClientRect();
|
|
if (rect.width === 0 || rect.height === 0) return;
|
|
|
|
const dpr = window.devicePixelRatio || 1;
|
|
width = rect.width;
|
|
height = rect.height;
|
|
|
|
canvas.width = Math.round(width * dpr);
|
|
canvas.height = Math.round(height * dpr);
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
|
|
// Sizing matches Next.js Image object-cover object-top
|
|
scale = height / SVG_HEIGHT;
|
|
offsetX = (width - SVG_WIDTH * scale) / 2;
|
|
};
|
|
|
|
// Load the illuminated bright logo patterns for cursor reaction
|
|
const brightLogoImg = new Image();
|
|
let brightLogoLoaded = false;
|
|
brightLogoImg.onload = () => {
|
|
brightLogoLoaded = true;
|
|
};
|
|
brightLogoImg.src = "/assets/images/hero_logo_patterns.svg";
|
|
|
|
// Dedicated offscreen canvas for pixel-precise radial spotlight on logos
|
|
let spotCanvas: HTMLCanvasElement | null = null;
|
|
let spotCtx: CanvasRenderingContext2D | null = null;
|
|
if (typeof document !== "undefined") {
|
|
spotCanvas = document.createElement("canvas");
|
|
spotCtx = spotCanvas.getContext("2d");
|
|
}
|
|
|
|
// Bottom curved cutout path from line 1144 of hero_back.svg (covers bottom corners)
|
|
const bottomPath =
|
|
typeof Path2D !== "undefined"
|
|
? new Path2D(
|
|
"M1856 493H1888L1888 791H-31.5541L-31.5547 493H0.00195312C0.00195312 499.519 0.00195312 502.778 0.555702 505.716C2.74796 517.346 11.6409 527.165 22.9981 530.496C25.8668 531.337 28.968 531.645 35.1702 532.26C133.065 541.975 434.449 567.159 928.224 567.159C1421.91 567.159 1722.99 541.984 1820.82 532.265C1826.24 531.727 1828.95 531.458 1831.13 530.928C1843.95 527.808 1852.37 519.028 1854.94 506.09C1855.38 503.894 1855.59 500.264 1856 493.004L1856 493Z"
|
|
)
|
|
: null;
|
|
|
|
const draw = (t: number) => {
|
|
ctx.clearRect(0, 0, width, height);
|
|
|
|
// 1. Single-pass dot rendering:
|
|
// Preserves original look, with dynamic, fast acoustic sound-wave undulation
|
|
ctx.fillStyle = "rgba(255, 255, 255, 0.22)";
|
|
ctx.beginPath();
|
|
|
|
for (let i = 0; i < dots.length; i++) {
|
|
const dot = dots[i];
|
|
|
|
// Static dot grid (no wave animation on dots per user request)
|
|
const screenX = dot.x * scale + offsetX;
|
|
const screenY = dot.y * scale;
|
|
|
|
if (screenX < -10 || screenX > width + 10) continue;
|
|
|
|
let r = dotmin;
|
|
if (currentStrength > 0.005) {
|
|
const scaler = Math.hypot(
|
|
(currentX - dot.x) / rowsize,
|
|
(currentY - dot.y) / rowsize
|
|
);
|
|
const addedSize = Math.max(0, dotsizebase - dotmin - scaler * decay);
|
|
r += addedSize * currentStrength;
|
|
}
|
|
|
|
ctx.moveTo(screenX + r, screenY);
|
|
ctx.arc(screenX, screenY, r, 0, Math.PI * 2);
|
|
}
|
|
ctx.fill();
|
|
|
|
// 2. Apply vertical fade gradient mask matching original dotFadeMask
|
|
ctx.globalCompositeOperation = "destination-in";
|
|
const fadeGrad = ctx.createLinearGradient(0, 45 * scale, 0, 566 * scale);
|
|
fadeGrad.addColorStop(0, "rgba(255, 255, 255, 0)");
|
|
fadeGrad.addColorStop(0.42, "rgba(255, 255, 255, 0)");
|
|
fadeGrad.addColorStop(0.72, "rgba(255, 255, 255, 0.85)");
|
|
fadeGrad.addColorStop(1, "rgba(255, 255, 255, 1)");
|
|
ctx.fillStyle = fadeGrad;
|
|
ctx.fillRect(0, 0, width, height);
|
|
|
|
// 3. Cut out the bottom curved shape matching line 1144 of hero_back.svg
|
|
if (bottomPath) {
|
|
ctx.globalCompositeOperation = "destination-out";
|
|
ctx.save();
|
|
ctx.translate(offsetX, 0);
|
|
ctx.scale(scale, scale);
|
|
ctx.fill(bottomPath);
|
|
ctx.restore();
|
|
}
|
|
|
|
ctx.globalCompositeOperation = "source-over";
|
|
|
|
// 4. Subtle hover illumination on dots:
|
|
if (DOT_HOVER_BRIGHTNESS > 0.001 && currentStrength > 0.005) {
|
|
ctx.globalCompositeOperation = "source-atop";
|
|
const scX = currentX * scale + offsetX;
|
|
const scY = currentY * scale;
|
|
const pRad = maxRadius * scale;
|
|
const dotLight = ctx.createRadialGradient(scX, scY, 0, scX, scY, pRad);
|
|
dotLight.addColorStop(
|
|
0,
|
|
`rgba(255, 255, 255, ${(DOT_HOVER_BRIGHTNESS * 6 * currentStrength).toFixed(3)})`
|
|
);
|
|
dotLight.addColorStop(
|
|
0.6,
|
|
`rgba(255, 255, 255, ${(DOT_HOVER_BRIGHTNESS * 2 * currentStrength).toFixed(3)})`
|
|
);
|
|
dotLight.addColorStop(1, "rgba(255, 255, 255, 0)");
|
|
ctx.fillStyle = dotLight;
|
|
ctx.fillRect(scX - pRad, scY - pRad, pRad * 2, pRad * 2);
|
|
ctx.globalCompositeOperation = "source-over";
|
|
}
|
|
|
|
// 5. Pixel-precise radial spotlight for TeamBy logo patterns:
|
|
if (currentStrength > 0.005 && brightLogoLoaded && spotCanvas && spotCtx) {
|
|
const spotRadiusSvg = maxRadius;
|
|
const spotRadiusScreen = spotRadiusSvg * scale;
|
|
const spotSize = Math.ceil(spotRadiusScreen * 2);
|
|
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const targetW = Math.round(spotSize * dpr);
|
|
const targetH = Math.round(spotSize * dpr);
|
|
|
|
if (spotCanvas.width !== targetW || spotCanvas.height !== targetH) {
|
|
spotCanvas.width = targetW;
|
|
spotCanvas.height = targetH;
|
|
spotCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
}
|
|
|
|
const scX = currentX * scale + offsetX;
|
|
const scY = currentY * scale;
|
|
|
|
spotCtx.clearRect(0, 0, spotSize, spotSize);
|
|
spotCtx.save();
|
|
spotCtx.translate(spotRadiusScreen - scX, spotRadiusScreen - scY);
|
|
spotCtx.drawImage(
|
|
brightLogoImg,
|
|
offsetX,
|
|
0,
|
|
SVG_WIDTH * scale,
|
|
SVG_HEIGHT * scale
|
|
);
|
|
spotCtx.restore();
|
|
|
|
const dLeft = Math.hypot((currentX - 531) / 220, (currentY - 194) / 180);
|
|
const dRight = Math.hypot((currentX - 1325) / 220, (currentY - 194) / 180);
|
|
const orbLight = Math.max(0, 1 - Math.min(dLeft, dRight));
|
|
|
|
const tTop = Math.max(0, Math.min(1, (currentY - 20) / 140));
|
|
const topFade = tTop * tTop * (3 - 2 * tTop);
|
|
|
|
const defaultBrightness = (0.2 + 0.8 * orbLight) * topFade;
|
|
const peakBoost =
|
|
LOGO_HOVER_BRIGHTNESS *
|
|
(0.2 + 0.8 * defaultBrightness) *
|
|
topFade *
|
|
currentStrength;
|
|
|
|
spotCtx.globalCompositeOperation = "destination-in";
|
|
const rad = spotCtx.createRadialGradient(
|
|
spotRadiusScreen,
|
|
spotRadiusScreen,
|
|
0,
|
|
spotRadiusScreen,
|
|
spotRadiusScreen,
|
|
spotRadiusScreen
|
|
);
|
|
rad.addColorStop(0, `rgba(255, 255, 255, ${peakBoost.toFixed(3)})`);
|
|
rad.addColorStop(
|
|
0.5,
|
|
`rgba(255, 255, 255, ${(peakBoost * 0.45).toFixed(3)})`
|
|
);
|
|
rad.addColorStop(
|
|
0.85,
|
|
`rgba(255, 255, 255, ${(peakBoost * 0.12).toFixed(3)})`
|
|
);
|
|
rad.addColorStop(1, "rgba(255, 255, 255, 0)");
|
|
|
|
spotCtx.fillStyle = rad;
|
|
spotCtx.fillRect(0, 0, spotSize, spotSize);
|
|
spotCtx.globalCompositeOperation = "source-over";
|
|
|
|
ctx.drawImage(
|
|
spotCanvas,
|
|
scX - spotRadiusScreen,
|
|
scY - spotRadiusScreen,
|
|
spotSize,
|
|
spotSize
|
|
);
|
|
}
|
|
};
|
|
|
|
// Smooth animation loop for subtle acoustic dot waviness and hover reaction
|
|
const loop = () => {
|
|
if (isHovering) {
|
|
currentX += (targetX - currentX) * LERP_LAG;
|
|
currentY += (targetY - currentY) * LERP_LAG;
|
|
currentStrength += (targetStrength - currentStrength) * FADE_IN_LAG;
|
|
} else if (currentStrength > 0.001) {
|
|
currentStrength += (0 - currentStrength) * FADE_OUT_LAG;
|
|
} else {
|
|
currentStrength = 0;
|
|
}
|
|
|
|
const t = performance.now() * 0.001;
|
|
draw(t);
|
|
|
|
animFrameId = requestAnimationFrame(loop);
|
|
};
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
const section = sectionRef?.current;
|
|
const targetArea = section || canvas;
|
|
const sRect = targetArea.getBoundingClientRect();
|
|
|
|
if (
|
|
e.clientX >= sRect.left &&
|
|
e.clientX <= sRect.right &&
|
|
e.clientY >= sRect.top &&
|
|
e.clientY <= sRect.bottom
|
|
) {
|
|
const cRect = canvas.getBoundingClientRect();
|
|
targetX = (e.clientX - cRect.left - offsetX) / scale;
|
|
targetY = (e.clientY - cRect.top) / scale;
|
|
targetStrength = 1;
|
|
isHovering = true;
|
|
|
|
if (currentX < -500) {
|
|
currentX = targetX;
|
|
currentY = targetY;
|
|
}
|
|
} else if (isHovering) {
|
|
targetStrength = 0;
|
|
isHovering = false;
|
|
}
|
|
};
|
|
|
|
const handleMouseLeave = () => {
|
|
targetStrength = 0;
|
|
isHovering = false;
|
|
};
|
|
|
|
resize();
|
|
const ro = new ResizeObserver(() => resize());
|
|
ro.observe(canvas);
|
|
|
|
animFrameId = requestAnimationFrame(loop);
|
|
|
|
window.addEventListener("mousemove", handleMouseMove, { passive: true });
|
|
window.addEventListener("mouseleave", handleMouseLeave);
|
|
|
|
return () => {
|
|
ro.disconnect();
|
|
window.removeEventListener("mousemove", handleMouseMove);
|
|
window.removeEventListener("mouseleave", handleMouseLeave);
|
|
if (animFrameId) cancelAnimationFrame(animFrameId);
|
|
};
|
|
}, [sectionRef]);
|
|
|
|
return (
|
|
<canvas
|
|
ref={canvasRef}
|
|
className="absolute inset-0 w-full h-full pointer-events-none z-10"
|
|
/>
|
|
);
|
|
}
|