"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 ( <>