Browse Source

feat: implement HeroSection with interactive dot canvas and assets

master
sina_sajjadi 2 weeks ago
parent
commit
7991d08e53
  1. 233
      components/sections/HeroDotCanvas.tsx
  2. 9
      components/sections/HeroSection.tsx
  3. 1
      public/assets/images/hero_back.svg
  4. 1212
      public/assets/images/hero_back.svg.with_dots.bak

233
components/sections/HeroDotCanvas.tsx

@ -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"
/>
);
}

9
components/sections/HeroSection.tsx

@ -1,9 +1,16 @@
"use client";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRef } from "react";
import { HeroDotCanvas } from "./HeroDotCanvas";
export function HeroSection() { export function HeroSection() {
const sectionRef = useRef<HTMLElement>(null);
return ( return (
<section <section
ref={sectionRef}
id="hero-section" id="hero-section"
className="relative w-full h-[796px] flex flex-col items-center pointer-events-none" className="relative w-full h-[796px] flex flex-col items-center pointer-events-none"
> >
@ -17,6 +24,8 @@ export function HeroSection() {
unoptimized unoptimized
className="object-cover object-top select-none pointer-events-none" className="object-cover object-top select-none pointer-events-none"
/> />
{/* Interactive Canvas Dots */}
<HeroDotCanvas sectionRef={sectionRef} />
</div> </div>
{/* Particle Starfield Lights (Light_4178_61305 & Light_4178_61255) */} {/* Particle Starfield Lights (Light_4178_61305 & Light_4178_61255) */}

1
public/assets/images/hero_back.svg
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

Loading…
Cancel
Save