"use client"; import React, { useEffect, useRef } from "react"; interface HeroDotCanvasProps { sectionRef?: React.RefObject; } export function HeroDotCanvas({ sectionRef }: HeroDotCanvasProps) { const canvasRef = useRef(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; 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; let isRunning = false; // Sizing & scaling state let width = 0; let height = 0; let scale = 1; let offsetX = 0; let dpr = 1; // Cache rects to prevent layout thrashing on mousemove let canvasRect: DOMRect | null = null; let sectionRect: DOMRect | null = null; const updateRects = () => { canvasRect = canvas.getBoundingClientRect(); const section = sectionRef?.current; sectionRect = (section || canvas).getBoundingClientRect(); }; // Dedicated offscreen canvas for pre-rasterized logo pattern bitmap let logoCanvas: HTMLCanvasElement | null = null; let logoCtx: CanvasRenderingContext2D | null = null; if (typeof document !== "undefined") { logoCanvas = document.createElement("canvas"); logoCtx = logoCanvas.getContext("2d"); } // 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; // Pre-rasterize the logo pattern once into logoCanvas as GPU bitmap const renderLogoCanvas = () => { if (!logoCanvas || !logoCtx || !brightLogoLoaded || width === 0 || height === 0) return; logoCanvas.width = Math.round(width * dpr); logoCanvas.height = Math.round(height * dpr); logoCtx.setTransform(dpr, 0, 0, dpr, 0, 0); logoCtx.clearRect(0, 0, width, height); logoCtx.drawImage( brightLogoImg, offsetX, 0, SVG_WIDTH * scale, SVG_HEIGHT * scale ); }; // Load the illuminated bright logo patterns for cursor reaction const brightLogoImg = new Image(); let brightLogoLoaded = false; brightLogoImg.onload = () => { brightLogoLoaded = true; renderLogoCanvas(); }; brightLogoImg.src = "/assets/images/hero_logo_patterns.svg"; const resize = () => { const rect = canvas.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) return; // Cap DPR at 1.5 to prevent massive 6.5MP buffer overhead on high-DPI screens while keeping crispness dpr = Math.min(window.devicePixelRatio || 1, 1.5); 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); scale = height / SVG_HEIGHT; offsetX = (width - SVG_WIDTH * scale) / 2; updateRects(); renderLogoCanvas(); draw(); }; const draw = () => { ctx.clearRect(0, 0, width, height); // 1. Single-pass dot rendering with fast bounding-box skip (avoids 7,000 Math.hypot calls) ctx.fillStyle = "rgba(255, 255, 255, 0.22)"; ctx.beginPath(); const hasHover = currentStrength > 0.005; 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 (hasHover) { const dx = Math.abs(currentX - dot.x); const dy = Math.abs(currentY - dot.y); if (dx < maxRadius && dy < maxRadius) { const scaler = Math.hypot(dx / rowsize, dy / 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 && hasHover) { 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 using pre-rasterized bitmap if (hasHover && brightLogoLoaded && spotCanvas && spotCtx && logoCanvas) { const spotRadiusSvg = maxRadius; const spotRadiusScreen = spotRadiusSvg * scale; const spotSize = Math.ceil(spotRadiusScreen * 2); 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); // Blit from pre-rendered logo bitmap texture (instant GPU copy, no vector SVG re-parsing) spotCtx.drawImage(logoCanvas, 0, 0, width, height); 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 ); } }; // Demand-driven animation loop: Smoothly animates LERP, fades down to 0, then stops 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; } draw(); // When cursor leaves and fade-out is complete: draw clean default state once and SLEEP if (!isHovering && currentStrength <= 0.001) { currentStrength = 0; draw(); isRunning = false; animFrameId = null; return; } // When cursor rests motionless inside section: settle and SLEEP until next movement if ( isHovering && Math.abs(targetX - currentX) < 0.05 && Math.abs(targetY - currentY) < 0.05 && Math.abs(targetStrength - currentStrength) < 0.001 ) { currentX = targetX; currentY = targetY; currentStrength = targetStrength; draw(); isRunning = false; animFrameId = null; return; } animFrameId = requestAnimationFrame(loop); }; const startLoop = () => { if (!isRunning) { isRunning = true; animFrameId = requestAnimationFrame(loop); } }; const handleMouseMove = (e: MouseEvent) => { if (!sectionRect || !canvasRect) { updateRects(); } const sRect = sectionRect!; if ( e.clientX >= sRect.left && e.clientX <= sRect.right && e.clientY >= sRect.top && e.clientY <= sRect.bottom ) { const cRect = canvasRect!; 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 = () => { if (isHovering) { targetStrength = 0; isHovering = false; startLoop(); } }; const handleScrollOrResize = () => { updateRects(); }; resize(); const ro = new ResizeObserver(() => { resize(); }); ro.observe(canvas); window.addEventListener("mousemove", handleMouseMove, { passive: true }); window.addEventListener("mouseleave", handleMouseLeave); window.addEventListener("scroll", handleScrollOrResize, { passive: true }); window.addEventListener("resize", handleScrollOrResize, { passive: true }); return () => { ro.disconnect(); window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mouseleave", handleMouseLeave); window.removeEventListener("scroll", handleScrollOrResize); window.removeEventListener("resize", handleScrollOrResize); if (animFrameId) cancelAnimationFrame(animFrameId); }; }, [sectionRef]); return ( ); }