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.
 
 
 
 

336 lines
13 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 user reference:
// dotmin: default radius of the dots (1.5px)
// dotsizebase: peak radius directly under the cursor (2.5px - smaller and more refined)
// decay: size drop-off per grid unit (0.32) -> ~38px tight interaction radius
const dotmin = 1.5;
const dotsizebase = 4.5;
const decay = 0.3;
const DOT_HOVER_BRIGHTNESS = 0.03; // Extra brightness boost when hovered (0.13 = subtle/soft, increase for brighter dots)
const LOGO_HOVER_BRIGHTNESS = 0.28; // Peak brightness multiplier for logos (scales based on default brightness)
// Reaction delay & trailing lag constants:
// Lower values = smoother delay and trailing inertia
const LERP_LAG = 0.07; // Delay in tracking cursor movement
const FADE_IN_LAG = 0.07; // Delay to smoothly bloom up
const FADE_OUT_LAG = 0.04; // Lingering delay before returning to rest
// 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 numCols = Math.round((maxX - minX) / rowsize) + 1; // 155
const numRows = Math.round((maxY - minY) / rowsize) + 1; // 43
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;
draw();
};
// Load the illuminated bright logo patterns for cursor reaction
const brightLogoImg = new Image();
let brightLogoLoaded = false;
brightLogoImg.onload = () => {
brightLogoLoaded = true;
draw();
};
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 1145 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 = () => {
ctx.clearRect(0, 0, width, height);
// 1. Single-pass dot rendering:
// Every dot is drawn EXACTLY ONCE with its dynamic radius r
// Eliminates any duplicate dot layers completely!
ctx.fillStyle = "rgba(255, 255, 255, 0.18)";
ctx.beginPath();
for (let i = 0; i < dots.length; i++) {
const dot = dots[i];
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 = dotmin + 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 the SVG's dotFadeMask
// Invisible dots on top stay invisible; transparent dots stay transparent!
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 1145 of hero_back.svg:
// Preserves the authentic curved arch of the dots pattern (matching previous commits)
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:
// Uses source-atop so it ONLY gently highlights the already-drawn & masked dots,
// strictly guaranteeing that NO dots outside the bottom curve are ever illuminated!
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 the TeamBy logo patterns:
// - Logos DO NOT scale (preserve exact geometric dimensions)
// - Radial gradient centered at cursor provides continuous falloff
// - Any icon at the edge of the radius is partially illuminated only where the radius reaches!
if (currentStrength > 0.005 && brightLogoLoaded && spotCanvas && spotCtx) {
const spotRadiusSvg = maxRadius; // 120px in SVG units matching cursor reach
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;
// Clear offscreen spotlight canvas
spotCtx.clearRect(0, 0, spotSize, spotSize);
// Position the full bright logo SVG so that cursor point (scX, scY) lands at (spotRadiusScreen, spotRadiusScreen)
spotCtx.save();
spotCtx.translate(spotRadiusScreen - scX, spotRadiusScreen - scY);
spotCtx.drawImage(brightLogoImg, offsetX, 0, SVG_WIDTH * scale, SVG_HEIGHT * scale);
spotCtx.restore();
// Calculate the logo's intrinsic default brightness at this cursor position in hero_back.svg:
// - Logos under the glowing light orbs at (1325, 194) and (531, 194) are naturally bright
// - Logos in darker areas are naturally dark
// - Logos near the top taper to 0 via top atmospheric fade
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;
// Feather with smooth radial gradient centered at (spotRadiusScreen, spotRadiusScreen):
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";
// Blit the illuminated logo spotlight onto the main canvas
ctx.drawImage(spotCanvas, scX - spotRadiusScreen, scY - spotRadiusScreen, spotSize, spotSize);
}
};
const loop = () => {
if (isHovering) {
currentX += (targetX - currentX) * LERP_LAG;
currentY += (targetY - currentY) * LERP_LAG;
currentStrength += (targetStrength - currentStrength) * FADE_IN_LAG;
} else {
currentStrength += (0 - currentStrength) * FADE_OUT_LAG;
}
draw();
if (isHovering || currentStrength > 0.005) {
animFrameId = requestAnimationFrame(loop);
} else {
currentStrength = 0;
draw();
animFrameId = null;
}
};
const startLoop = () => {
if (!animFrameId) {
animFrameId = requestAnimationFrame(loop);
}
};
const handleMouseMove = (e: MouseEvent) => {
const section = sectionRef?.current;
const targetArea = section || canvas;
const sRect = targetArea.getBoundingClientRect();
// Check if cursor is anywhere within the hero section
if (
e.clientX >= sRect.left &&
e.clientX <= sRect.right &&
e.clientY >= sRect.top &&
e.clientY <= sRect.bottom
) {
const cRect = canvas.getBoundingClientRect();
// Convert client coordinates to SVG grid coordinate space
targetX = (e.clientX - cRect.left - offsetX) / scale;
targetY = (e.clientY - cRect.top) / scale;
targetStrength = 1;
isHovering = true;
if (currentX < -500) {
currentX = targetX;
currentY = targetY;
}
startLoop();
} else if (isHovering) {
targetStrength = 0;
isHovering = false;
startLoop();
}
};
const handleMouseLeave = () => {
targetStrength = 0;
isHovering = false;
startLoop();
};
resize();
const ro = new ResizeObserver(() => resize());
ro.observe(canvas);
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-0"
/>
);
}