4 changed files with 1454 additions and 1 deletions
-
233components/sections/HeroDotCanvas.tsx
-
9components/sections/HeroSection.tsx
-
1public/assets/images/hero_back.svg
-
1212public/assets/images/hero_back.svg.with_dots.bak
@ -0,0 +1,233 @@ |
|||
"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 = 51; |
|||
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; |
|||
|
|||
// 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(); |
|||
}; |
|||
|
|||
// 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. Draw all dots with their dynamic radius, using ONLY the single standard dot fill
|
|||
// No color change, no alpha change: only the scale changes!
|
|||
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 100% 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
|
|||
// Dots on bottom right and left with black background stay 100% invisible!
|
|||
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"; |
|||
}; |
|||
|
|||
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" |
|||
/> |
|||
); |
|||
} |
|||
1
public/assets/images/hero_back.svg
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
1212
public/assets/images/hero_back.svg.with_dots.bak
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
Write
Preview
Loading…
Cancel
Save
Reference in new issue