From f0bf527d8ef7d6a5bdf055a8a97f99f6500db3b2 Mon Sep 17 00:00:00 2001 From: sina_sajjadi Date: Sat, 5 Sep 2026 14:36:11 +0330 Subject: [PATCH] feat: add interactive canvas dot grid and hero section components with cursor-responsive hover effects --- components/sections/HeroDotCanvas.tsx | 146 +++++++++++++------------- components/sections/HeroSection.tsx | 2 +- public/assets/images/hero_back.svg | 35 ++++-- 3 files changed, 100 insertions(+), 83 deletions(-) diff --git a/components/sections/HeroDotCanvas.tsx b/components/sections/HeroDotCanvas.tsx index 108f127..bc1d22c 100644 --- a/components/sections/HeroDotCanvas.tsx +++ b/components/sections/HeroDotCanvas.tsx @@ -25,21 +25,17 @@ export function HeroDotCanvas({ sectionRef }: HeroDotCanvasProps) { 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 + // 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 (0.13 = subtle/soft, increase for brighter dots) - const LOGO_HOVER_BRIGHTNESS = 0.28; // Peak brightness multiplier for logos (scales based on default brightness) + 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: - // 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 + 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 }[] = []; @@ -49,8 +45,6 @@ export function HeroDotCanvas({ sectionRef }: HeroDotCanvasProps) { } } - 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 @@ -84,8 +78,6 @@ export function HeroDotCanvas({ sectionRef }: HeroDotCanvasProps) { // 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 @@ -93,7 +85,6 @@ export function HeroDotCanvas({ sectionRef }: HeroDotCanvasProps) { let brightLogoLoaded = false; brightLogoImg.onload = () => { brightLogoLoaded = true; - draw(); }; brightLogoImg.src = "/assets/images/hero_logo_patterns.svg"; @@ -105,9 +96,7 @@ export function HeroDotCanvas({ sectionRef }: HeroDotCanvasProps) { spotCtx = spotCanvas.getContext("2d"); } - - - // Bottom curved cutout path from line 1145 of hero_back.svg (covers bottom corners) + // Bottom curved cutout path from line 1144 of hero_back.svg (covers bottom corners) const bottomPath = typeof Path2D !== "undefined" ? new Path2D( @@ -115,16 +104,18 @@ export function HeroDotCanvas({ sectionRef }: HeroDotCanvasProps) { ) : null; - const draw = () => { + const draw = (t: number) => { 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)"; + // 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; @@ -132,9 +123,12 @@ export function HeroDotCanvas({ sectionRef }: HeroDotCanvasProps) { 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; + 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); @@ -142,8 +136,7 @@ export function HeroDotCanvas({ sectionRef }: HeroDotCanvasProps) { } ctx.fill(); - // 2. Apply vertical fade gradient mask matching the SVG's dotFadeMask - // Invisible dots on top stay invisible; transparent dots stay transparent! + // 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)"); @@ -153,8 +146,7 @@ export function HeroDotCanvas({ sectionRef }: HeroDotCanvasProps) { 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) + // 3. Cut out the bottom curved shape matching line 1144 of hero_back.svg if (bottomPath) { ctx.globalCompositeOperation = "destination-out"; ctx.save(); @@ -167,28 +159,29 @@ export function HeroDotCanvas({ sectionRef }: HeroDotCanvasProps) { 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( + 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! + // 5. Pixel-precise radial spotlight for TeamBy logo patterns: if (currentStrength > 0.005 && brightLogoLoaded && spotCanvas && spotCtx) { - const spotRadiusSvg = maxRadius; // 120px in SVG units matching cursor reach + const spotRadiusSvg = maxRadius; const spotRadiusScreen = spotRadiusSvg * scale; const spotSize = Math.ceil(spotRadiusScreen * 2); @@ -205,19 +198,18 @@ export function HeroDotCanvas({ sectionRef }: HeroDotCanvasProps) { 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.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)); @@ -226,52 +218,62 @@ export function HeroDotCanvas({ sectionRef }: HeroDotCanvasProps) { 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; + 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 + 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( + 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); + 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 { + } else if (currentStrength > 0.001) { 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 t = performance.now() * 0.001; + draw(t); + + animFrameId = requestAnimationFrame(loop); }; const handleMouseMove = (e: MouseEvent) => { @@ -279,7 +281,6 @@ export function HeroDotCanvas({ sectionRef }: HeroDotCanvasProps) { 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 && @@ -287,7 +288,6 @@ export function HeroDotCanvas({ sectionRef }: HeroDotCanvasProps) { 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; @@ -297,25 +297,23 @@ export function HeroDotCanvas({ sectionRef }: HeroDotCanvasProps) { 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); + animFrameId = requestAnimationFrame(loop); + window.addEventListener("mousemove", handleMouseMove, { passive: true }); window.addEventListener("mouseleave", handleMouseLeave); diff --git a/components/sections/HeroSection.tsx b/components/sections/HeroSection.tsx index 1d13a16..c5e1c54 100644 --- a/components/sections/HeroSection.tsx +++ b/components/sections/HeroSection.tsx @@ -17,7 +17,7 @@ export function HeroSection() { {/* Background Graphic & Curve (back_4178_60221: x: 32, y: 230, w: 1856, h: 566) */}
- + + + - + + + - - + + + + - - + + - - + + @@ -1202,6 +1212,15 @@ + + + + + + + + +