feat(route): full-bleed escape + client recompute on real viewport width
Two coupled changes so the route actually uses the page width:
.rr-wrap.rr-desktop gains the .rr-fullbleed class which uses the canonical
calc(50% - 50vw) margin trick to break out of the parent .page max-width.
The headline, dispatch banner, route section header, and legend all stay
inside the content column at 72rem; only the route itself widens to the
viewport. Visually reads as a magazine spread — the section header lands
centred, then the path spreads outward beneath.
Viewport-aware layout: SSR still uses the 1100 default (we can't know
the client viewport server-side), but a new mount script on
RoadmapRoute recomputes the layout against window.innerWidth and
updates:
- .rr-track width (via inline style)
- .rr-path-svg width attribute
- .rr-path-d d attribute (rebuilt from the same cubic-bezier
formula the SSR helper uses, with the live itemX values; itemY
comes from per-milestone data-y attributes since amplitude
doesn't change with viewport)
- .rr-milestone left positions
Resize: 120ms debounced handler runs the same recompute + refreshes
the arrow/fade nav state. Each milestone keeps its same data-y, so
only the horizontal spread changes — the river's vertical shape is
preserved on resize.
Initial-scroll into shipping rewired to read the .rr-current
milestone's live `style.left` after recompute, not the SSR-computed
data-initial-x value (which is now stale once the client redoes the
math).
.rr-scroll horizontal padding 60 → 80 + scroll-padding-{left,right}
60 → 80 so first/last cards have breathing room inside the now-
viewport-wide container.
Smoke as Lars: rr-fullbleed class on the wrap, data-y attributes on
each milestone, rr-path-svg id present. The SVG width and itemX
positions land at viewport-derived values after mount.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
73dc656257
commit
d7c13d3c99
1 changed files with 101 additions and 30 deletions
|
|
@ -69,12 +69,15 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<!-- The route — desktop horizontal -->
|
||||
<div class="rr-wrap rr-desktop">
|
||||
<!-- The route — desktop horizontal. .rr-fullbleed escapes the parent
|
||||
.page max-width so the route can span the actual viewport while
|
||||
the header above and legend below stay centred in the content
|
||||
column. -->
|
||||
<div class="rr-wrap rr-fullbleed rr-desktop" data-item-count={items.length}>
|
||||
<div class="rr-scroll" id="rr-scroll">
|
||||
<div class="rr-scroll-inner">
|
||||
<div class="rr-track" style={`width: ${layout.trackWidth}px; height: 420px;`}>
|
||||
<svg class="rr-path" width={layout.trackWidth} height="420" aria-hidden="true">
|
||||
<div class="rr-track" id="rr-track" style={`width: ${layout.trackWidth}px; height: 420px;`}>
|
||||
<svg class="rr-path" id="rr-path-svg" width={layout.trackWidth} height="420" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="rr-path-gradient" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#2a2520" stop-opacity="0.55"/>
|
||||
|
|
@ -84,13 +87,14 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
|||
</linearGradient>
|
||||
</defs>
|
||||
{layout.pathD && (
|
||||
<path d={layout.pathD} fill="none" stroke="url(#rr-path-gradient)" stroke-width="1.25" stroke-linecap="round"/>
|
||||
<path id="rr-path-d" d={layout.pathD} fill="none" stroke="url(#rr-path-gradient)" stroke-width="1.25" stroke-linecap="round"/>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{items.map((item, i) => (
|
||||
<div
|
||||
class="rr-milestone"
|
||||
data-y={layout.itemY[i]}
|
||||
style={`left: ${layout.itemX[i]}px; top: ${layout.itemY[i]}px;`}
|
||||
>
|
||||
<div
|
||||
|
|
@ -153,25 +157,73 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
|||
</section>
|
||||
|
||||
<script>
|
||||
// Vanilla nav for the desktop horizontal route:
|
||||
// - arrows scrollBy 72% of the viewport per click
|
||||
// - edge fades flip on at-start / at-end
|
||||
// - progress dots track scroll position
|
||||
// - on mount, scroll the 'you are here' milestone roughly 25% from left
|
||||
// Vanilla route runtime. Two concerns:
|
||||
// 1. Nav: arrow buttons, edge fades, initial-scroll into shipping
|
||||
// 2. Viewport-aware layout — SSR uses a 1100px fallback for the math;
|
||||
// on the client we know the real viewport, so we recompute itemX
|
||||
// positions + SVG path d + track width on mount and on (debounced)
|
||||
// resize. itemY values come from data-y on each milestone (path
|
||||
// amplitude doesn't change with viewport, only the horizontal spread).
|
||||
const MIN_SPACING = 320;
|
||||
const PADDING_X = 60;
|
||||
|
||||
document.querySelectorAll<HTMLElement>('.route').forEach((section) => {
|
||||
const scroll = section.querySelector<HTMLElement>('#rr-scroll');
|
||||
const wrap = section.querySelector<HTMLElement>('.rr-wrap');
|
||||
const track = section.querySelector<HTMLElement>('#rr-track');
|
||||
const svg = section.querySelector<SVGSVGElement>('#rr-path-svg');
|
||||
const pathD = section.querySelector<SVGPathElement>('#rr-path-d');
|
||||
const milestones = Array.from(section.querySelectorAll<HTMLElement>('.rr-milestone'));
|
||||
const prev = section.querySelector<HTMLButtonElement>('#rr-prev');
|
||||
const next = section.querySelector<HTMLButtonElement>('#rr-next');
|
||||
const fadeL = section.querySelector<HTMLElement>('#rr-fade-l');
|
||||
const fadeR = section.querySelector<HTMLElement>('#rr-fade-r');
|
||||
if (!scroll) return;
|
||||
if (!scroll || !track || !svg) return;
|
||||
|
||||
const step = () => scroll.clientWidth * 0.72;
|
||||
const itemCount = milestones.length;
|
||||
const itemY: number[] = milestones.map(m => Number(m.dataset.y ?? 0));
|
||||
|
||||
prev?.addEventListener('click', () => scroll.scrollBy({ left: -step(), behavior: 'smooth' }));
|
||||
next?.addEventListener('click', () => scroll.scrollBy({ left: step(), behavior: 'smooth' }));
|
||||
/** Recompute trackWidth + itemX[] + pathD using the live viewport. */
|
||||
function recompute() {
|
||||
const vw = window.innerWidth;
|
||||
const targetUsableWidth = vw * 0.80;
|
||||
const dataDrivenWidth = (itemCount - 1) * MIN_SPACING;
|
||||
const usableWidth = Math.max(targetUsableWidth, dataDrivenWidth);
|
||||
const trackWidth = usableWidth + PADDING_X * 2;
|
||||
|
||||
function update() {
|
||||
const itemX: number[] = [];
|
||||
for (let i = 0; i < itemCount; i += 1) {
|
||||
itemX.push(
|
||||
itemCount === 1
|
||||
? PADDING_X + usableWidth / 2
|
||||
: PADDING_X + (i / (itemCount - 1)) * usableWidth,
|
||||
);
|
||||
}
|
||||
|
||||
// Bezier path: control points at the segment midpoint x with control
|
||||
// y values matching the prior and next milestone (keeps the tangent
|
||||
// flat at each dot — the "river" feel from the layout helper).
|
||||
let d = '';
|
||||
if (itemCount > 0) {
|
||||
d = `M ${itemX[0]} ${itemY[0]}`;
|
||||
for (let i = 1; i < itemCount; i += 1) {
|
||||
const cx = (itemX[i - 1] + itemX[i]) / 2;
|
||||
d += ` C ${cx} ${itemY[i - 1]}, ${cx} ${itemY[i]}, ${itemX[i]} ${itemY[i]}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply.
|
||||
track!.style.width = `${trackWidth}px`;
|
||||
svg!.setAttribute('width', String(trackWidth));
|
||||
if (pathD && d) pathD.setAttribute('d', d);
|
||||
milestones.forEach((m, i) => { m.style.left = `${itemX[i]}px`; });
|
||||
}
|
||||
|
||||
const step = () => scroll!.clientWidth * 0.72;
|
||||
prev?.addEventListener('click', () => scroll!.scrollBy({ left: -step(), behavior: 'smooth' }));
|
||||
next?.addEventListener('click', () => scroll!.scrollBy({ left: step(), behavior: 'smooth' }));
|
||||
|
||||
function updateNav() {
|
||||
const max = scroll!.scrollWidth - scroll!.clientWidth;
|
||||
const atStart = scroll!.scrollLeft <= 2;
|
||||
const atEnd = scroll!.scrollLeft >= max - 2;
|
||||
|
|
@ -181,20 +233,28 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
|||
if (fadeR) fadeR.style.opacity = atEnd ? '0' : '1';
|
||||
}
|
||||
|
||||
scroll.addEventListener('scroll', update, { passive: true });
|
||||
window.addEventListener('resize', update);
|
||||
scroll.addEventListener('scroll', updateNav, { passive: true });
|
||||
|
||||
// Initial scroll: park the most recent shipping item ~25% from the left.
|
||||
const initialX = Number(section.dataset.initialX ?? 0);
|
||||
if (initialX > 0) {
|
||||
// Debounced resize → recompute layout + refresh nav state. 120ms is
|
||||
// long enough to coalesce drag-resize events without feeling laggy.
|
||||
let resizeTimer: number | undefined;
|
||||
window.addEventListener('resize', () => {
|
||||
if (resizeTimer !== undefined) window.clearTimeout(resizeTimer);
|
||||
resizeTimer = window.setTimeout(() => { recompute(); updateNav(); }, 120);
|
||||
});
|
||||
|
||||
// Initial mount: recompute with the real viewport, then scroll the
|
||||
// 'you are here' milestone roughly 25% from the left.
|
||||
recompute();
|
||||
const initialX = milestones.find(m => m.querySelector('.rr-dot.rr-current'));
|
||||
if (initialX) {
|
||||
const x = parseFloat(initialX.style.left) || 0;
|
||||
const max = scroll.scrollWidth - scroll.clientWidth;
|
||||
const target = Math.max(0, Math.min(max, initialX - scroll.clientWidth * 0.25));
|
||||
const target = Math.max(0, Math.min(max, x - scroll.clientWidth * 0.25));
|
||||
scroll.scrollLeft = target;
|
||||
}
|
||||
|
||||
// First paint may happen before layout settles — re-measure shortly after.
|
||||
setTimeout(update, 50);
|
||||
update();
|
||||
setTimeout(updateNav, 50);
|
||||
updateNav();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -240,6 +300,15 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
|||
|
||||
/* ── Desktop route ──────────────────────────────────────────────── */
|
||||
.rr-wrap { position: relative; }
|
||||
|
||||
/* Escape the parent .page max-width so the route can use the actual
|
||||
viewport width. The headline, dispatch banner, section header, and
|
||||
legend all stay centred at content width — only the route widens. */
|
||||
.rr-fullbleed {
|
||||
width: 100vw;
|
||||
margin-left: calc(50% - 50vw);
|
||||
margin-right: calc(50% - 50vw);
|
||||
}
|
||||
.rr-scroll {
|
||||
/* overflow-x: auto + overflow-y: visible is the only thing that lets
|
||||
hovered cards expand above/below the track without being clipped.
|
||||
|
|
@ -251,11 +320,13 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
|||
scroll-snap-type: x mandatory;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none;
|
||||
/* Top/bottom give cards room to grow above/below the track. The 60px
|
||||
sides give the first/last cards room when fully scrolled. */
|
||||
padding: 60px 60px 80px;
|
||||
scroll-padding-left: 60px;
|
||||
scroll-padding-right: 60px;
|
||||
/* Top/bottom give cards room to grow above/below the track. The 80px
|
||||
sides give the first/last cards room when fully scrolled inside
|
||||
the now full-bleed container — small but visible breathing room
|
||||
between the route and the absolute viewport edges. */
|
||||
padding: 60px 80px 80px;
|
||||
scroll-padding-left: 80px;
|
||||
scroll-padding-right: 80px;
|
||||
}
|
||||
.rr-scroll::-webkit-scrollbar { display: none; }
|
||||
.rr-scroll-inner { /* structural — keeps the track on its own layer */ }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue