You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
447 lines
13 KiB
447 lines
13 KiB
"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>
|
|
);
|
|
}
|