148 lines
4.6 KiB
TypeScript
148 lines
4.6 KiB
TypeScript
'use client';
|
|
|
|
import { motion, useInView } from 'framer-motion';
|
|
import { useRef } from 'react';
|
|
|
|
/**
|
|
* DrillOrbit
|
|
*
|
|
* A glowing point looping along a parabolic arc —
|
|
* abstractly evoking a horizontal directional drill path.
|
|
*
|
|
* The SVG has a horizontal linear-gradient mask: the entire
|
|
* element fades in from the edge and out toward the opposite side.
|
|
* The travelling dot also animates its own opacity to fade in/out
|
|
* at the path endpoints — no abrupt appearance or disappearance.
|
|
*/
|
|
|
|
type Side = 'left' | 'right';
|
|
|
|
interface DrillOrbitProps {
|
|
side?: Side;
|
|
invert?: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
const PATH = 'M -20 80 C 150 80 200 340 400 340 C 600 340 650 80 820 80';
|
|
|
|
export function DrillOrbit({
|
|
side = 'right',
|
|
invert = false,
|
|
className = '',
|
|
}: DrillOrbitProps) {
|
|
const ref = useRef<SVGSVGElement>(null);
|
|
const isInView = useInView(ref, { once: true, margin: '0px 0px -10% 0px' });
|
|
|
|
const color = invert ? '#084c39' : '#0e7a5c';
|
|
const glowColor = invert ? 'rgba(8,76,57,0.35)' : 'rgba(14,122,92,0.45)';
|
|
|
|
// Fade from transparent → opaque → transparent over the animation cycle
|
|
// so the dot smoothly appears from the edge and disappears at the far edge.
|
|
const dotOpacityKeyframes = [0, 0, 1, 1, 1, 0, 0];
|
|
const dotOpacityTimes = [0, 0.08, 0.18, 0.5, 0.82, 0.92, 1];
|
|
|
|
const transition = {
|
|
duration: 5.5,
|
|
ease: 'easeInOut' as const,
|
|
repeat: Infinity,
|
|
repeatDelay: 1.5,
|
|
};
|
|
|
|
return (
|
|
<svg
|
|
ref={ref}
|
|
viewBox="0 0 800 420"
|
|
fill="none"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
className={`absolute pointer-events-none select-none ${
|
|
side === 'right' ? 'right-0 bottom-0' : 'left-0 bottom-0'
|
|
} ${className}`}
|
|
style={{
|
|
width: 'clamp(360px, 55vw, 800px)',
|
|
height: 'clamp(180px, 27vw, 420px)',
|
|
transform: side === 'left' ? 'scaleX(-1)' : undefined,
|
|
// Horizontal mask: fades at the near edge (0→20%) and far edge (80%→100%)
|
|
WebkitMaskImage:
|
|
'linear-gradient(to right, transparent 0%, black 20%, black 80%, transparent 100%)',
|
|
maskImage:
|
|
'linear-gradient(to right, transparent 0%, black 20%, black 80%, transparent 100%)',
|
|
}}
|
|
aria-hidden="true"
|
|
>
|
|
<defs>
|
|
<filter id="drill-glow" x="-80%" y="-80%" width="260%" height="260%">
|
|
<feGaussianBlur stdDeviation="10" result="blur" />
|
|
<feMerge>
|
|
<feMergeNode in="blur" />
|
|
<feMergeNode in="SourceGraphic" />
|
|
</feMerge>
|
|
</filter>
|
|
</defs>
|
|
|
|
{/* Bore arc */}
|
|
<motion.path
|
|
d={PATH}
|
|
stroke={color}
|
|
strokeWidth="1"
|
|
strokeLinecap="round"
|
|
opacity={0.18}
|
|
initial={{ pathLength: 0 }}
|
|
animate={isInView ? { pathLength: 1 } : { pathLength: 0 }}
|
|
transition={{ duration: 2.5, ease: [0.4, 0, 0.2, 1] }}
|
|
/>
|
|
|
|
{/* Dashed ground-level reference */}
|
|
<motion.line
|
|
x1="0" y1="80" x2="800" y2="80"
|
|
stroke={color}
|
|
strokeWidth="0.8"
|
|
strokeDasharray="6 10"
|
|
opacity={0.12}
|
|
initial={{ pathLength: 0 }}
|
|
animate={isInView ? { pathLength: 1 } : { pathLength: 0 }}
|
|
transition={{ duration: 1.6, ease: 'easeOut' }}
|
|
/>
|
|
|
|
{/* Continuously looping drill-head — fades in/out at path ends */}
|
|
{isInView && (
|
|
<>
|
|
{/* Outer glow bloom */}
|
|
<motion.circle
|
|
r={22}
|
|
fill={glowColor}
|
|
filter="url(#drill-glow)"
|
|
style={{ offsetPath: `path('${PATH}')`, offsetDistance: '0%' } as React.CSSProperties}
|
|
animate={{
|
|
offsetDistance: ['0%', '100%'],
|
|
opacity: dotOpacityKeyframes,
|
|
}}
|
|
transition={{ ...transition, times: dotOpacityTimes }}
|
|
/>
|
|
{/* Core dot */}
|
|
<motion.circle
|
|
r={5}
|
|
fill={color}
|
|
style={{ offsetPath: `path('${PATH}')`, offsetDistance: '0%' } as React.CSSProperties}
|
|
animate={{
|
|
offsetDistance: ['0%', '100%'],
|
|
opacity: dotOpacityKeyframes,
|
|
}}
|
|
transition={{ ...transition, times: dotOpacityTimes }}
|
|
/>
|
|
{/* Inner white highlight */}
|
|
<motion.circle
|
|
r={2}
|
|
fill="white"
|
|
style={{ offsetPath: `path('${PATH}')`, offsetDistance: '0%' } as React.CSSProperties}
|
|
animate={{
|
|
offsetDistance: ['0%', '100%'],
|
|
opacity: dotOpacityKeyframes.map((v) => v * 0.8),
|
|
}}
|
|
transition={{ ...transition, times: dotOpacityTimes }}
|
|
/>
|
|
</>
|
|
)}
|
|
</svg>
|
|
);
|
|
}
|