feat: replace literal decorations with AbstractBore and AbstractTrench

Former-commit-id: 4f7c5fcc7203aa4a36aabd8ffc13cf9f28a0f77a
This commit is contained in:
2026-05-11 21:44:31 +02:00
parent 74caea6854
commit e33dcdddac
13 changed files with 186 additions and 411 deletions

View File

@@ -0,0 +1,71 @@
'use client';
import { motion } from 'framer-motion';
interface AbstractBoreProps {
className?: string;
invert?: boolean;
}
/**
* AbstractBore
*
* Visualizes Horizontal Directional Drilling (HDD).
* A smooth, parabolic underground arc. A beam of energy (the drill/cable)
* travels along this path, fading in gently at the start and fading out at the end.
* Purely abstract, no literal drill bits or circles.
*/
export function AbstractBore({ className = '', invert = false }: AbstractBoreProps) {
const color = invert ? 'rgba(10, 61, 40, 0.6)' : 'rgba(14, 122, 92, 0.6)';
const glowColor = invert ? 'rgba(10, 61, 40, 1)' : 'rgba(20, 184, 138, 1)';
// Parabolic path: Starts left, dips deep in the middle, comes up right
const pathD = "M -100 50 C 200 50 300 250 500 250 C 700 250 800 50 1100 50";
// The length of this cubic bezier is approx 1260
const pathLength = 1260;
// The length of the moving beam
const beamLength = 200;
return (
<svg
viewBox="0 0 1000 300"
preserveAspectRatio="none"
className={`absolute pointer-events-none select-none w-full ${className}`}
aria-hidden="true"
>
{/* Subtle track background */}
<path
d={pathD}
stroke={color}
strokeWidth="1"
fill="none"
opacity={0.15}
/>
{/* Moving Energy Beam */}
<motion.path
d={pathD}
stroke={glowColor}
strokeWidth="2"
fill="none"
strokeLinecap="round"
// Dasharray: beam length, then gap for the rest of the path
strokeDasharray={`${beamLength} ${pathLength}`}
animate={{
// Move from right off-screen to left off-screen (or vice versa)
// Starting at pathLength pushes the dash off the start of the curve.
// Ending at -beamLength pushes it completely off the end.
strokeDashoffset: [pathLength, -beamLength],
// Fade in during the first 20%, stay solid, fade out during last 20%
opacity: [0, 1, 1, 0]
}}
transition={{
duration: 6,
ease: "linear",
repeat: Infinity,
times: [0, 0.15, 0.85, 1] // Syncs with the offset to fade exactly at edges
}}
/>
</svg>
);
}

View File

@@ -0,0 +1,70 @@
'use client';
import { motion } from 'framer-motion';
interface AbstractTrenchProps {
className?: string;
invert?: boolean;
}
/**
* AbstractTrench
*
* Visualizes open trench construction (Offene Bauweise / Graben).
* A sharp, geometric profile representing a cut into the earth.
* A beam of energy flows through the trench, hugging the sharp corners,
* fading in and out at the horizontal edges.
* Purely abstract, architectural styling.
*/
export function AbstractTrench({ className = '', invert = false }: AbstractTrenchProps) {
const color = invert ? 'rgba(10, 61, 40, 0.6)' : 'rgba(14, 122, 92, 0.6)';
const glowColor = invert ? 'rgba(10, 61, 40, 1)' : 'rgba(20, 184, 138, 1)';
// Geometric trench path: straight, 45-deg drop, straight bottom, 45-deg rise, straight
const pathD = "M -100 50 L 300 50 L 350 200 L 650 200 L 700 50 L 1100 50";
// Length of this polyline is approx 1260
const pathLength = 1260;
const beamLength = 250;
return (
<svg
viewBox="0 0 1000 300"
preserveAspectRatio="none"
className={`absolute pointer-events-none select-none w-full ${className}`}
aria-hidden="true"
>
{/* Subtle track background */}
<path
d={pathD}
stroke={color}
strokeWidth="1"
fill="none"
strokeLinejoin="round"
opacity={0.15}
/>
{/* Moving Energy Beam */}
<motion.path
d={pathD}
stroke={glowColor}
strokeWidth="2"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
// Dasharray: beam length, then gap for the rest of the path
strokeDasharray={`${beamLength} ${pathLength}`}
animate={{
strokeDashoffset: [pathLength, -beamLength],
opacity: [0, 1, 1, 0]
}}
transition={{
duration: 7.5,
ease: "linear",
repeat: Infinity,
times: [0, 0.15, 0.85, 1], // Syncs with the offset to fade exactly at edges
delay: 1.5 // Offset from AbstractBore if used together
}}
/>
</svg>
);
}

View File

@@ -1,125 +0,0 @@
'use client';
import { motion } from 'framer-motion';
/**
* DrillBitSpinner
*
* Abstract animated drill head cross-section.
* Resembles an HDD tri-blade drill bit seen from the front.
*
* The wrapping div uses a radial-gradient mask so it fades naturally
* into whatever background it's placed on — no hard edge clipping.
*/
interface DrillBitSpinnerProps {
size?: number;
color?: string;
duration?: number;
opacity?: number;
glow?: boolean;
className?: string;
}
export function DrillBitSpinner({
size = 120,
color = '#0e7a5c',
duration = 8,
opacity = 1,
glow = true,
className = '',
}: DrillBitSpinnerProps) {
const r = size / 2;
const outerR = r * 0.92;
const bladeLen = r * 0.62;
const bladeW = r * 0.18;
const coreR = r * 0.22;
const blades = [0, 120, 240].map((deg) => {
const rad = (deg * Math.PI) / 180;
return {
x: r + Math.cos(rad) * (bladeLen * 0.5),
y: r + Math.sin(rad) * (bladeLen * 0.5),
rotate: deg,
};
});
return (
<div
className={`relative pointer-events-none select-none ${className}`}
style={{
width: size,
height: size,
opacity,
// Radial gradient mask: the spinner fades out toward all edges.
// This prevents any hard clip when partially outside the container.
WebkitMaskImage: 'radial-gradient(circle at center, black 20%, transparent 70%)',
maskImage: 'radial-gradient(circle at center, black 20%, transparent 70%)',
}}
aria-hidden="true"
>
{/* Glow bloom (pulsing) */}
{glow && (
<motion.div
className="absolute inset-0 rounded-full"
style={{ background: `radial-gradient(circle, ${color}40 0%, transparent 70%)` }}
animate={{ scale: [1, 1.4, 1], opacity: [0.6, 0.2, 0.6] }}
transition={{ duration: 3, repeat: Infinity, ease: 'easeInOut' }}
/>
)}
{/* The rotating drill assembly */}
<motion.svg
viewBox={`0 0 ${size} ${size}`}
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="absolute inset-0 w-full h-full"
animate={{ rotate: 360 }}
transition={{ duration, repeat: Infinity, ease: 'linear' }}
>
{/* Outer halo ring */}
<circle
cx={r} cy={r} r={outerR}
stroke={color}
strokeWidth={size * 0.018}
strokeDasharray={`${outerR * 0.4} ${outerR * 0.15}`}
opacity={0.35}
/>
{/* Three cutting blades */}
{blades.map((blade, i) => (
<g key={i} transform={`rotate(${blade.rotate} ${r} ${r})`}>
<rect
x={r - bladeW / 2}
y={r - coreR}
width={bladeW}
height={bladeLen}
rx={bladeW / 2}
fill={color}
opacity={0.7}
/>
<circle
cx={r}
cy={r - coreR - bladeLen + bladeW / 2}
r={bladeW * 0.55}
fill={color}
opacity={0.9}
/>
</g>
))}
{/* Inner core ring */}
<circle
cx={r} cy={r} r={coreR + size * 0.025}
stroke={color}
strokeWidth={size * 0.022}
opacity={0.5}
/>
{/* Center nozzle */}
<circle cx={r} cy={r} r={coreR * 0.55} fill={color} opacity={0.85} />
<circle cx={r} cy={r} r={coreR * 0.22} fill="white" opacity={0.6} />
</motion.svg>
</div>
);
}

View File

@@ -1,147 +0,0 @@
'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>
);
}

View File

@@ -1,62 +0,0 @@
import React from 'react';
/**
* DrillTeeth
*
* Abstract, minimalist section divider inspired by HDD bore paths.
* Not literal teeth — instead, a subtle organic wave that hints at
* earth strata displaced by subsurface drilling.
*
* Two gentle asymmetric curves create a refined, modern transition
* between sections. Think Stripe/Linear, not construction site.
*
* Usage: Place inside a section with `relative overflow-hidden`.
* The SVG renders outside the box via translate, creating overlap.
*/
interface DrillTeethProps {
/** Which edge of the parent section */
position?: 'top' | 'bottom';
/** Fill color — should match the CURRENT section's background */
fill?: string;
className?: string;
}
export function DrillTeeth({
position = 'bottom',
fill = 'currentColor',
className = '',
}: DrillTeethProps) {
const isTop = position === 'top';
return (
<div
className={`absolute left-0 right-0 w-full pointer-events-none z-20 ${
isTop ? 'top-0 -translate-y-[99%]' : 'bottom-0 translate-y-[99%]'
} ${className}`}
aria-hidden="true"
style={{ lineHeight: 0 }}
>
<svg
viewBox="0 0 1440 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="none"
className="w-full h-[clamp(16px,3vw,48px)] block"
style={isTop ? { transform: 'scaleY(-1)' } : undefined}
>
{/* Primary curve — gentle asymmetric wave */}
<path
d="M0,48 L0,28 C120,36 240,6 480,12 C720,18 840,42 1080,24 C1200,16 1320,32 1440,28 L1440,48 Z"
fill={fill}
/>
{/* Secondary subtle offset — adds depth like a geological layer */}
<path
d="M0,48 L0,36 C180,42 360,16 600,22 C840,28 960,44 1200,32 C1320,26 1400,38 1440,36 L1440,48 Z"
fill={fill}
opacity="0.6"
/>
</svg>
</div>
);
}