"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: (
),
},
{
title: "AI-Powered Operations",
desc: "MileTruth™ AI continuously forecasts demand and optimizes every route and load.",
icon: (
),
},
{
title: "Regional Connectivity",
desc: "Dedicated air capacity linking major economic hubs, not passenger-belly space.",
icon: (
),
},
{
title: "EV Last Mile",
desc: "Zero-emission electric vehicles complete every first- and final-mile delivery.",
icon: (
),
},
];
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(null);
const svgRef = useRef(null);
const pathRef = useRef(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(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(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(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(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 (
<>