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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- The route — desktop horizontal -->
|
<!-- The route — desktop horizontal. .rr-fullbleed escapes the parent
|
||||||
<div class="rr-wrap rr-desktop">
|
.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" id="rr-scroll">
|
||||||
<div class="rr-scroll-inner">
|
<div class="rr-scroll-inner">
|
||||||
<div class="rr-track" style={`width: ${layout.trackWidth}px; height: 420px;`}>
|
<div class="rr-track" id="rr-track" style={`width: ${layout.trackWidth}px; height: 420px;`}>
|
||||||
<svg class="rr-path" width={layout.trackWidth} height="420" aria-hidden="true">
|
<svg class="rr-path" id="rr-path-svg" width={layout.trackWidth} height="420" aria-hidden="true">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="rr-path-gradient" x1="0" y1="0" x2="1" y2="0">
|
<linearGradient id="rr-path-gradient" x1="0" y1="0" x2="1" y2="0">
|
||||||
<stop offset="0" stop-color="#2a2520" stop-opacity="0.55"/>
|
<stop offset="0" stop-color="#2a2520" stop-opacity="0.55"/>
|
||||||
|
|
@ -84,13 +87,14 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
{layout.pathD && (
|
{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>
|
</svg>
|
||||||
|
|
||||||
{items.map((item, i) => (
|
{items.map((item, i) => (
|
||||||
<div
|
<div
|
||||||
class="rr-milestone"
|
class="rr-milestone"
|
||||||
|
data-y={layout.itemY[i]}
|
||||||
style={`left: ${layout.itemX[i]}px; top: ${layout.itemY[i]}px;`}
|
style={`left: ${layout.itemX[i]}px; top: ${layout.itemY[i]}px;`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
@ -153,25 +157,73 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Vanilla nav for the desktop horizontal route:
|
// Vanilla route runtime. Two concerns:
|
||||||
// - arrows scrollBy 72% of the viewport per click
|
// 1. Nav: arrow buttons, edge fades, initial-scroll into shipping
|
||||||
// - edge fades flip on at-start / at-end
|
// 2. Viewport-aware layout — SSR uses a 1100px fallback for the math;
|
||||||
// - progress dots track scroll position
|
// on the client we know the real viewport, so we recompute itemX
|
||||||
// - on mount, scroll the 'you are here' milestone roughly 25% from left
|
// 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) => {
|
document.querySelectorAll<HTMLElement>('.route').forEach((section) => {
|
||||||
const scroll = section.querySelector<HTMLElement>('#rr-scroll');
|
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 prev = section.querySelector<HTMLButtonElement>('#rr-prev');
|
||||||
const next = section.querySelector<HTMLButtonElement>('#rr-next');
|
const next = section.querySelector<HTMLButtonElement>('#rr-next');
|
||||||
const fadeL = section.querySelector<HTMLElement>('#rr-fade-l');
|
const fadeL = section.querySelector<HTMLElement>('#rr-fade-l');
|
||||||
const fadeR = section.querySelector<HTMLElement>('#rr-fade-r');
|
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' }));
|
/** Recompute trackWidth + itemX[] + pathD using the live viewport. */
|
||||||
next?.addEventListener('click', () => scroll.scrollBy({ left: step(), behavior: 'smooth' }));
|
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 max = scroll!.scrollWidth - scroll!.clientWidth;
|
||||||
const atStart = scroll!.scrollLeft <= 2;
|
const atStart = scroll!.scrollLeft <= 2;
|
||||||
const atEnd = scroll!.scrollLeft >= max - 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';
|
if (fadeR) fadeR.style.opacity = atEnd ? '0' : '1';
|
||||||
}
|
}
|
||||||
|
|
||||||
scroll.addEventListener('scroll', update, { passive: true });
|
scroll.addEventListener('scroll', updateNav, { passive: true });
|
||||||
window.addEventListener('resize', update);
|
|
||||||
|
|
||||||
// Initial scroll: park the most recent shipping item ~25% from the left.
|
// Debounced resize → recompute layout + refresh nav state. 120ms is
|
||||||
const initialX = Number(section.dataset.initialX ?? 0);
|
// long enough to coalesce drag-resize events without feeling laggy.
|
||||||
if (initialX > 0) {
|
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 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;
|
scroll.scrollLeft = target;
|
||||||
}
|
}
|
||||||
|
setTimeout(updateNav, 50);
|
||||||
// First paint may happen before layout settles — re-measure shortly after.
|
updateNav();
|
||||||
setTimeout(update, 50);
|
|
||||||
update();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -240,6 +300,15 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
||||||
|
|
||||||
/* ── Desktop route ──────────────────────────────────────────────── */
|
/* ── Desktop route ──────────────────────────────────────────────── */
|
||||||
.rr-wrap { position: relative; }
|
.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 {
|
.rr-scroll {
|
||||||
/* overflow-x: auto + overflow-y: visible is the only thing that lets
|
/* overflow-x: auto + overflow-y: visible is the only thing that lets
|
||||||
hovered cards expand above/below the track without being clipped.
|
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-snap-type: x mandatory;
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
/* Top/bottom give cards room to grow above/below the track. The 60px
|
/* Top/bottom give cards room to grow above/below the track. The 80px
|
||||||
sides give the first/last cards room when fully scrolled. */
|
sides give the first/last cards room when fully scrolled inside
|
||||||
padding: 60px 60px 80px;
|
the now full-bleed container — small but visible breathing room
|
||||||
scroll-padding-left: 60px;
|
between the route and the absolute viewport edges. */
|
||||||
scroll-padding-right: 60px;
|
padding: 60px 80px 80px;
|
||||||
|
scroll-padding-left: 80px;
|
||||||
|
scroll-padding-right: 80px;
|
||||||
}
|
}
|
||||||
.rr-scroll::-webkit-scrollbar { display: none; }
|
.rr-scroll::-webkit-scrollbar { display: none; }
|
||||||
.rr-scroll-inner { /* structural — keeps the track on its own layer */ }
|
.rr-scroll-inner { /* structural — keeps the track on its own layer */ }
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue