This commit is contained in:
2026-01-29 23:27:24 +01:00
parent eba4905706
commit 98d3ee2198
2 changed files with 176 additions and 105 deletions

View File

@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { useEffect, useRef, useState, useCallback } from 'react';
import { motion, useScroll, useTransform, useSpring, useAnimationFrame } from 'framer-motion';
interface FlowingPathProps {
@@ -36,26 +36,42 @@ export const FlowingPath: React.FC<FlowingPathProps> = ({ className = '' }) => {
scrollYProgress,
(latest) => {
if (dimensions.height === 0 || vh === 0) return latest;
// Total scrollable distance
const scrollRange = dimensions.height - vh;
const currentScrollY = latest * scrollRange;
const targetY = currentScrollY + vh / 2;
// We want the dot to be at y = currentScrollY + vh/2
// But at the very top (latest=0), we want it at y=0 (or above)
// At the very bottom (latest=1), we want it at y=dimensions.height
let targetY;
if (latest < 0.05) {
// Transition from top of page to center of viewport
targetY = (latest / 0.05) * (vh / 2);
} else if (latest > 0.95) {
// Transition from center of viewport to bottom of page
const p = (latest - 0.95) / 0.05;
targetY = (currentScrollY + vh / 2) * (1 - p) + dimensions.height * p;
} else {
targetY = currentScrollY + vh / 2;
}
return Math.max(0, Math.min(1, targetY / dimensions.height));
}
);
// Smooth spring animation for the path drawing
const smoothProgress = useSpring(centeredProgress, {
stiffness: 40,
damping: 25,
stiffness: 100, // Much higher stiffness for immediate response
damping: 40, // Higher damping to prevent any overshoot/backward motion
restDelta: 0.0001,
mass: 0.5, // Lower mass for faster acceleration
});
// Transform scroll progress to path drawing
const pathDrawn = useTransform(smoothProgress, [0, 1], [0, 1]);
// Accent path transform
const accentPathDrawn = useTransform(smoothProgress, [0, 1], [0, 0.98]);
// Dot transforms
const dot1Distance = useTransform(smoothProgress, [0, 1], ['0%', '100%']);
const dot2Distance = useTransform(smoothProgress, [0, 1], ['0%', '98%']);
@@ -105,8 +121,9 @@ export const FlowingPath: React.FC<FlowingPathProps> = ({ className = '' }) => {
const curr = nodes[i];
if (curr.type === 'curve') {
const cp1y = prev.y + (curr.y - prev.y) * 0.5;
const cp2y = prev.y + (curr.y - prev.y) * 0.5;
const dy = curr.y - prev.y;
const cp1y = prev.y + dy * 0.4;
const cp2y = prev.y + dy * 0.6;
path += ` C ${prev.x} ${cp1y}, ${curr.x} ${cp2y}, ${curr.x} ${curr.y}`;
} else {
path += ` L ${curr.x} ${curr.y}`;
@@ -116,30 +133,45 @@ export const FlowingPath: React.FC<FlowingPathProps> = ({ className = '' }) => {
return path;
}, []);
// Generate secondary decorative paths
const generateSecondaryPath = useCallback((width: number, height: number, offset: number): string => {
const margin = width < 768 ? 10 : 40 + offset;
const contentWidth = Math.min(width * 0.85, 1000);
// Generate secondary decorative paths that follow the main path with some divergence
const generateSecondaryPath = useCallback((width: number, height: number, seed: number): string => {
const margin = width < 768 ? 20 : 80;
const contentWidth = Math.min(width * 0.9, 1200);
const leftEdge = (width - contentWidth) / 2 - margin;
const rightEdge = (width + contentWidth) / 2 + margin;
const centerX = width / 2;
const sectionHeight = height / 8;
let path = `M ${width / 2 + offset} ${-100}`;
const points = [
{ x: leftEdge + offset, y: sectionHeight * 1.2 },
{ x: rightEdge - offset, y: sectionHeight * 2.5 },
{ x: leftEdge - offset, y: sectionHeight * 4.0 },
{ x: rightEdge + offset, y: sectionHeight * 5.5 },
{ x: width / 2 + offset, y: height + 100 },
const random = (i: number) => {
const x = Math.sin(seed + i) * 10000;
return x - Math.floor(x);
};
// Base nodes similar to generatePath but with randomized offsets
const nodes = [
{ x: centerX - 100 + (random(0) - 0.5) * 40, y: 0 },
{ x: centerX - 100 + (random(1) - 0.5) * 40, y: sectionHeight * 0.2 },
{ x: rightEdge + (random(2) - 0.5) * 60, y: sectionHeight * 0.8 },
{ x: rightEdge - 50 + (random(3) - 0.5) * 60, y: sectionHeight * 1.5 },
{ x: leftEdge + 50 + (random(4) - 0.5) * 60, y: sectionHeight * 2.2 },
{ x: leftEdge + (random(5) - 0.5) * 60, y: sectionHeight * 3.0 },
{ x: centerX + 150 + (random(6) - 0.5) * 80, y: sectionHeight * 3.8 },
{ x: centerX - 150 + (random(7) - 0.5) * 80, y: sectionHeight * 4.6 },
{ x: rightEdge - 20 + (random(8) - 0.5) * 60, y: sectionHeight * 5.4 },
{ x: rightEdge - 100 + (random(9) - 0.5) * 60, y: sectionHeight * 6.2 },
{ x: leftEdge + 100 + (random(10) - 0.5) * 60, y: sectionHeight * 7.0 },
{ x: centerX + (random(11) - 0.5) * 40, y: sectionHeight * 7.6 },
{ x: centerX + (random(12) - 0.5) * 40, y: height }
];
for (let i = 0; i < points.length; i++) {
const curr = points[i];
const prev = i > 0 ? points[i - 1] : { x: width / 2 + offset, y: -100 };
const cp1y = prev.y + (curr.y - prev.y) * 0.5;
const cp2y = prev.y + (curr.y - prev.y) * 0.5;
let path = `M ${nodes[0].x} ${nodes[0].y}`;
for (let i = 1; i < nodes.length; i++) {
const prev = nodes[i - 1];
const curr = nodes[i];
const dy = curr.y - prev.y;
const cp1y = prev.y + dy * 0.4;
const cp2y = prev.y + dy * 0.6;
path += ` C ${prev.x} ${cp1y}, ${curr.x} ${cp2y}, ${curr.x} ${curr.y}`;
}
@@ -196,13 +228,13 @@ export const FlowingPath: React.FC<FlowingPathProps> = ({ className = '' }) => {
<div
ref={containerRef}
className={`absolute inset-0 pointer-events-none overflow-visible ${className}`}
style={{
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: dimensions.height,
zIndex: 5,
zIndex: 1,
}}
aria-hidden="true"
>
@@ -219,35 +251,36 @@ export const FlowingPath: React.FC<FlowingPathProps> = ({ className = '' }) => {
<feGaussianBlur stdDeviation="4" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
<filter id="dotGlow" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feColorMatrix type="matrix" values="0 0 0 0 0.58 0 0 0 0 0.64 0 0 0 0 0.72 0 0 0 1 0" />
<filter id="dotGlow" x="-200%" y="-200%" width="500%" height="500%">
<feGaussianBlur stdDeviation="5" result="blur" />
<feColorMatrix type="matrix" values="0 0 0 0 0.2 0 0 0 0 0.2 0 0 0 0 0.2 0 0 0 1 0" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<linearGradient id="lineGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="rgba(148, 163, 184, 0.05)" />
<stop offset="20%" stopColor="rgba(148, 163, 184, 0.3)" />
<stop offset="50%" stopColor="rgba(148, 163, 184, 0.6)" />
<stop offset="80%" stopColor="rgba(148, 163, 184, 0.3)" />
<stop offset="100%" stopColor="rgba(148, 163, 184, 0.05)" />
<stop offset="0%" stopColor="rgba(0, 0, 0, 0.05)" />
<stop offset="20%" stopColor="rgba(0, 0, 0, 0.2)" />
<stop offset="50%" stopColor="rgba(0, 0, 0, 0.4)" />
<stop offset="80%" stopColor="rgba(0, 0, 0, 0.2)" />
<stop offset="100%" stopColor="rgba(0, 0, 0, 0.05)" />
</linearGradient>
</defs>
{/* Background decorative paths - static, very subtle */}
<path
d={generateSecondaryPath(dimensions.width, dimensions.height, 40)}
stroke="rgba(148, 163, 184, 0.15)"
d={generateSecondaryPath(dimensions.width, dimensions.height, 10)}
stroke="rgba(0, 0, 0, 0.1)"
strokeWidth="1"
fill="none"
strokeLinecap="round"
strokeDasharray="5,10"
/>
<path
d={generateSecondaryPath(dimensions.width, dimensions.height, -30)}
stroke="rgba(148, 163, 184, 0.1)"
d={generateSecondaryPath(dimensions.width, dimensions.height, 20)}
stroke="rgba(0, 0, 0, 0.08)"
strokeWidth="0.5"
fill="none"
strokeLinecap="round"
@@ -258,7 +291,7 @@ export const FlowingPath: React.FC<FlowingPathProps> = ({ className = '' }) => {
ref={pathRef}
d={pathData}
stroke="url(#lineGradient)"
strokeWidth="1.5"
strokeWidth="2"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
@@ -268,45 +301,97 @@ export const FlowingPath: React.FC<FlowingPathProps> = ({ className = '' }) => {
}}
/>
{/* Accent path that follows slightly behind */}
<motion.path
{/* Accent path - fully filled, static */}
<path
d={pathData}
stroke="rgba(203, 213, 225, 0.2)"
strokeWidth="3"
stroke="rgba(0, 0, 0, 0.05)"
strokeWidth="4"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
style={{
pathLength: accentPathDrawn,
}}
/>
{/* Particle System */}
<ParticleSystem
{/* Secondary accent path - fully filled, static */}
<path
d={pathData}
stroke="rgba(0, 0, 0, 0.03)"
strokeWidth="8"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
transform="translate(5, 5)"
/>
{/* Extra static dashed lines with unique random paths */}
<path
d={generateSecondaryPath(dimensions.width, dimensions.height, 123)}
stroke="rgba(0, 0, 0, 0.15)"
strokeWidth="1.5"
fill="none"
strokeDasharray="10,20"
/>
<path
d={generateSecondaryPath(dimensions.width, dimensions.height, 456)}
stroke="rgba(0, 0, 0, 0.12)"
strokeWidth="1"
fill="none"
strokeDasharray="5,15"
/>
<path
d={generateSecondaryPath(dimensions.width, dimensions.height, 789)}
stroke="rgba(0, 0, 0, 0.1)"
strokeWidth="0.8"
fill="none"
strokeDasharray="2,10"
/>
{/* Pulse System */}
<PulseSystem
pathRef={pathRef}
progress={smoothProgress}
pathLength={pathLength}
checkpoints={[0.12, 0.28, 0.45, 0.62, 0.82]}
/>
{/* Animated dot that travels along the path */}
<motion.circle
r="4"
fill="rgba(30, 41, 59, 1)"
r="6"
fill="white"
stroke="black"
strokeWidth="1.5"
filter="url(#dotGlow)"
style={{
offsetPath: `path("${pathData}")`,
offsetDistance: dot1Distance,
}}
animate={{
scale: [1, 1.2, 1],
opacity: [0.8, 1, 0.8],
}}
transition={{
duration: 2,
repeat: Infinity,
ease: "easeInOut"
}}
/>
{/* Secondary traveling dot */}
<motion.circle
r="2"
fill="rgba(148, 163, 184, 0.6)"
r="3"
fill="rgba(0, 0, 0, 0.4)"
style={{
offsetPath: `path("${pathData}")`,
offsetDistance: dot2Distance,
}}
animate={{
scale: [1, 1.3, 1],
translateX: [0, 5, -5, 0],
}}
transition={{
duration: 3,
repeat: Infinity,
ease: "easeInOut"
}}
/>
{/* Node markers at key positions */}
@@ -385,76 +470,62 @@ const NodeMarker: React.FC<NodeMarkerProps> = ({ pos, pathData, progress }) => {
);
};
interface Particle {
interface Pulse {
id: number;
x: number;
y: number;
vx: number;
vy: number;
life: number;
size: number;
}
const ParticleSystem: React.FC<{
const PulseSystem: React.FC<{
pathRef: React.RefObject<SVGPathElement>;
progress: any;
pathLength: number;
}> = ({ pathRef, progress, pathLength }) => {
const [particles, setParticles] = useState<Particle[]>([]);
const lastPos = useRef({ x: 0, y: 0 });
const particleId = useRef(0);
checkpoints: number[];
}> = ({ pathRef, progress, pathLength, checkpoints }) => {
const [pulses, setPulses] = useState<Pulse[]>([]);
const lastProgress = useRef(0);
const pulseId = useRef(0);
useAnimationFrame((time, delta) => {
if (!pathRef.current || pathLength === 0) return;
// Get current position of the dot
const currentProgress = progress.get();
const point = pathRef.current.getPointAtLength(currentProgress * pathLength);
// Update and emit particles in one go
setParticles(prev => {
const updated = prev
.map(p => ({
...p,
x: p.x + p.vx,
y: p.y + p.vy,
life: p.life - 0.02,
}))
.filter(p => p.life > 0);
const dist = Math.hypot(point.x - lastPos.current.x, point.y - lastPos.current.y);
if (dist > 1) {
const newParticles: Particle[] = [];
const count = Math.min(3, Math.floor(dist / 2));
for (let i = 0; i < count; i++) {
newParticles.push({
id: particleId.current++,
x: point.x,
y: point.y,
vx: (Math.random() - 0.5) * 2,
vy: (Math.random() - 0.5) * 2,
life: 1.0,
size: Math.random() * 3 + 1,
});
}
lastPos.current = { x: point.x, y: point.y };
return [...updated, ...newParticles].slice(-60);
}
// Check for checkpoint pulses
checkpoints.forEach(cp => {
const crossed = (lastProgress.current < cp && currentProgress >= cp) ||
(lastProgress.current > cp && currentProgress <= cp);
return updated;
if (crossed) {
const checkpointPoint = pathRef.current!.getPointAtLength(cp * pathLength);
setPulses(prev => [...prev, {
id: pulseId.current++,
x: checkpointPoint.x,
y: checkpointPoint.y,
life: 1.0,
}].slice(-8));
}
});
lastProgress.current = currentProgress;
// Update pulses
setPulses(prev => prev.map(p => ({ ...p, life: p.life - 0.015 })).filter(p => p.life > 0));
});
return (
<g>
{particles.map(p => (
{pulses.map(p => (
<circle
key={p.id}
cx={p.x}
cy={p.y}
r={p.size}
fill={`rgba(148, 163, 184, ${p.life * 0.5})`}
r={(1 - p.life) * 150}
fill="none"
stroke="rgba(0, 0, 0, 0.15)"
strokeWidth={p.life * 3}
style={{ opacity: p.life }}
/>
))}
</g>