Browse Source

feat: implement hero section with canvas-based animation and supporting assets

master
sina_sajjadi 1 week ago
parent
commit
2855f18268
  1. 2
      components/sections/AiCopilotSection.tsx
  2. 2
      components/sections/HeroDotCanvas.tsx
  3. 5
      components/sections/HeroSection.tsx
  4. 447
      components/sections/HeroWaves.tsx
  5. 1
      components/sections/heroStarlightDots.ts
  6. 1
      next.config.ts
  7. 25
      public/assets/images/hero_back.svg

2
components/sections/AiCopilotSection.tsx

@ -98,7 +98,7 @@ export function AiCopilotSection() {
{/* Middle Luminous Orb (Figma: 437x437 centered at x=608, y=218.5) */}
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[132.57%] h-[197.94%] pointer-events-none select-none z-10">
<AnimatedCopilotOrb />
<CopilotParticleCanvas />
{/* <CopilotParticleCanvas /> */}
</div>
</div>
</div>

2
components/sections/HeroDotCanvas.tsx

@ -328,7 +328,7 @@ export function HeroDotCanvas({ sectionRef }: HeroDotCanvasProps) {
return (
<canvas
ref={canvasRef}
className="absolute inset-0 w-full h-full pointer-events-none z-0"
className="absolute inset-0 w-full h-full pointer-events-none z-10"
/>
);
}

5
components/sections/HeroSection.tsx

@ -4,6 +4,7 @@ import Image from "next/image";
import Link from "next/link";
import { useRef } from "react";
import { HeroDotCanvas } from "./HeroDotCanvas";
import { HeroWaves } from "./HeroWaves";
export function HeroSection() {
const sectionRef = useRef<HTMLElement>(null);
@ -17,13 +18,15 @@ export function HeroSection() {
{/* Background Graphic & Curve (back_4178_60221: x: 32, y: 230, w: 1856, h: 566) */}
<div className="absolute top-[230px] left-1/2 -translate-x-1/2 w-[1856px] max-w-[calc(100vw-64px)] h-[566px] rounded-b-[32px] overflow-hidden z-0">
<Image
src="/assets/images/hero_back.svg?v=sound_waves_v5"
src="/assets/images/hero_back.svg?v=grav_v1"
alt=""
fill
priority
unoptimized
className="object-cover object-top select-none pointer-events-none"
/>
{/* Interactive Gravitational Water Waves */}
{/* <HeroWaves sectionRef={sectionRef} /> */}
{/* Interactive Canvas Dots */}
<HeroDotCanvas sectionRef={sectionRef} />
</div>

447
components/sections/HeroWaves.tsx

@ -0,0 +1,447 @@
"use client";
import React, { useEffect, useRef } from "react";
import { STARLIGHT_DOTS_MARKUP } from "./heroStarlightDots";
interface HeroWavesProps {
sectionRef?: React.RefObject<HTMLElement | null>;
}
interface LayerConfig {
baseY: number;
maxPull: number;
sigma: number;
baseline: number;
freq1: number;
speed1: number;
amp1: number;
freq2: number;
speed2: number;
amp2: number;
phase: number;
}
const LAYERS: LayerConfig[] = [
// Layer 1: Deep Blue Base (#0029B2, blur 55px)
{
baseY: 255,
maxPull: 85,
sigma: 290,
baseline: 0.22,
freq1: 0.0028,
speed1: 0.85,
amp1: 28,
freq2: 0.0055,
speed2: -1.1,
amp2: 16,
phase: 0,
},
// Layer 2: Mid-Ocean Blue (#3D6FE5, blur 30px, mask 2)
{
baseY: 295,
maxPull: 95,
sigma: 250,
baseline: 0.24,
freq1: 0.0033,
speed1: 1.15,
amp1: 32,
freq2: 0.0065,
speed2: -1.4,
amp2: 18,
phase: 1.4,
},
// Layer 3: Sky Blue Swell (#4B80FF, blur 60px, mask 3)
{
baseY: 350,
maxPull: 90,
sigma: 270,
baseline: 0.23,
freq1: 0.0036,
speed1: 0.95,
amp1: 34,
freq2: 0.007,
speed2: -1.25,
amp2: 18,
phase: 2.7,
},
// Layer 4: Luminous Atmosphere Indigo (#2D4D99, plus-lighter, blur 60px, mask 4)
{
baseY: 410,
maxPull: 75,
sigma: 280,
baseline: 0.2,
freq1: 0.003,
speed1: 0.75,
amp1: 30,
freq2: 0.0058,
speed2: -0.95,
amp2: 16,
phase: 3.9,
},
// Layer 5: Pure White Core Light (#FFFFFF, blur 60px, mask 5)
{
baseY: 485,
maxPull: 55,
sigma: 230,
baseline: 0.22,
freq1: 0.0034,
speed1: 0.8,
amp1: 24,
freq2: 0.0062,
speed2: -1.05,
amp2: 12,
phase: 0.8,
},
];
const NUM_POINTS = 64;
const X_MIN = -32;
const X_MAX = 1888;
const DX = (X_MAX - X_MIN) / (NUM_POINTS - 1);
// Pre-calculate X coordinates
const X_COORDS = new Float64Array(NUM_POINTS);
for (let i = 0; i < NUM_POINTS; i++) {
X_COORDS[i] = X_MIN + i * DX;
}
// Pre-allocated buffers for Y coordinates
const Y_BUFFERS = Array.from({ length: 5 }, () => new Float64Array(NUM_POINTS));
function buildSplinePath(xArr: Float64Array, yArr: Float64Array): string {
const n = NUM_POINTS;
let d = `M ${xArr[0].toFixed(1)} ${yArr[0].toFixed(1)}`;
for (let i = 0; i < n - 1; i++) {
const i0 = i === 0 ? 0 : i - 1;
const i1 = i;
const i2 = i + 1;
const i3 = i + 2 >= n ? n - 1 : i + 2;
const cp1x = xArr[i1] + (xArr[i2] - xArr[i0]) / 6;
const cp1y = yArr[i1] + (yArr[i2] - yArr[i0]) / 6;
const cp2x = xArr[i2] - (xArr[i3] - xArr[i1]) / 6;
const cp2y = yArr[i2] - (yArr[i3] - yArr[i1]) / 6;
d += ` C ${cp1x.toFixed(1)} ${cp1y.toFixed(1)}, ${cp2x.toFixed(1)} ${cp2y.toFixed(1)}, ${xArr[i2].toFixed(1)} ${yArr[i2].toFixed(1)}`;
}
d += " L 1888 855 L -32 855 Z";
return d;
}
export function HeroWaves({ sectionRef }: HeroWavesProps) {
const svgRef = useRef<SVGSVGElement>(null);
const pathRefs = [
useRef<SVGPathElement>(null),
useRef<SVGPathElement>(null),
useRef<SVGPathElement>(null),
useRef<SVGPathElement>(null),
useRef<SVGPathElement>(null),
];
useEffect(() => {
const SVG_WIDTH = 1856;
const SVG_HEIGHT = 566;
let width = 0;
let height = 0;
let scale = 1;
let offsetX = 0;
const resize = () => {
const svg = svgRef.current;
if (!svg) return;
const rect = svg.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
width = rect.width;
height = rect.height;
scale = height / SVG_HEIGHT;
offsetX = (width - SVG_WIDTH * scale) / 2;
};
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;
const LERP_LAG = 0.08;
const FADE_IN_LAG = 0.06;
const FADE_OUT_LAG = 0.04;
const updateWaves = (t: number) => {
for (let layerIdx = 0; layerIdx < LAYERS.length; layerIdx++) {
const cfg = LAYERS[layerIdx];
const yBuf = Y_BUFFERS[layerIdx];
// Gravitational attraction proximity:
// Strongest when cursor is near the layer's waterline, fading smoothly when far above
const distY = Math.abs(currentY - cfg.baseY);
const verticalProximity = Math.pow(
Math.max(0, Math.min(1, 1 - distY / 380)),
1.3
);
const pull = cfg.maxPull * verticalProximity * currentStrength;
for (let i = 0; i < NUM_POINTS; i++) {
const x = X_COORDS[i];
const dx = x - currentX;
// Gaussian tidal crest centered at cursor X
const bell = Math.exp(-(dx * dx) / (2 * cfg.sigma * cfg.sigma));
// Volume conservation: water pulled towards cursor causes surrounding water to recede
const tidalFactor = bell - cfg.baseline;
const tidalDisplacement = tidalFactor * pull;
// Harmonic ambient sea wave motion
const yAmbient =
cfg.baseY +
Math.sin(x * cfg.freq1 + t * cfg.speed1 + cfg.phase) * cfg.amp1 +
Math.cos(x * cfg.freq2 + t * cfg.speed2) * cfg.amp2;
// Dynamic ripple emanating from the gravitational center
const ripple =
Math.sin(dx * 0.022 - t * 3.2) *
Math.exp(-(dx * dx) / (2 * 160 * 160)) *
5 *
verticalProximity *
currentStrength;
// Pulling upward reduces SVG Y coordinate
yBuf[i] = yAmbient - tidalDisplacement - ripple;
}
// Directly mutate DOM path 'd' attribute for maximum 60-120 FPS performance
const pathEl = pathRefs[layerIdx].current;
if (pathEl) {
pathEl.setAttribute("d", buildSplinePath(X_COORDS, yBuf));
}
}
};
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;
}
const t = performance.now() * 0.001;
updateWaves(t);
animFrameId = requestAnimationFrame(loop);
};
const handleMouseMove = (e: MouseEvent) => {
const section = sectionRef?.current;
const svg = svgRef.current;
const targetArea = section || svg;
if (!targetArea) return;
const sRect = targetArea.getBoundingClientRect();
if (
e.clientX >= sRect.left &&
e.clientX <= sRect.right &&
e.clientY >= sRect.top &&
e.clientY <= sRect.bottom
) {
targetX = (e.clientX - sRect.left - offsetX) / scale;
targetY = (e.clientY - sRect.top) / scale;
targetStrength = 1;
isHovering = true;
if (currentX < -500) {
currentX = targetX;
currentY = targetY;
}
} else if (isHovering) {
targetStrength = 0;
isHovering = false;
}
};
const handleMouseLeave = () => {
targetStrength = 0;
isHovering = false;
};
resize();
const ro = new ResizeObserver(() => resize());
if (svgRef.current) ro.observe(svgRef.current);
animFrameId = requestAnimationFrame(loop);
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 (
<svg
ref={svgRef}
viewBox="0 0 1856 566"
preserveAspectRatio="xMidYMin slice"
className="absolute inset-0 w-full h-full pointer-events-none select-none z-[1]"
>
<defs>
{/* GPU Gaussian Blur Filters matching original Figma specifications */}
<filter
id="hw_filter1"
x="-300"
y="-50"
width="2500"
height="1000"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="55" />
</filter>
<filter
id="hw_filter2"
x="-300"
y="-50"
width="2500"
height="1000"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="30" />
</filter>
<filter
id="hw_filter3"
x="-152"
y="235"
width="2160"
height="640"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="60" />
</filter>
<filter
id="hw_filter4"
x="-152"
y="285"
width="2160"
height="540"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="60" />
</filter>
<filter
id="hw_filter5"
x="-152"
y="335"
width="2160"
height="440"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="60" />
</filter>
{/* Top Fade Gradient Masks for depth blending */}
<linearGradient id="hw_wave2Grad" x1="0" y1="600" x2="0" y2="150" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="white" stopOpacity="1" />
<stop offset="0.75" stopColor="white" stopOpacity="1" />
<stop offset="1" stopColor="white" stopOpacity="0" />
</linearGradient>
<mask id="hw_mask2" maskUnits="userSpaceOnUse" x="-300" y="0" width="2500" height="900">
<rect x="-300" y="0" width="2500" height="900" fill="url(#hw_wave2Grad)" />
</mask>
<linearGradient id="hw_wave3Grad" x1="0" y1="755" x2="0" y2="270" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="white" stopOpacity="1" />
<stop offset="0.7" stopColor="white" stopOpacity="1" />
<stop offset="1" stopColor="white" stopOpacity="0" />
</linearGradient>
<mask id="hw_mask3" maskUnits="userSpaceOnUse" x="-300" y="0" width="2500" height="900">
<rect x="-300" y="0" width="2500" height="900" fill="url(#hw_wave3Grad)" />
</mask>
<linearGradient id="hw_wave4Grad" x1="0" y1="705" x2="0" y2="320" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="white" stopOpacity="1" />
<stop offset="0.7" stopColor="white" stopOpacity="1" />
<stop offset="1" stopColor="white" stopOpacity="0" />
</linearGradient>
<mask id="hw_mask4" maskUnits="userSpaceOnUse" x="-300" y="0" width="2500" height="900">
<rect x="-300" y="0" width="2500" height="900" fill="url(#hw_wave4Grad)" />
</mask>
<linearGradient id="hw_wave5Grad" x1="0" y1="655" x2="0" y2="370" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="white" stopOpacity="1" />
<stop offset="0.7" stopColor="white" stopOpacity="1" />
<stop offset="1" stopColor="white" stopOpacity="0" />
</linearGradient>
<mask id="hw_mask5" maskUnits="userSpaceOnUse" x="-300" y="0" width="2500" height="900">
<rect x="-300" y="0" width="2500" height="900" fill="url(#hw_wave5Grad)" />
</mask>
</defs>
{/* 5 Dynamic Wave Layers */}
{/* Layer 1: Deep Blue Base */}
<g filter="url(#hw_filter1)">
<path ref={pathRefs[0]} fill="#0029B2" />
</g>
{/* Layer 2: Mid-Ocean Blue */}
<g filter="url(#hw_filter2)" mask="url(#hw_mask2)">
<path ref={pathRefs[1]} fill="#3D6FE5" />
</g>
{/* Layer 3: Sky Blue Swell */}
<g filter="url(#hw_filter3)" mask="url(#hw_mask3)">
<path ref={pathRefs[2]} fill="#4B80FF" />
</g>
{/* Layer 4: Atmospheric Luminous Indigo */}
<g
filter="url(#hw_filter4)"
mask="url(#hw_mask4)"
style={{ mixBlendMode: "plus-lighter" }}
>
<path ref={pathRefs[3]} fill="#2D4D99" />
</g>
{/* Layer 5: White Core Glow */}
<g filter="url(#hw_filter5)" mask="url(#hw_mask5)">
<path ref={pathRefs[4]} fill="#FFFFFF" />
</g>
{/* Floating Starlight Sparkle Dots */}
<g
className="pointer-events-none select-none"
dangerouslySetInnerHTML={{ __html: STARLIGHT_DOTS_MARKUP }}
/>
</svg>
);
}

1
components/sections/heroStarlightDots.ts
File diff suppressed because it is too large
View File

1
next.config.ts

@ -2,6 +2,7 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
allowedDevOrigins: ["192.168.1.64"],
};
export default nextConfig;

25
public/assets/images/hero_back.svg
File diff suppressed because it is too large
View File

Loading…
Cancel
Save