Files
doormile_react/src/components/sections/WingsWhyGrid.tsx
2026-07-09 15:20:34 +05:30

956 lines
39 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;
// Row-wrapped grid layouts only (e.g. the 601-1024px 2-column tablet
// breakpoint) — single-row layouts (desktop, mobile) never use these.
const ROW_Y_TOLERANCE = 12; // px tolerance for clustering card anchors into the same row
const ROW_CLEARANCE_PADDING = 6; // safety margin between a crest and the row above's card bottom
// Exact max of buildWavePoints' ampFactor (1 + 0.28 + 0.15), used to clamp a
// row's wave amplitude so no crest can ever reach into the row above it,
// regardless of segment index/phase.
const MAX_WAVE_AMP_FACTOR = 1 + 0.28 + 0.15;
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;
}
// A non-mobile card anchor, carrying its source card's full box (wrap-
// relative) alongside the {x,y} route point — top/bottom are needed to
// group anchors into rows and clamp a row's wave amplitude against the row
// above it; left/right are needed to find the empty column gutter (the
// icon's own x sits near the left edge of its card, not the card's true
// horizontal center, so card edges — not icon x — must drive that
// calculation). Never leaves measure(); only plain Point[] is returned.
interface CardAnchor extends Point {
top: number;
bottom: number;
left: number;
right: 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;
}
// Clusters card anchors into visual rows by their shared top edge. CSS grid
// auto-flow is row-major and `align-items: stretch` (the grid default) makes
// same-row cards share cardRect.top exactly, so a single-pass walk against
// the current row's reference y (with a small tolerance for sub-pixel
// getBoundingClientRect noise) is sufficient — no need for full clustering.
function groupIntoRows(anchors: CardAnchor[]): CardAnchor[][] {
const rows: CardAnchor[][] = [];
for (const a of anchors) {
const row = rows[rows.length - 1];
if (row && Math.abs(row[0].top - a.top) <= ROW_Y_TOLERANCE) {
row.push(a);
} else {
rows.push([a]);
}
}
return rows;
}
// The x midpoint of the empty strip between two adjacent columns in a row —
// i.e. a real, always-card-free gutter, derived from actual card edges (not
// icon x, which sits near each card's left padding rather than its true
// horizontal center). `side` picks which internal gutter: "right" is
// between the two rightmost columns, "left" between the two leftmost — for
// the current 2-column layout these are the same single gutter, but this
// stays correct if a wider grid (3+ columns) ever wraps into multiple rows.
function computeColumnGutterX(row: CardAnchor[], side: "left" | "right"): number {
const sorted = [...row].sort((a, b) => a.left - b.left);
if (sorted.length < 2) return (sorted[0].left + sorted[0].right) / 2;
return side === "right"
? (sorted[sorted.length - 2].right + sorted[sorted.length - 1].left) / 2
: (sorted[0].right + sorted[1].left) / 2;
}
// Builds a safe serpentine (boustrophedon) route across a row-wrapped grid:
// each row is traversed in alternating direction so consecutive rows' ends
// line up on the same side, and the vertical drop between rows is routed
// through the always-empty column gutter on that side rather than through
// either column's own x — the only way to guarantee the connector never
// crosses a card, since a card anchor sits only ~10px below the card above
// it in the same column. Each row's own wave amplitude is clamped to the
// measured clearance above it so no crest can reach into the row above.
function buildMultiRowRoute(rows: CardAnchor[][], baseAmplitude: number): Point[] {
const out: Point[] = [];
let prevRowBottom: number | null = null;
rows.forEach((row, ri) => {
const ordered = ri % 2 === 0 ? row : [...row].reverse();
const rowAnchorY = ordered[0].y;
const amplitude =
prevRowBottom === null
? baseAmplitude
: Math.max(
0,
Math.min(baseAmplitude, (rowAnchorY - prevRowBottom - ROW_CLEARANCE_PADDING) / MAX_WAVE_AMP_FACTOR)
);
const waved = buildWavePoints(
ordered.map(({ x, y }) => ({ x, y })),
amplitude
);
if (out.length > 0) {
const prevPoint = out[out.length - 1];
const nextPoint = waved[0];
// Row (ri-1) ends at its right extreme when it traveled left-to-right
// (even index, unreversed), left extreme when it traveled
// right-to-left (odd index, reversed) — the gutter must sit on that
// same side so the dogleg never has to cross back over a card.
const side: "left" | "right" = (ri - 1) % 2 === 0 ? "right" : "left";
const gutterX = computeColumnGutterX(rows[ri - 1], side);
out.push({ x: gutterX, y: prevPoint.y }, { x: gutterX, y: nextPoint.y });
}
out.push(...waved);
prevRowBottom = Math.max(...row.map((a) => a.bottom));
});
return out;
}
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();
// Card box (wrap-relative) is captured alongside x/y for every card,
// mobile included, so the shape is uniform — only the non-mobile
// branch below actually reads top/bottom/left/right (to group into
// rows, clamp wave amplitude against the row above, and find the
// empty column gutter for inter-row transitions).
const anchors = cardRefs.current
.map((card, i) => {
const icon = iconRefs.current[i];
if (!card || !icon) return null;
const cardRect = card.getBoundingClientRect();
const iconRect = icon.getBoundingClientRect();
const top = cardRect.top - wrapRect.top;
const bottom = cardRect.bottom - wrapRect.top;
const left = cardRect.left - wrapRect.left;
const right = cardRect.right - wrapRect.left;
if (isMobile) {
return {
x: 12,
y: iconRect.top + iconRect.height / 2 - wrapRect.top,
top,
bottom,
left,
right,
};
}
return {
x: iconRect.left + iconRect.width / 2 - wrapRect.left,
y: cardRect.top - wrapRect.top - 14,
top,
bottom,
left,
right,
};
})
.filter((p): p is CardAnchor => p !== null);
const points: Point[] = anchors.map(({ x, y }) => ({ x, y }));
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));
let routePoints: Point[];
if (isMobile) {
routePoints = points;
} else {
const rows = groupIntoRows(anchors);
// A single row (desktop, always 4-up) takes the exact original
// path — no behavior change there. Only a row-wrapped layout (the
// tablet/small-laptop 2-column breakpoint) uses the row-aware
// serpentine route.
routePoints = rows.length <= 1 ? buildWavePoints(points, amplitude) : buildMultiRowRoute(rows, 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>
</>
);
}