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.
418 lines
12 KiB
418 lines
12 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;
|
|
blur: number;
|
|
isAdditive?: boolean;
|
|
}
|
|
|
|
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,
|
|
blur: 32,
|
|
},
|
|
// Layer 2: Mid-Ocean Blue (#3D6FE5, blur 18px, mask 2: y1=600, y2=150)
|
|
{
|
|
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,
|
|
blur: 18,
|
|
},
|
|
// Layer 3: Sky Blue Swell (#4B80FF, blur 34px, mask 3: y1=755, y2=270)
|
|
{
|
|
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,
|
|
blur: 34,
|
|
},
|
|
// Layer 4: Luminous Atmosphere Indigo (#2D4D99, plus-lighter, blur 34px, mask 4: y1=705, y2=320)
|
|
{
|
|
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,
|
|
blur: 34,
|
|
isAdditive: true,
|
|
},
|
|
// Layer 5: Pure White Core Light (#FFFFFF, blur 34px, mask 5: y1=655, y2=370)
|
|
{
|
|
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,
|
|
blur: 34,
|
|
},
|
|
];
|
|
|
|
const SVG_WIDTH = 1856;
|
|
const SVG_HEIGHT = 566;
|
|
|
|
const NUM_POINTS = 64;
|
|
const X_MIN = -32;
|
|
const X_MAX = 1888;
|
|
const DX = (X_MAX - X_MIN) / (NUM_POINTS - 1);
|
|
|
|
// Pre-calculated 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 drawSplinePath(ctx: CanvasRenderingContext2D, xArr: Float64Array, yArr: Float64Array) {
|
|
const n = NUM_POINTS;
|
|
ctx.beginPath();
|
|
ctx.moveTo(xArr[0], yArr[0]);
|
|
|
|
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;
|
|
|
|
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, xArr[i2], yArr[i2]);
|
|
}
|
|
|
|
ctx.lineTo(1888, 855);
|
|
ctx.lineTo(-32, 855);
|
|
ctx.closePath();
|
|
}
|
|
|
|
export function HeroWaves({ sectionRef }: HeroWavesProps) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
const canvas = canvasRef.current;
|
|
if (!container || !canvas) return;
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return;
|
|
|
|
let width = 0;
|
|
let height = 0;
|
|
let scale = 1;
|
|
let offsetX = 0;
|
|
const DPR_SCALE = 0.35; // 0.35x internal resolution eliminates 88% GPU raster cost while blur remains identical
|
|
|
|
let cachedFills: (string | CanvasGradient)[] = [];
|
|
|
|
const updateGradients = () => {
|
|
// Layer 0: solid deep blue #0029B2
|
|
// Layer 1: mid-ocean blue gradient y1=600 to y2=150
|
|
const g1 = ctx.createLinearGradient(0, 600, 0, 150);
|
|
g1.addColorStop(0, "rgba(61, 111, 229, 1)");
|
|
g1.addColorStop(0.75, "rgba(61, 111, 229, 1)");
|
|
g1.addColorStop(1, "rgba(61, 111, 229, 0)");
|
|
|
|
// Layer 2: sky blue swell gradient y1=755 to y2=270
|
|
const g2 = ctx.createLinearGradient(0, 755, 0, 270);
|
|
g2.addColorStop(0, "rgba(75, 128, 255, 1)");
|
|
g2.addColorStop(0.7, "rgba(75, 128, 255, 1)");
|
|
g2.addColorStop(1, "rgba(75, 128, 255, 0)");
|
|
|
|
// Layer 3: atmospheric indigo gradient y1=705 to y2=320 (lighter blend)
|
|
const g3 = ctx.createLinearGradient(0, 705, 0, 320);
|
|
g3.addColorStop(0, "rgba(45, 77, 153, 1)");
|
|
g3.addColorStop(0.7, "rgba(45, 77, 153, 1)");
|
|
g3.addColorStop(1, "rgba(45, 77, 153, 0)");
|
|
|
|
// Layer 4: white core light gradient y1=655 to y2=370
|
|
const g4 = ctx.createLinearGradient(0, 655, 0, 370);
|
|
g4.addColorStop(0, "rgba(255, 255, 255, 1)");
|
|
g4.addColorStop(0.7, "rgba(255, 255, 255, 1)");
|
|
g4.addColorStop(1, "rgba(255, 255, 255, 0)");
|
|
|
|
cachedFills = ["#0029B2", g1, g2, g3, g4];
|
|
};
|
|
|
|
const resize = () => {
|
|
const rect = container.getBoundingClientRect();
|
|
if (rect.width === 0 || rect.height === 0) return;
|
|
width = rect.width;
|
|
height = rect.height;
|
|
|
|
scale = Math.max(width / SVG_WIDTH, height / SVG_HEIGHT);
|
|
offsetX = (width - SVG_WIDTH * scale) / 2;
|
|
|
|
const targetW = Math.max(1, Math.round(width * DPR_SCALE));
|
|
const targetH = Math.max(1, Math.round(height * DPR_SCALE));
|
|
|
|
if (canvas.width !== targetW || canvas.height !== targetH) {
|
|
canvas.width = targetW;
|
|
canvas.height = targetH;
|
|
}
|
|
|
|
ctx.setTransform(scale * DPR_SCALE, 0, 0, scale * DPR_SCALE, offsetX * DPR_SCALE, 0);
|
|
updateGradients();
|
|
};
|
|
|
|
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;
|
|
let isInView = true;
|
|
let lastDrawTime = 0;
|
|
|
|
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;
|
|
}
|
|
}
|
|
};
|
|
|
|
const render = () => {
|
|
// Clear canvas buffer completely
|
|
ctx.save();
|
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
ctx.filter = "none";
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
ctx.restore();
|
|
|
|
for (let i = 0; i < LAYERS.length; i++) {
|
|
const cfg = LAYERS[i];
|
|
const yBuf = Y_BUFFERS[i];
|
|
|
|
ctx.filter = `blur(${cfg.blur * scale * DPR_SCALE}px)`;
|
|
|
|
if (cfg.isAdditive) {
|
|
ctx.globalCompositeOperation = "lighter";
|
|
}
|
|
|
|
drawSplinePath(ctx, X_COORDS, yBuf);
|
|
ctx.fillStyle = cachedFills[i];
|
|
ctx.fill();
|
|
|
|
if (cfg.isAdditive) {
|
|
ctx.globalCompositeOperation = "source-over";
|
|
}
|
|
}
|
|
|
|
ctx.filter = "none";
|
|
};
|
|
|
|
const loop = (now: number) => {
|
|
animFrameId = requestAnimationFrame(loop);
|
|
|
|
if (!isInView || document.hidden) return;
|
|
|
|
const isActive = isHovering || currentStrength > 0.0005;
|
|
const minInterval = isActive ? 1000 / 30 : 1000 / 15; // 30fps when active, 15fps when idle ambient
|
|
|
|
const elapsed = now - lastDrawTime;
|
|
if (elapsed < minInterval) return;
|
|
lastDrawTime = now - (elapsed % minInterval);
|
|
|
|
if (isHovering) {
|
|
currentX += (targetX - currentX) * LERP_LAG;
|
|
currentY += (targetY - currentY) * LERP_LAG;
|
|
currentStrength += (targetStrength - currentStrength) * FADE_IN_LAG;
|
|
} else if (currentStrength > 0.0005) {
|
|
currentStrength += (0 - currentStrength) * FADE_OUT_LAG;
|
|
} else {
|
|
currentStrength = 0;
|
|
}
|
|
|
|
const t = now * 0.001;
|
|
updateWaves(t);
|
|
render();
|
|
};
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
const section = sectionRef?.current;
|
|
const targetArea = section || container;
|
|
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());
|
|
ro.observe(container);
|
|
|
|
const io = new IntersectionObserver(
|
|
([entry]) => {
|
|
isInView = entry.isIntersecting;
|
|
},
|
|
{ threshold: 0 }
|
|
);
|
|
io.observe(container);
|
|
|
|
animFrameId = requestAnimationFrame(loop);
|
|
|
|
window.addEventListener("mousemove", handleMouseMove, { passive: true });
|
|
window.addEventListener("mouseleave", handleMouseLeave);
|
|
|
|
const handleVisibility = () => {
|
|
if (document.hidden) {
|
|
lastDrawTime = performance.now();
|
|
}
|
|
};
|
|
document.addEventListener("visibilitychange", handleVisibility);
|
|
|
|
return () => {
|
|
ro.disconnect();
|
|
io.disconnect();
|
|
window.removeEventListener("mousemove", handleMouseMove);
|
|
window.removeEventListener("mouseleave", handleMouseLeave);
|
|
document.removeEventListener("visibilitychange", handleVisibility);
|
|
if (animFrameId) cancelAnimationFrame(animFrameId);
|
|
};
|
|
}, [sectionRef]);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="absolute inset-0 w-full h-full pointer-events-none select-none z-[1]"
|
|
>
|
|
{/* High-Performance Hardware-Accelerated Canvas Waves */}
|
|
<canvas
|
|
ref={canvasRef}
|
|
className="absolute inset-0 w-full h-full pointer-events-none select-none"
|
|
/>
|
|
|
|
{/* Floating Starlight Sparkle Dots (Static Zero-GPU SVG Overlay) */}
|
|
<svg
|
|
viewBox="0 0 1856 566"
|
|
preserveAspectRatio="xMidYMin slice"
|
|
className="absolute inset-0 w-full h-full pointer-events-none select-none z-[2]"
|
|
dangerouslySetInnerHTML={{ __html: STARLIGHT_DOTS_MARKUP }}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|