+
))}
diff --git a/src/components/sections/WingsWhyGrid.tsx b/src/components/sections/WingsWhyGrid.tsx
index 98dcbc6..2e6722b 100644
--- a/src/components/sections/WingsWhyGrid.tsx
+++ b/src/components/sections/WingsWhyGrid.tsx
@@ -25,6 +25,14 @@ 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 = [
{
@@ -79,6 +87,20 @@ interface Point {
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}`;
@@ -126,6 +148,87 @@ function buildWavePoints(points: Point[], amplitude: number): Point[] {
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(null);
const svgRef = useRef(null);
@@ -198,24 +301,43 @@ export default function WingsWhyGrid() {
const wrapRect = wrap.getBoundingClientRect();
const isMobile = isMobileLayout();
- const points = cardRefs.current
+ // 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 Point => p !== null);
+ .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));
@@ -228,7 +350,18 @@ export default function WingsWhyGrid() {
const amplitude = isMobile
? 0
: Math.max(WAVE_AMPLITUDE_MIN, Math.min(WAVE_AMPLITUDE_MAX, wrapRect.width / 20));
- const routePoints = isMobile ? points : buildWavePoints(points, amplitude);
+
+ 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);