Browse Source

feat: add HeroDotCanvas component with interactive mouse-following dot grid animation

master
sina_sajjadi 2 weeks ago
parent
commit
3d2e2e8e58
  1. 132
      components/sections/HeroDotCanvas.tsx
  2. 4
      components/sections/HeroSection.tsx
  3. 5
      public/assets/images/hero_back.svg
  4. 1013
      public/assets/images/hero_logo_patterns.svg
  5. 1013
      public/assets/images/hero_logo_patterns_dark.svg

132
components/sections/HeroDotCanvas.tsx

@ -22,7 +22,7 @@ export function HeroDotCanvas({ sectionRef }: HeroDotCanvasProps) {
const rowsize = 12;
const minX = 6;
const maxX = 1854;
const minY = 51;
const minY = 3; // Grid extends to the top of the card
const maxY = 555;
// Scaling constants per user reference:
@ -32,6 +32,8 @@ export function HeroDotCanvas({ sectionRef }: HeroDotCanvasProps) {
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
@ -86,6 +88,23 @@ export function HeroDotCanvas({ sectionRef }: HeroDotCanvasProps) {
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"
@ -132,7 +151,7 @@ export function HeroDotCanvas({ sectionRef }: HeroDotCanvasProps) {
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!
// Dots on bottom right and left with black background stay invisible at rest
if (bottomPath) {
ctx.globalCompositeOperation = "destination-out";
ctx.save();
@ -143,6 +162,115 @@ export function HeroDotCanvas({ sectionRef }: HeroDotCanvasProps) {
}
ctx.globalCompositeOperation = "source-over";
// 4. Subtle hover interaction:
// When hovered, scaled dots receive a gentle, soft brightness boost (~0.13 * ratio):
// - If a dot is invisible as default (on top or in dark corners): it softly emerges at ~0.13 alpha (never fully white!)
// - If a dot is already visible (0.18): it gently brightens to ~0.29 alpha
// - When cursor leaves: it smoothly fades back to its resting state
if (currentStrength > 0.005) {
const minCol = Math.max(0, Math.floor((currentX - maxRadius - minX) / rowsize));
const maxCol = Math.min(numCols - 1, Math.ceil((currentX + maxRadius - minX) / rowsize));
const minRow = Math.max(0, Math.floor((currentY - maxRadius - minY) / rowsize));
const maxRow = Math.min(numRows - 1, Math.ceil((currentY + maxRadius - minY) / rowsize));
for (let r = minRow; r <= maxRow; r++) {
const dotY = minY + r * rowsize;
const screenY = dotY * scale;
for (let c = minCol; c <= maxCol; c++) {
const dotX = minX + c * rowsize;
const screenX = dotX * scale + offsetX;
if (screenX < -10 || screenX > width + 10) continue;
const scaler = Math.hypot((currentX - dotX) / rowsize, (currentY - dotY) / rowsize);
const addedSize = Math.max(0, (dotsizebase - dotmin) - scaler * decay);
if (addedSize > 0) {
const radius = dotmin + addedSize * currentStrength;
const ratio = (addedSize / (dotsizebase - dotmin)) * currentStrength;
// Smooth top atmospheric fade: tapers smoothly from y=150 down to y=15
// ensuring dots never hit a sharp ceiling or sudden cut off
const t = Math.max(0, Math.min(1, (dotY - 15) / 135));
const topFade = t * t * (3 - 2 * t);
const alpha = DOT_HOVER_BRIGHTNESS * ratio * topFade;
if (alpha > 0.002) {
ctx.fillStyle = `rgba(255, 255, 255, ${alpha.toFixed(3)})`;
ctx.beginPath();
ctx.arc(screenX, screenY, radius, 0, Math.PI * 2);
ctx.fill();
}
}
}
}
}
// 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 = () => {

4
components/sections/HeroSection.tsx

@ -51,7 +51,7 @@ export function HeroSection() {
</div>
{/* Left Floating 3D Shape (Object_4178_61355: x: 479, y: 436, w: 56, h: 56) */}
<div className="absolute top-[436px] left-1/2 -translate-x-[calc(50%+481px)] w-[56px] h-[56px] pointer-events-none z-20">
<div className="absolute top-[436px] left-1/2 -translate-x-[calc(50%+481px)] w-[56px] h-[56px] pointer-events-none z-30">
<Image
src="/assets/images/object_left.png"
alt=""
@ -63,7 +63,7 @@ export function HeroSection() {
</div>
{/* Right Floating 3D Shape (Object_4178_61358: x: 1397, y: 458, w: 48, h: 48) */}
<div className="absolute top-[458px] left-1/2 -translate-x-[calc(50%-437px)] w-[48px] h-[48px] pointer-events-none z-20">
<div className="absolute top-[458px] left-1/2 -translate-x-[calc(50%-437px)] w-[48px] h-[48px] pointer-events-none z-30">
<Image
src="/assets/images/object_right.png"
alt=""

5
public/assets/images/hero_back.svg

@ -1030,6 +1030,7 @@
<g filter="url(#filter5_f_4179_62380)">
<path d="M-32 654.999L1888 655V455.001C1888 455.001 1532.5 535 928 535C323.5 535 -32 455 -32 455V654.999Z" fill="white"/>
</g>
<rect opacity="0.2" y="45" width="1856" height="521" fill="url(#pattern0_4179_62380)"/>
<g opacity="0.9">
<path d="M1344.17 392.934C1343.49 392.934 1342.95 393.481 1342.95 394.157C1342.95 394.832 1343.49 395.379 1344.17 395.379C1344.84 395.379 1345.39 394.832 1345.39 394.157C1345.39 393.481 1344.84 392.934 1344.17 392.934Z" fill="#9ECBFF"/>
<path d="M1296.49 490.723C1295.81 490.723 1295.27 491.27 1295.27 491.946C1295.27 492.621 1295.81 493.168 1296.49 493.168C1297.16 493.168 1297.71 492.621 1297.71 491.946C1297.71 491.27 1297.16 490.723 1296.49 490.723Z" fill="#9ECBFF"/>
@ -1180,7 +1181,9 @@
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="60" result="effect1_foregroundBlur_4179_62380"/>
</filter>
<pattern id="pattern0_4179_62380" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image0_4179_62380" transform="matrix(0.000538793 0 0 0.00191939 -0.0172414 -0.527831)"/>
</pattern>
<filter id="filter6_f_4179_62380" x="1037" y="-94" width="576" height="576" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>

1013
public/assets/images/hero_logo_patterns.svg
File diff suppressed because it is too large
View File

1013
public/assets/images/hero_logo_patterns_dark.svg
File diff suppressed because it is too large
View File

Loading…
Cancel
Save