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

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