update navber wings
This commit is contained in:
@@ -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<HTMLDivElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(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);
|
||||
|
||||
Reference in New Issue
Block a user