feat(roadmap): animate the route with scroll-linked motion

Adds three things to the /roadmap horizontal route:
- a scroll-proximity focus effect (centre milestone scales/brightens,
  edges recede) so the track feels alive as you move it;
- eased, accumulating wheel/trackpad scrolling instead of instant jumps;
- a trailing tail past the last milestone (opt-in tailLength) so the line
  keeps going and the final item can scroll toward the centre.
Also enlarges the milestone card text for readability.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-06-10 17:18:13 +02:00
parent 3cf7171eb2
commit c9efe869ea
2 changed files with 120 additions and 21 deletions

View file

@ -15,10 +15,18 @@ const CONTENT_MAX = 1152;
const DEFAULT_PADDING = 60;
const paddingLeft = Math.max(DEFAULT_PADDING, (viewportWidth - CONTENT_MAX) / 2);
// Trailing room past the last milestone = a quarter of the viewport, so the
// final item can scroll until it sits halfway between the right edge and the
// screen centre (~0.75 of the viewport). The drawn line extends the same
// distance so it keeps going as that item arrives. (Client recompute redoes
// this with the real viewport; this is the SSR fallback.)
const trailing = Math.round(viewportWidth * 0.25);
const layout = computeRouteLayout({
itemCount: items.length,
viewportWidth,
paddingLeft,
paddingRight: trailing,
tailLength: trailing,
});
const travelledStop = travelledStopFor(items.map(i => i.status));
@ -172,6 +180,7 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
const MIN_SPACING = 320;
const PADDING_X = 60;
const CONTENT_MAX = 1152; // matches --content-max (72rem)
const MID_Y = 210; // vertical centreline = track height (420) / 2
document.querySelectorAll<HTMLElement>('.route').forEach((section) => {
const scroll = section.querySelector<HTMLElement>('#rr-scroll');
@ -187,6 +196,9 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
const itemCount = milestones.length;
const itemY: number[] = milestones.map(m => Number(m.dataset.y ?? 0));
// Current horizontal positions, kept in sync by recompute() — used by
// the scroll-proximity focus effect.
let itemXs: number[] = [];
/** Recompute trackWidth + itemX[] + pathD using the live viewport. */
function recompute() {
@ -197,7 +209,10 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
// Match the SSR offset — first item aligns with the content-column
// left edge so the route lines up with the dispatch banner below.
const paddingLeft = Math.max(PADDING_X, (vw - CONTENT_MAX) / 2);
const trackWidth = paddingLeft + usableWidth + PADDING_X;
// Trailing room = a quarter of the viewport so the final milestone can
// scroll until it sits halfway to the screen centre (~0.75 of vw).
const trailing = Math.round(vw * 0.25);
const trackWidth = paddingLeft + usableWidth + trailing;
const itemX: number[] = [];
for (let i = 0; i < itemCount; i += 1) {
@ -218,6 +233,13 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
const cx = (itemX[i - 1] + itemX[i]) / 2;
d += ` C ${cx} ${itemY[i - 1]}, ${cx} ${itemY[i]}, ${itemX[i]} ${itemY[i]}`;
}
// Trailing tail: continue the line past the last milestone, easing
// back to the centreline so it keeps going as that item scrolls in.
const lastX = itemX[itemCount - 1];
const lastY = itemY[itemCount - 1];
const tailEndX = lastX + trailing;
const tcx = (lastX + tailEndX) / 2;
d += ` C ${tcx} ${lastY}, ${tcx} ${MID_Y}, ${tailEndX} ${MID_Y}`;
}
// Apply.
@ -225,6 +247,25 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
svg!.setAttribute('width', String(trackWidth));
if (pathD && d) pathD.setAttribute('d', d);
milestones.forEach((m, i) => { m.style.left = `${itemX[i]}px`; });
itemXs = itemX;
}
/* Scroll-proximity focus: emphasise the milestone nearest the centre
of the viewport and let those toward the edges recede + dim. Driven
every frame that the track moves (via updateNav), so movement feels
alive rather than a flat pan. Not parallax — every milestone still
tracks the scroll 1:1; only scale + opacity shift with position. */
function updateFocus() {
if (!scroll || itemXs.length === 0) return;
const center = scroll.scrollLeft + scroll.clientWidth / 2;
const half = Math.max(1, scroll.clientWidth / 2);
milestones.forEach((m, i) => {
const t = Math.min(1, Math.abs((itemXs[i] ?? 0) - center) / half);
const scale = (1 - 0.10 * t).toFixed(3);
const op = (1 - 0.42 * t).toFixed(3);
m.style.transform = `translate(-50%, -50%) scale(${scale})`;
m.style.opacity = op;
});
}
/* Edge state — fades + advance disable. */
@ -235,6 +276,7 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
if (fadeL) fadeL.style.opacity = atStart ? '0' : '1';
if (fadeR) fadeR.style.opacity = atEnd ? '0' : '1';
if (advance) advance.classList.toggle('rr-at-end', atEnd);
updateFocus();
}
/* ── Unified scroll handling: wheel, drag, animated glide. ──
@ -251,10 +293,13 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
let velocity = 0; // px/ms, signed (positive = pointer moving right)
let momentumRAF: number | null = null;
let animateRAF: number | null = null;
let wheelRAF: number | null = null;
let wheelTarget = 0; // eased target scrollLeft for wheel/trackpad input
function cancelAnims() {
if (momentumRAF !== null) { cancelAnimationFrame(momentumRAF); momentumRAF = null; }
if (animateRAF !== null) { cancelAnimationFrame(animateRAF); animateRAF = null; }
if (wheelRAF !== null) { cancelAnimationFrame(wheelRAF); wheelRAF = null; }
}
function animateScrollTo(target: number, durationMs: number) {
@ -279,9 +324,34 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
const dx = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
if (dx === 0) return;
e.preventDefault();
cancelAnims();
scroll!.scrollLeft += dx;
// Drop any drag-momentum or arrow glide that's mid-flight, but keep
// building onto the wheel target so quick successive ticks accumulate
// distance and the glide stays continuous.
if (momentumRAF !== null) { cancelAnimationFrame(momentumRAF); momentumRAF = null; }
if (animateRAF !== null) { cancelAnimationFrame(animateRAF); animateRAF = null; }
const max = scroll!.scrollWidth - scroll!.clientWidth;
const base = wheelRAF !== null ? wheelTarget : scroll!.scrollLeft;
wheelTarget = Math.max(0, Math.min(max, base + dx));
// Ease scrollLeft toward the target each frame (~0.2 of the remaining
// distance), so the wheel feels like a smooth glide rather than a jump.
if (wheelRAF === null) {
const step = () => {
const diff = wheelTarget - scroll!.scrollLeft;
if (Math.abs(diff) < 0.5) {
scroll!.scrollLeft = wheelTarget;
wheelRAF = null;
updateNav();
return;
}
scroll!.scrollLeft += diff * 0.2;
updateNav();
wheelRAF = requestAnimationFrame(step);
};
wheelRAF = requestAnimationFrame(step);
}
}, { passive: false });
// Drag — pointer events; momentum on release.
@ -430,7 +500,22 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
.rr-milestone {
position: absolute;
/* Inline transform/opacity are driven per-frame from JS based on each
milestone's distance from the viewport centre, so the track comes
alive as you move it (centre milestone emphasised, edges recede).
The short ease softens the per-frame updates into a glide. */
transform: translate(-50%, -50%);
transition: transform .2s ease-out, opacity .2s ease-out;
will-change: transform, opacity;
}
/* A hovered/focused card always reads at full size and brightness,
regardless of where it sits along the route — overrides the inline
focus styles JS sets. */
.rr-milestone:has(.rr-card:hover),
.rr-milestone:has(.rr-card:focus-visible) {
transform: translate(-50%, -50%) scale(1) !important;
opacity: 1 !important;
z-index: 10;
}
.rr-dot {
@ -477,8 +562,8 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
.rr-card {
display: block;
width: 220px;
padding: 12px 14px;
width: 240px;
padding: 14px 16px;
border-radius: 10px;
background: transparent;
color: inherit;
@ -504,16 +589,16 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
.rr-eyebrow {
font-family: var(--font-sans);
font-size: 9px;
font-size: 11px;
letter-spacing: 1.4px;
text-transform: uppercase;
margin: 0 0 6px;
margin: 0 0 7px;
font-weight: 600;
}
.rr-card-title {
font-family: var(--font-serif);
font-size: 16px;
line-height: 1.2;
font-size: 20px;
line-height: 1.25;
color: var(--on-surface);
margin: 0;
}
@ -529,20 +614,20 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
}
.rr-card:hover .rr-more,
.rr-card:focus-visible .rr-more {
max-height: 280px;
max-height: 340px;
opacity: 1;
margin-top: 10px;
margin-top: 12px;
}
.rr-desc {
font-family: var(--font-sans);
font-size: 12px;
line-height: 1.55;
font-size: 14px;
line-height: 1.6;
color: var(--on-surface-variant);
margin: 0 0 10px;
}
.rr-trail {
font-family: var(--font-sans);
font-size: 9px;
font-size: 10px;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--on-surface-muted);
@ -661,7 +746,7 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
}
.rrm-eyebrow {
font-family: var(--font-sans);
font-size: 9px;
font-size: 11px;
letter-spacing: 1.4px;
text-transform: uppercase;
margin: 0;
@ -669,21 +754,21 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
}
.rrm-title {
font-family: var(--font-serif);
font-size: 18px;
line-height: 1.2;
font-size: 21px;
line-height: 1.25;
color: var(--on-surface);
margin: 0;
}
.rrm-desc {
font-family: var(--font-sans);
font-size: 13px;
line-height: 1.55;
font-size: 15px;
line-height: 1.6;
color: var(--on-surface-variant);
margin: 0;
}
.rrm-trail {
font-family: var(--font-sans);
font-size: 9px;
font-size: 10px;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--on-surface-muted);

View file

@ -22,6 +22,9 @@ export interface LayoutOpts {
paddingX?: number; // default 60 — symmetric leading + trailing padding
paddingLeft?: number; // overrides paddingX on the leading edge only
paddingRight?: number; // overrides paddingX on the trailing edge only
tailLength?: number; // px to extend the drawn path past the final
// milestone, easing back to the centreline — lets
// the line keep going as the last item scrolls in
}
export interface LayoutResult {
@ -103,6 +106,17 @@ export function computeRouteLayout(opts: LayoutOpts): LayoutResult {
d += ` C ${cx} ${itemY[i - 1]}, ${cx} ${itemY[i]}, ${itemX[i]} ${itemY[i]}`;
}
// Trailing tail: continue the path past the last milestone, easing it back
// to the centreline so the line keeps going while the final item scrolls
// toward the middle. Tangent stays flat at the last dot (control y = lastY).
if (opts.tailLength && opts.tailLength > 0) {
const lastX = itemX[itemCount - 1];
const lastY = itemY[itemCount - 1];
const tailEndX = lastX + opts.tailLength;
const cx = (lastX + tailEndX) / 2;
d += ` C ${cx} ${lastY}, ${cx} ${midY}, ${tailEndX} ${midY}`;
}
return { trackWidth, pathD: d, itemX, itemY, cardSide, midY };
}