823 lines
33 KiB
TypeScript
823 lines
33 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef } from "react";
|
|
import gsap from "gsap";
|
|
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
|
import { MotionPathPlugin } from "gsap/MotionPathPlugin";
|
|
import { DrawSVGPlugin } from "gsap/DrawSVGPlugin";
|
|
import { ScrollReveal, StaggerChildren } from "@/animations/Reveal";
|
|
|
|
let hasDrawSVG = false;
|
|
if (typeof window !== "undefined") {
|
|
gsap.registerPlugin(ScrollTrigger, MotionPathPlugin);
|
|
try {
|
|
// DrawSVGPlugin ships free with gsap 3.13+. Registration (not the import
|
|
// itself) is guarded so the draw step can fall back to plain
|
|
// stroke-dasharray if it's ever unavailable, without touching timing.
|
|
gsap.registerPlugin(DrawSVGPlugin);
|
|
hasDrawSVG = true;
|
|
} catch {
|
|
hasDrawSVG = false;
|
|
}
|
|
}
|
|
|
|
const MOBILE_BQ = 600;
|
|
const AIRCRAFT_ROTATION_OFFSET = 90; // glyph's rest pose points "up"
|
|
const WAVE_AMPLITUDE_MIN = 20;
|
|
const WAVE_AMPLITUDE_MAX = 38;
|
|
|
|
const FEATURES = [
|
|
{
|
|
title: "Same-Day Air Logistics",
|
|
desc: "Max 3-hour door-to-door delivery between India's major regional capitals.",
|
|
icon: (
|
|
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="12" cy="12" r="10" />
|
|
<polyline points="12 6 12 12 16 14" />
|
|
</svg>
|
|
),
|
|
},
|
|
{
|
|
title: "AI-Powered Operations",
|
|
desc: "MileTruth™ AI continuously forecasts demand and optimizes every route and load.",
|
|
icon: (
|
|
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect x="4" y="4" width="16" height="16" rx="3" />
|
|
<path d="M9 9h6v6H9z" />
|
|
<path d="M9 1v3M15 1v3M9 20v3M15 20v3M1 9h3M1 15h3M20 9h3M20 15h3" />
|
|
</svg>
|
|
),
|
|
},
|
|
{
|
|
title: "Regional Connectivity",
|
|
desc: "Dedicated air capacity linking major economic hubs, not passenger-belly space.",
|
|
icon: (
|
|
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="6" cy="6" r="2.5" />
|
|
<circle cx="18" cy="6" r="2.5" />
|
|
<circle cx="12" cy="18" r="2.5" />
|
|
<path d="M8.2 7.2 10 16M15.8 7.2 14 16M8.5 6h7" />
|
|
</svg>
|
|
),
|
|
},
|
|
{
|
|
title: "EV Last Mile",
|
|
desc: "Zero-emission electric vehicles complete every first- and final-mile delivery.",
|
|
icon: (
|
|
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M3 13h13l3 4h2v3H3z" />
|
|
<circle cx="7" cy="20" r="1.6" />
|
|
<circle cx="16" cy="20" r="1.6" />
|
|
<path d="M6 13V8a1 1 0 0 1 1-1h5l3 6" />
|
|
</svg>
|
|
),
|
|
},
|
|
];
|
|
|
|
interface Point {
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
function buildPathD(points: Point[]): string {
|
|
if (points.length < 2) return "";
|
|
let d = `M ${points[0].x} ${points[0].y}`;
|
|
for (let i = 0; i < points.length - 1; i++) {
|
|
const p0 = points[i];
|
|
const p1 = points[i + 1];
|
|
const dx = (p1.x - p0.x) / 2;
|
|
d += ` C ${p0.x + dx} ${p0.y}, ${p1.x - dx} ${p1.y}, ${p1.x} ${p1.y}`;
|
|
}
|
|
return d;
|
|
}
|
|
|
|
// Inserts one raised "crest" point between every consecutive pair of card
|
|
// anchors, turning the mostly-straight spline into an alternating
|
|
// trough-crest-trough wave: each card position stays a trough (so the pulse
|
|
// still arrives level with its icon, unchanged), while the gap between cards
|
|
// arcs upward. buildPathD's per-segment horizontal-tangent control points
|
|
// already keep every trough/crest transition smooth (no sharp bends) without
|
|
// needing a fancier spline — only the input point list changes.
|
|
//
|
|
// Each crest's height and horizontal position are nudged by a small,
|
|
// index-derived (not random) offset, so consecutive humps are never
|
|
// identical — reading as a real, slightly irregular air route rather than a
|
|
// generated sine wave — while staying perfectly stable across re-measures
|
|
// (resize, etc.) since the offsets depend only on segment index, not chance.
|
|
// Each offset sums two sine terms at unrelated frequencies/phases rather
|
|
// than one, so the sequence of crest heights doesn't itself read as a
|
|
// single obvious repeating rhythm.
|
|
function buildWavePoints(points: Point[], amplitude: number): Point[] {
|
|
if (points.length < 2 || amplitude <= 0) return points;
|
|
const waved: Point[] = [points[0]];
|
|
for (let i = 0; i < points.length - 1; i++) {
|
|
const p0 = points[i];
|
|
const p1 = points[i + 1];
|
|
const ampFactor =
|
|
1 + 0.28 * Math.sin(i * 2.9 + 0.4) + 0.15 * Math.sin(i * 5.3 + 2.1);
|
|
const xJitter =
|
|
(p1.x - p0.x) * (0.08 * Math.sin(i * 1.7 + 1.3) + 0.06 * Math.sin(i * 3.6 + 0.2));
|
|
waved.push({
|
|
x: (p0.x + p1.x) / 2 + xJitter,
|
|
y: Math.min(p0.y, p1.y) - amplitude * ampFactor,
|
|
});
|
|
waved.push(p1);
|
|
}
|
|
return waved;
|
|
}
|
|
|
|
export default function WingsWhyGrid() {
|
|
const wrapRef = useRef<HTMLDivElement>(null);
|
|
const svgRef = useRef<SVGSVGElement>(null);
|
|
const pathRef = useRef<SVGPathElement>(null);
|
|
// A second copy of the same "d", drawn brighter/thicker/blurred. A moving
|
|
// dasharray window (kept in sync with the pulse's own progress, see
|
|
// onUpdate below) makes a soft, feathered segment of the route glow right
|
|
// under the pulse and fade back to the resting line just behind it — the
|
|
// route being "energized" as the pulse passes, not just a static line.
|
|
const illumRef = useRef<SVGPathElement>(null);
|
|
// The traveler is the primary visual: a bright optical pulse riding the
|
|
// route. It's the outer group MotionPathPlugin translates. The aircraft is
|
|
// nested inside it as a small, secondary glyph — never its own focal point.
|
|
const travelerRef = useRef<SVGGElement>(null);
|
|
// Only this inner group rotates to face the direction of travel (the
|
|
// halo). Kept separate from travelerRef with an explicit 0,0 transform
|
|
// origin so rotation never pivots around a fill-box-derived point.
|
|
const directionalRef = useRef<SVGGElement>(null);
|
|
// The aircraft's own rotation wrapper, painted as a sibling *after* the
|
|
// core circle (see JSX) so its silhouette stays readable on top of the
|
|
// bright core instead of being buried under it. Mirrors directionalRef's
|
|
// rotation exactly (set alongside it in onUpdate) so both halves of the
|
|
// traveler always face the same way despite being separate DOM branches.
|
|
const aircraftDirectionalRef = useRef<SVGGElement>(null);
|
|
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
const iconRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
|
|
useEffect(() => {
|
|
const wrap = wrapRef.current;
|
|
const svg = svgRef.current;
|
|
const path = pathRef.current;
|
|
const illum = illumRef.current;
|
|
const traveler = travelerRef.current;
|
|
const directional = directionalRef.current;
|
|
const aircraftDirectional = aircraftDirectionalRef.current;
|
|
if (!wrap || !svg || !path || !illum || !traveler || !directional || !aircraftDirectional) return;
|
|
|
|
// Declared here (not inside the gsap.context callback below) so the
|
|
// cleanup function — which must run on every unmount, including ones
|
|
// gsap.context() itself doesn't know about, like these plain rAF/
|
|
// ResizeObserver handles — can always reach them.
|
|
let resizeRaf = 0;
|
|
let initialRaf = 0;
|
|
let rafIn = 0;
|
|
let ro: ResizeObserver | undefined;
|
|
let loadHandler: (() => void) | undefined;
|
|
// Also assigned (not re-declared) inside the gsap.context callback below,
|
|
// for the same reason: cleanup needs to reach them even though
|
|
// ctx.revert() already kills anything the context tracked, since the
|
|
// ScrollTrigger is created synchronously (tracked) but the flight
|
|
// timeline is only created later, asynchronously, once onEnter actually
|
|
// fires (not reliably tracked by the context in that case).
|
|
let st: ScrollTrigger | null = null;
|
|
let playedTimeline: gsap.core.Timeline | null = null;
|
|
|
|
// Everything GSAP-related (ScrollTrigger, timelines, gsap.set calls) is
|
|
// created inside this context so a single ctx.revert() on unmount kills
|
|
// all of it — this is what makes the whole setup safe to re-run on every
|
|
// mount (initial load, client-side route navigation, back/forward, hot
|
|
// reload) instead of only working after a hard refresh, where a prior
|
|
// mount's leftover ScrollTrigger/timeline could otherwise linger or hold
|
|
// a stale measurement.
|
|
const ctx = gsap.context(() => {
|
|
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
let played = false;
|
|
|
|
const isMobileLayout = () => window.matchMedia(`(max-width: ${MOBILE_BQ}px)`).matches;
|
|
|
|
const measure = (): { points: Point[]; isMobile: boolean } => {
|
|
const wrapRect = wrap.getBoundingClientRect();
|
|
const isMobile = isMobileLayout();
|
|
|
|
const points = cardRefs.current
|
|
.map((card, i) => {
|
|
const icon = iconRefs.current[i];
|
|
if (!card || !icon) return null;
|
|
const cardRect = card.getBoundingClientRect();
|
|
const iconRect = icon.getBoundingClientRect();
|
|
if (isMobile) {
|
|
return {
|
|
x: 12,
|
|
y: iconRect.top + iconRect.height / 2 - wrapRect.top,
|
|
};
|
|
}
|
|
return {
|
|
x: iconRect.left + iconRect.width / 2 - wrapRect.left,
|
|
y: cardRect.top - wrapRect.top - 14,
|
|
};
|
|
})
|
|
.filter((p): p is Point => p !== null);
|
|
|
|
svg.setAttribute("width", String(wrapRect.width));
|
|
svg.setAttribute("height", String(wrapRect.height));
|
|
svg.setAttribute("viewBox", `0 0 ${wrapRect.width} ${wrapRect.height}`);
|
|
|
|
// Wave only applies to the horizontal (non-mobile) layout — mobile
|
|
// keeps its existing straight vertical line unchanged. Amplitude
|
|
// scales with the section's width so it stays "gentle" (never a
|
|
// straight line, never overly aggressive) at any breakpoint.
|
|
const amplitude = isMobile
|
|
? 0
|
|
: Math.max(WAVE_AMPLITUDE_MIN, Math.min(WAVE_AMPLITUDE_MAX, wrapRect.width / 20));
|
|
const routePoints = isMobile ? points : buildWavePoints(points, amplitude);
|
|
const d = buildPathD(routePoints);
|
|
path.setAttribute("d", d);
|
|
illum.setAttribute("d", d);
|
|
|
|
return { points, isMobile };
|
|
};
|
|
|
|
ro = new ResizeObserver(() => {
|
|
cancelAnimationFrame(resizeRaf);
|
|
resizeRaf = requestAnimationFrame(() => {
|
|
if (!reduced) {
|
|
buildSequence();
|
|
} else {
|
|
measure();
|
|
}
|
|
});
|
|
});
|
|
ro.observe(wrap);
|
|
|
|
// Initial synchronous measurement so `path` has real geometry before any
|
|
// gsap.set/timeline touches it (ResizeObserver's first callback is async).
|
|
// Followed by one deferred re-measure: StaggerChildren's own mount effect
|
|
// (a sibling/child effect) can still have cards translated by their
|
|
// pre-reveal yOffset at this exact tick, so the very first synchronous
|
|
// read can be transiently stale — a post-paint rAF corrects it.
|
|
measure();
|
|
initialRaf = requestAnimationFrame(() => {
|
|
measure();
|
|
// Recalculates every ScrollTrigger's start/end position against the
|
|
// now-settled layout. Without this, a trigger created a moment before
|
|
// web fonts/images finish shifting the page can end up with a stale
|
|
// "top 85%" position that a normal scroll never actually crosses —
|
|
// the classic reason a GSAP ScrollTrigger works after a hard refresh
|
|
// (cache-warm, layout already stable) but not after a fresh client-side
|
|
// navigation.
|
|
ScrollTrigger.refresh();
|
|
});
|
|
|
|
// Extra safety net: images loading in after this point can still shift
|
|
// layout. One more refresh once the whole page (including images) has
|
|
// finished loading catches that — a no-op if `load` already fired.
|
|
if (document.readyState !== "complete") {
|
|
loadHandler = () => ScrollTrigger.refresh();
|
|
window.addEventListener("load", loadHandler, { once: true });
|
|
}
|
|
|
|
const activateCard = (i: number) => {
|
|
const card = cardRefs.current[i];
|
|
const icon = iconRefs.current[i];
|
|
if (!card) return;
|
|
// Arrival: border gently strengthens, ambient glow grows, icon
|
|
// brightens, card lifts a touch (CSS-driven, eased over ~0.5s — see
|
|
// .wwg-card / .wwg-card-glow transition durations below — so it grows
|
|
// and settles rather than snapping on).
|
|
card.classList.add("wwg-card-flown", "wwg-card-pulse");
|
|
// Icon's own gentle scale pulse starts a beat after the arrival
|
|
// begins, so the sequence reads as "arrives, then responds" rather
|
|
// than everything happening at once.
|
|
if (icon) {
|
|
gsap.timeline({ delay: 0.28 })
|
|
.to(icon, { scale: 1.02, duration: 0.32, ease: "expo.out" })
|
|
.to(icon, { scale: 1, duration: 0.32, ease: "sine.inOut" });
|
|
}
|
|
// Hold the arrival state, then ease into the subtler, persistent
|
|
// "flown" resting state.
|
|
gsap.delayedCall(0.95, () => card.classList.remove("wwg-card-pulse"));
|
|
};
|
|
|
|
const buildSequence = () => {
|
|
if (playedTimeline) playedTimeline.kill();
|
|
|
|
const { points, isMobile } = measure();
|
|
if (points.length < 2) return;
|
|
|
|
const restOpacity = isMobile ? 0.15 : 0.18;
|
|
const total = path.getTotalLength();
|
|
// The illuminated window trailing the pulse: roughly a fifth of the
|
|
// route's length, so it reads as a genuine glowing trail rather than
|
|
// a small blip or the entire line at once.
|
|
const trailLen = total * 0.22;
|
|
const illumDashGap = total + 200;
|
|
illum.setAttribute("stroke-dasharray", `${trailLen} ${illumDashGap}`);
|
|
const fractions = points.map((pt) => {
|
|
let best = 0;
|
|
let bestDist = Infinity;
|
|
const step = Math.max(2, total / 400);
|
|
for (let l = 0; l <= total; l += step) {
|
|
const p = path.getPointAtLength(l);
|
|
const dist = (p.x - pt.x) ** 2 + (p.y - pt.y) ** 2;
|
|
if (dist < bestDist) {
|
|
bestDist = dist;
|
|
best = l;
|
|
}
|
|
}
|
|
return best / total;
|
|
});
|
|
const fired = fractions.map(() => false);
|
|
|
|
let currentAngle = 0;
|
|
let angleInit = false;
|
|
|
|
const tl = gsap.timeline({
|
|
scrollTrigger: {
|
|
trigger: wrap,
|
|
start: "top 75%",
|
|
end: "bottom 35%",
|
|
scrub: 1,
|
|
},
|
|
});
|
|
playedTimeline = tl;
|
|
|
|
// The energy pulse's own journey — calm and deliberate, ~4.8-5.5s so
|
|
// it glides rather than rushes from the first card to the last.
|
|
const FLIGHT_DURATION = 5.2;
|
|
// The route draws slightly faster than the pulse travels, both
|
|
// starting together at t=0 — so the drawn extent always leads the
|
|
// pulse's position (never the other way round) and the whole route
|
|
// is guaranteed fully illuminated well before the pulse's own journey
|
|
// ends, while both still read as one continuous, synchronized motion.
|
|
const DRAW_DURATION = FLIGHT_DURATION * 0.9;
|
|
|
|
if (hasDrawSVG) {
|
|
tl.to(path, { drawSVG: "100%", duration: DRAW_DURATION, ease: "power2.inOut" }, 0);
|
|
} else {
|
|
gsap.set(path, { strokeDasharray: total, strokeDashoffset: total });
|
|
tl.to(path, { strokeDashoffset: 0, duration: DRAW_DURATION, ease: "power2.inOut" }, 0);
|
|
}
|
|
// The base route ramps straight to its final resting opacity as it
|
|
// draws — never to an elevated "traveling" brightness — so there is
|
|
// only ever one illuminated layer on screen: the bright illum trail
|
|
// riding with the pulse (below). Without this, the whole drawn route
|
|
// would sit at a visibly elevated opacity for the entire flight,
|
|
// reading as a second, competing "lit" route behind the pulse.
|
|
tl.to(path, { opacity: restOpacity, duration: 0.9, ease: "sine.out" }, 0);
|
|
|
|
// GSAP computes its own rotation pivot for SVG elements from the
|
|
// target's bounding box (stamped as a data-svg-origin attribute) and
|
|
// ignores plain CSS transform-origin for that purpose — so without
|
|
// this, both directional groups were actually rotating around their
|
|
// bbox corner instead of (0,0), visibly orbiting off the path's
|
|
// centerline as they turned. svgOrigin is GSAP's own equivalent,
|
|
// stated in the element's literal SVG coordinates (not bbox-relative
|
|
// percentages), and only needs setting once — the per-frame
|
|
// rotation-only gsap.set calls below then reuse it automatically.
|
|
gsap.set([directional, aircraftDirectional], { svgOrigin: "0 0" });
|
|
|
|
// A soft fade-in rather than an instant pop, so the pulse's arrival
|
|
// reads as one continuous gesture from the very first frame.
|
|
tl.to(traveler, { opacity: 1, duration: 0.3, ease: "sine.out" }, 0);
|
|
tl.to(illum, { opacity: 1, duration: 0.4, ease: "sine.out" }, 0);
|
|
tl.to(traveler, {
|
|
// alignOrigin locks the traveler's own center (not a default
|
|
// corner/bbox reference) to the path, matching the explicit
|
|
// transform-origin: 50% 50% above — keeps the pulse/aircraft
|
|
// precisely on the route's centerline with no vertical drift.
|
|
motionPath: { path, autoRotate: false, alignOrigin: [0.5, 0.5] },
|
|
duration: FLIGHT_DURATION,
|
|
ease: "power2.inOut",
|
|
onUpdate: function () {
|
|
const p = this.progress();
|
|
const l = p * total;
|
|
const eps = Math.max(1, total * 0.01);
|
|
const a = path.getPointAtLength(Math.max(0, l - eps));
|
|
const b = path.getPointAtLength(Math.min(total, l + eps));
|
|
const target = (Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI + AIRCRAFT_ROTATION_OFFSET;
|
|
|
|
if (!angleInit) {
|
|
currentAngle = target;
|
|
angleInit = true;
|
|
} else {
|
|
let delta = target - currentAngle;
|
|
// shortest angular path so the lerp never spins the long way round
|
|
delta = ((delta + 180) % 360 + 360) % 360 - 180;
|
|
currentAngle += delta * 0.12;
|
|
}
|
|
// Only the two directional groups (trail + halo, and the aircraft)
|
|
// rotate — the core stays a perfectly round point of light on the
|
|
// path. Both are set to the same angle every frame so they always
|
|
// face the same way despite being separate DOM branches (see
|
|
// aircraftDirectionalRef for why they're split).
|
|
gsap.set(directional, { rotation: currentAngle });
|
|
gsap.set(aircraftDirectional, { rotation: currentAngle });
|
|
|
|
// Slide the illuminated window's trailing edge to l - trailLen and
|
|
// its leading edge to l (i.e. right under the pulse) by shifting
|
|
// the dash pattern's phase — the CSS blur on .wwg-illum-trail turns
|
|
// those two hard edges into a soft glow that fades into the resting
|
|
// route, rather than a sharp on/off cut.
|
|
illum.setAttribute("stroke-dashoffset", String(trailLen - l));
|
|
|
|
fractions.forEach((f, i) => {
|
|
if (!fired[i] && p >= f) {
|
|
fired[i] = true;
|
|
activateCard(i);
|
|
} else if (fired[i] && p < f) {
|
|
fired[i] = false;
|
|
// Reset card state if scrubbed backwards
|
|
cardRefs.current[i]?.classList.remove("wwg-card-flown", "wwg-card-pulse");
|
|
}
|
|
});
|
|
},
|
|
}, 0);
|
|
|
|
// Only once the pulse's own journey is complete does it fade out —
|
|
// the route itself is already at its resting opacity by this point
|
|
// (see above), so nothing further happens to it here.
|
|
tl.to(traveler, { opacity: 0, duration: 0.6, ease: "sine.out" }, FLIGHT_DURATION);
|
|
tl.to(illum, { opacity: 0, duration: 0.6, ease: "sine.out" }, FLIGHT_DURATION);
|
|
};
|
|
|
|
if (reduced) {
|
|
const { isMobile } = measure();
|
|
gsap.set(path, { opacity: isMobile ? 0.15 : 0.18 });
|
|
if (hasDrawSVG) {
|
|
gsap.set(path, { drawSVG: "100%" });
|
|
} else {
|
|
gsap.set(path, { strokeDasharray: "none", strokeDashoffset: 0 });
|
|
}
|
|
gsap.set(traveler, { opacity: 0 });
|
|
gsap.set(illum, { opacity: 0 });
|
|
} else {
|
|
gsap.set(path, { opacity: 0 });
|
|
if (hasDrawSVG) {
|
|
gsap.set(path, { drawSVG: "0%" });
|
|
}
|
|
gsap.set(traveler, { opacity: 0 });
|
|
gsap.set(illum, { opacity: 0 });
|
|
|
|
// Build the scrubbing sequence immediately
|
|
buildSequence();
|
|
}
|
|
}, wrap);
|
|
|
|
return () => {
|
|
ro?.disconnect();
|
|
cancelAnimationFrame(resizeRaf);
|
|
cancelAnimationFrame(initialRaf);
|
|
cancelAnimationFrame(rafIn);
|
|
if (loadHandler) window.removeEventListener("load", loadHandler);
|
|
st?.kill();
|
|
playedTimeline?.kill();
|
|
// Reverts/kills everything gsap.context tracked (ScrollTrigger.create,
|
|
// gsap.set calls, etc.) as a safety net beyond the explicit kills
|
|
// above, which exist specifically because the flight timeline is
|
|
// created asynchronously (inside onEnter) and so isn't guaranteed to
|
|
// be tracked by the context itself.
|
|
ctx.revert();
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<>
|
|
<style
|
|
dangerouslySetInnerHTML={{
|
|
__html: `
|
|
.wwg-section {
|
|
background: #ffffff;
|
|
padding: var(--space-section-lg, 80px) 0;
|
|
}
|
|
|
|
.wwg-header {
|
|
margin: 0 auto 48px auto;
|
|
max-width: 720px;
|
|
text-align: center;
|
|
}
|
|
|
|
.wwg-eyebrow {
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
letter-spacing: 2px;
|
|
text-transform: uppercase;
|
|
color: rgba(17, 17, 17, 0.45);
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.wwg-title {
|
|
font-size: clamp(28px, 3.2vw, 46px) !important;
|
|
font-weight: 800 !important;
|
|
letter-spacing: -0.03em !important;
|
|
line-height: 1.15 !important;
|
|
text-transform: none !important;
|
|
color: #111111 !important;
|
|
margin: 0;
|
|
}
|
|
|
|
.wwg-inner {
|
|
max-width: 1380px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.wwg-grid-wrap {
|
|
position: relative;
|
|
overflow: visible;
|
|
}
|
|
|
|
.wwg-flightpath {
|
|
position: absolute;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
overflow: visible;
|
|
z-index: 5;
|
|
}
|
|
|
|
.wwg-flightpath-line {
|
|
fill: none;
|
|
stroke: #c8102e;
|
|
stroke-width: 2;
|
|
stroke-linecap: round;
|
|
stroke-linejoin: round;
|
|
opacity: 0;
|
|
}
|
|
|
|
/* A brighter, blurred copy of the same route, painted *behind* the
|
|
crisp foreground line (see JSX order). Its dasharray is driven
|
|
per-frame (see onUpdate) to keep a soft window of it trailing
|
|
directly beneath the pulse; because it sits behind rather than on
|
|
top, its blur reads as a glow radiating from under the route
|
|
instead of visually thickening the route's own edges. Stroke
|
|
width intentionally matches the base route so it never peeks out
|
|
wider than the crisp line drawn on top of it. */
|
|
.wwg-illum-trail {
|
|
fill: none;
|
|
stroke: #ff3d5a;
|
|
stroke-width: 2;
|
|
stroke-linecap: round;
|
|
filter: blur(5px);
|
|
opacity: 0;
|
|
}
|
|
|
|
/* The traveler is the primary visual: a single centered optical
|
|
pulse riding the route, with a small secondary aircraft glyph
|
|
carried inside it. Position (translate) lives on .wwg-traveler;
|
|
only the inner .wwg-traveler-directional group rotates to face
|
|
the direction of travel — the round pulse core stays outside it
|
|
so it never rotates, always reading as a clean point of light. */
|
|
.wwg-traveler {
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
will-change: transform;
|
|
/* Explicit center alignment (paired with alignOrigin: [0.5, 0.5]
|
|
on the motionPath tween) so the traveler's own center — not a
|
|
corner/default reference point — is what MotionPathPlugin locks
|
|
to the route, keeping it precisely on the path's centerline
|
|
with no vertical drift. */
|
|
transform-origin: 50% 50%;
|
|
}
|
|
|
|
.wwg-traveler-directional {
|
|
transform-origin: 0px 0px;
|
|
will-change: transform;
|
|
}
|
|
|
|
/* Large, radially-symmetric ambient glow — the true hero of the
|
|
traveler. Centered on the pulse with no directional offset, so
|
|
there is never a brighter patch leading ahead of the aircraft.
|
|
Non-directional, so it lives outside the rotating
|
|
.wwg-traveler-directional group. */
|
|
.wwg-pulse-bloom {
|
|
fill: #c8102e;
|
|
opacity: 0.2;
|
|
filter: blur(18px);
|
|
}
|
|
|
|
.wwg-pulse-halo {
|
|
fill: #c8102e;
|
|
opacity: 0.52;
|
|
filter: blur(7px);
|
|
}
|
|
|
|
.wwg-pulse-core {
|
|
fill: #ffffff;
|
|
filter: drop-shadow(0 0 4px rgba(255, 255, 255, 0.98)) drop-shadow(0 0 9px rgba(200, 16, 46, 0.85))
|
|
drop-shadow(0 0 15px rgba(200, 16, 46, 0.5));
|
|
}
|
|
|
|
/* Deliberately no drop-shadow of its own — the bloom/halo/trail
|
|
above supply all the glow. Sized to be clearly recognizable as an
|
|
aircraft without becoming the focal point: the pulse is still the
|
|
brightest, largest element on screen at every moment. */
|
|
.wwg-aircraft {
|
|
color: #c8102e;
|
|
opacity: 0.65;
|
|
}
|
|
|
|
.wwg-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
gap: 24px;
|
|
}
|
|
|
|
.wwg-card {
|
|
position: relative;
|
|
min-width: 0;
|
|
padding: 30px 24px;
|
|
border: 1px solid rgba(17, 17, 17, 0.08);
|
|
border-radius: 18px;
|
|
background: #fafafa;
|
|
/* Staggered delays so activation reads as a cascade — glow moves
|
|
first (see .wwg-card-glow below, no delay), then border/shape,
|
|
then the background tint settles last — rather than every
|
|
property snapping at once. The "expo-out" curve (rather than
|
|
plain ease) is what gives it a soft, premium settle instead of a
|
|
mechanical linear-ish snap. */
|
|
transition:
|
|
border-color 0.75s cubic-bezier(0.16, 1, 0.3, 1) 0.08s,
|
|
box-shadow 0.75s cubic-bezier(0.16, 1, 0.3, 1) 0.08s,
|
|
transform 0.75s cubic-bezier(0.16, 1, 0.3, 1) 0.08s,
|
|
background-color 0.75s cubic-bezier(0.16, 1, 0.3, 1) 0.22s;
|
|
}
|
|
|
|
.wwg-card:hover {
|
|
border-color: rgba(200, 16, 46, 0.35);
|
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.06);
|
|
transform: translateY(-4px);
|
|
}
|
|
|
|
.wwg-card-glow {
|
|
position: absolute;
|
|
inset: -12px;
|
|
border-radius: 22px;
|
|
background: radial-gradient(circle, rgba(200, 16, 46, 0.45) 0%, rgba(200, 16, 46, 0) 70%);
|
|
opacity: 0;
|
|
z-index: -1;
|
|
pointer-events: none;
|
|
filter: blur(6px);
|
|
transition: opacity 0.75s cubic-bezier(0.16, 1, 0.3, 1);
|
|
}
|
|
|
|
.wwg-card-flown {
|
|
border-color: rgba(200, 16, 46, 0.25);
|
|
background-color: #fdf7f8;
|
|
}
|
|
|
|
.wwg-card-flown .wwg-card-glow {
|
|
opacity: 0.32;
|
|
}
|
|
|
|
.wwg-card-pulse {
|
|
border-color: rgba(200, 16, 46, 0.5);
|
|
box-shadow: 0 16px 34px rgba(0, 0, 0, 0.07);
|
|
transform: translateY(-3px);
|
|
}
|
|
|
|
.wwg-card-pulse .wwg-card-glow {
|
|
opacity: 0.85;
|
|
}
|
|
|
|
.wwg-icon {
|
|
width: 52px;
|
|
height: 52px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 14px;
|
|
background: rgba(200, 16, 46, 0.06);
|
|
color: #c8102e;
|
|
margin-bottom: 20px;
|
|
transition:
|
|
background-color 0.7s cubic-bezier(0.16, 1, 0.3, 1) 0.16s,
|
|
filter 0.7s cubic-bezier(0.16, 1, 0.3, 1) 0.16s;
|
|
}
|
|
|
|
.wwg-card-flown .wwg-icon {
|
|
background: rgba(200, 16, 46, 0.1);
|
|
}
|
|
|
|
.wwg-card-pulse .wwg-icon {
|
|
background: rgba(200, 16, 46, 0.14);
|
|
filter: brightness(1.12);
|
|
}
|
|
|
|
.wwg-card-title {
|
|
font-size: 17px !important;
|
|
font-weight: 700 !important;
|
|
line-height: 1.3 !important;
|
|
text-transform: none !important;
|
|
color: #111111 !important;
|
|
margin: 0 0 8px 0;
|
|
}
|
|
|
|
.wwg-card-desc {
|
|
font-size: 14.5px;
|
|
line-height: 1.55;
|
|
color: rgba(17, 17, 17, 0.6);
|
|
margin: 0;
|
|
}
|
|
|
|
@media (max-width: 1024px) {
|
|
.wwg-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
}
|
|
|
|
@media (max-width: 600px) {
|
|
.wwg-grid { grid-template-columns: 1fr; padding-left: 28px; }
|
|
/* var(--space-section-lg) floors at a fixed 52px on any phone
|
|
width; trimmed to a real mobile value to match the section
|
|
rhythm near the footer. Matches this file's own MOBILE_BQ
|
|
breakpoint used by the flight-path animation logic. */
|
|
.wwg-section { padding-top: 44px; padding-bottom: 44px; }
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.wwg-card, .wwg-card-glow, .wwg-icon {
|
|
transition: none;
|
|
}
|
|
}
|
|
`,
|
|
}}
|
|
/>
|
|
|
|
<section className="wwg-section" id="wings-why">
|
|
<div className="dm-section-container">
|
|
<div className="wwg-inner">
|
|
<ScrollReveal delay={0.05} duration={0.75} yOffset={25} className="wwg-header">
|
|
<div className="wwg-eyebrow">/ Why Doormile Wings /</div>
|
|
<h2 className="wwg-title">Built for the speed of the Gen-Z economy</h2>
|
|
</ScrollReveal>
|
|
|
|
<div className="wwg-grid-wrap" ref={wrapRef}>
|
|
<svg ref={svgRef} className="wwg-flightpath" aria-hidden="true" focusable="false">
|
|
{/* Rendered first (behind) so its blur/extra width reads as a
|
|
soft glow sitting under the route rather than visually
|
|
thickening it — the crisp .wwg-flightpath-line painted
|
|
after it always defines the route's actual edges. */}
|
|
<path ref={illumRef} className="wwg-illum-trail" d="" />
|
|
<path ref={pathRef} className="wwg-flightpath-line" d="" />
|
|
<g ref={travelerRef} className="wwg-traveler">
|
|
{/* Large feathered bloom: the true hero of the traveler, an
|
|
ambient glow with no direction of its own. */}
|
|
<circle className="wwg-pulse-bloom" cx="0" cy="0" r="26" />
|
|
<g ref={directionalRef} className="wwg-traveler-directional">
|
|
<ellipse className="wwg-pulse-halo" cx="0" cy="0" rx="11" ry="6" />
|
|
</g>
|
|
<circle className="wwg-pulse-core" cx="0" cy="0" r="2.6" />
|
|
{/* Painted after (on top of) the core so the aircraft's
|
|
silhouette stays readable instead of being buried under
|
|
the bright core dot. Rotation mirrors directionalRef
|
|
exactly (see onUpdate) despite living in its own group. */}
|
|
<g ref={aircraftDirectionalRef} className="wwg-traveler-directional">
|
|
{/* The stock glyph's original 24x24 viewBox had ~3.5
|
|
units of dead padding on every side around the actual
|
|
ink (true silhouette bbox was only 17x20) — scaling
|
|
from the nominal 24-unit box would have kept baking
|
|
that padding in and rendering smaller than intended.
|
|
These coordinates instead start from the silhouette's
|
|
own tight bbox (no padding), centered on (0,0), then
|
|
scaled 1.3x — giving a true rendered size of ~22x26px,
|
|
clearing 22px on both axes. Deliberately no
|
|
scale()/translate() transform: baked directly into
|
|
the path data so it renders at full native
|
|
resolution rather than through a transform stacked on
|
|
top of this group's own rotation transform. */}
|
|
<path
|
|
className="wwg-aircraft"
|
|
d="M0,-13 C-0.78,-13 -1.3,-12.22 -1.3,-11.31 L-1.3,-3.9 L-11.05,1.3 L-11.05,3.9 L-1.3,0.91 L-1.3,9.1 L-4.55,11.44 L-4.55,13 L0,11.7 L4.55,13 L4.55,11.44 L1.3,9.1 L1.3,0.91 L11.05,3.9 L11.05,1.3 L1.3,-3.9 L1.3,-11.31 C1.3,-12.22 0.78,-13 0,-13 Z"
|
|
fill="currentColor"
|
|
/>
|
|
</g>
|
|
</g>
|
|
</svg>
|
|
|
|
<StaggerChildren stagger={0.1} duration={0.7} yOffset={30} className="wwg-grid">
|
|
{FEATURES.map((f, i) => (
|
|
<div
|
|
key={f.title}
|
|
className="wwg-card"
|
|
ref={(el) => {
|
|
cardRefs.current[i] = el;
|
|
}}
|
|
>
|
|
<span className="wwg-card-glow" aria-hidden="true" />
|
|
<div
|
|
className="wwg-icon"
|
|
aria-hidden="true"
|
|
ref={(el) => {
|
|
iconRefs.current[i] = el;
|
|
}}
|
|
>
|
|
{f.icon}
|
|
</div>
|
|
<h3 className="wwg-card-title">{f.title}</h3>
|
|
<p className="wwg-card-desc">{f.desc}</p>
|
|
</div>
|
|
))}
|
|
</StaggerChildren>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</>
|
|
);
|
|
}
|