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:
parent
3cf7171eb2
commit
c9efe869ea
2 changed files with 120 additions and 21 deletions
|
|
@ -15,10 +15,18 @@ const CONTENT_MAX = 1152;
|
||||||
const DEFAULT_PADDING = 60;
|
const DEFAULT_PADDING = 60;
|
||||||
const paddingLeft = Math.max(DEFAULT_PADDING, (viewportWidth - CONTENT_MAX) / 2);
|
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({
|
const layout = computeRouteLayout({
|
||||||
itemCount: items.length,
|
itemCount: items.length,
|
||||||
viewportWidth,
|
viewportWidth,
|
||||||
paddingLeft,
|
paddingLeft,
|
||||||
|
paddingRight: trailing,
|
||||||
|
tailLength: trailing,
|
||||||
});
|
});
|
||||||
const travelledStop = travelledStopFor(items.map(i => i.status));
|
const travelledStop = travelledStopFor(items.map(i => i.status));
|
||||||
|
|
||||||
|
|
@ -172,6 +180,7 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
||||||
const MIN_SPACING = 320;
|
const MIN_SPACING = 320;
|
||||||
const PADDING_X = 60;
|
const PADDING_X = 60;
|
||||||
const CONTENT_MAX = 1152; // matches --content-max (72rem)
|
const CONTENT_MAX = 1152; // matches --content-max (72rem)
|
||||||
|
const MID_Y = 210; // vertical centreline = track height (420) / 2
|
||||||
|
|
||||||
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');
|
||||||
|
|
@ -187,6 +196,9 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
||||||
|
|
||||||
const itemCount = milestones.length;
|
const itemCount = milestones.length;
|
||||||
const itemY: number[] = milestones.map(m => Number(m.dataset.y ?? 0));
|
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. */
|
/** Recompute trackWidth + itemX[] + pathD using the live viewport. */
|
||||||
function recompute() {
|
function recompute() {
|
||||||
|
|
@ -197,7 +209,10 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
||||||
// Match the SSR offset — first item aligns with the content-column
|
// Match the SSR offset — first item aligns with the content-column
|
||||||
// left edge so the route lines up with the dispatch banner below.
|
// left edge so the route lines up with the dispatch banner below.
|
||||||
const paddingLeft = Math.max(PADDING_X, (vw - CONTENT_MAX) / 2);
|
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[] = [];
|
const itemX: number[] = [];
|
||||||
for (let i = 0; i < itemCount; i += 1) {
|
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;
|
const cx = (itemX[i - 1] + itemX[i]) / 2;
|
||||||
d += ` C ${cx} ${itemY[i - 1]}, ${cx} ${itemY[i]}, ${itemX[i]} ${itemY[i]}`;
|
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.
|
// Apply.
|
||||||
|
|
@ -225,6 +247,25 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
||||||
svg!.setAttribute('width', String(trackWidth));
|
svg!.setAttribute('width', String(trackWidth));
|
||||||
if (pathD && d) pathD.setAttribute('d', d);
|
if (pathD && d) pathD.setAttribute('d', d);
|
||||||
milestones.forEach((m, i) => { m.style.left = `${itemX[i]}px`; });
|
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. */
|
/* 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 (fadeL) fadeL.style.opacity = atStart ? '0' : '1';
|
||||||
if (fadeR) fadeR.style.opacity = atEnd ? '0' : '1';
|
if (fadeR) fadeR.style.opacity = atEnd ? '0' : '1';
|
||||||
if (advance) advance.classList.toggle('rr-at-end', atEnd);
|
if (advance) advance.classList.toggle('rr-at-end', atEnd);
|
||||||
|
updateFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Unified scroll handling: wheel, drag, animated glide. ──
|
/* ── 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 velocity = 0; // px/ms, signed (positive = pointer moving right)
|
||||||
let momentumRAF: number | null = null;
|
let momentumRAF: number | null = null;
|
||||||
let animateRAF: 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() {
|
function cancelAnims() {
|
||||||
if (momentumRAF !== null) { cancelAnimationFrame(momentumRAF); momentumRAF = null; }
|
if (momentumRAF !== null) { cancelAnimationFrame(momentumRAF); momentumRAF = null; }
|
||||||
if (animateRAF !== null) { cancelAnimationFrame(animateRAF); animateRAF = null; }
|
if (animateRAF !== null) { cancelAnimationFrame(animateRAF); animateRAF = null; }
|
||||||
|
if (wheelRAF !== null) { cancelAnimationFrame(wheelRAF); wheelRAF = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function animateScrollTo(target: number, durationMs: number) {
|
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;
|
const dx = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
|
||||||
if (dx === 0) return;
|
if (dx === 0) return;
|
||||||
e.preventDefault();
|
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();
|
updateNav();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scroll!.scrollLeft += diff * 0.2;
|
||||||
|
updateNav();
|
||||||
|
wheelRAF = requestAnimationFrame(step);
|
||||||
|
};
|
||||||
|
wheelRAF = requestAnimationFrame(step);
|
||||||
|
}
|
||||||
}, { passive: false });
|
}, { passive: false });
|
||||||
|
|
||||||
// Drag — pointer events; momentum on release.
|
// Drag — pointer events; momentum on release.
|
||||||
|
|
@ -430,7 +500,22 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
||||||
|
|
||||||
.rr-milestone {
|
.rr-milestone {
|
||||||
position: absolute;
|
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%);
|
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 {
|
.rr-dot {
|
||||||
|
|
@ -477,8 +562,8 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
||||||
|
|
||||||
.rr-card {
|
.rr-card {
|
||||||
display: block;
|
display: block;
|
||||||
width: 220px;
|
width: 240px;
|
||||||
padding: 12px 14px;
|
padding: 14px 16px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
|
@ -504,16 +589,16 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
||||||
|
|
||||||
.rr-eyebrow {
|
.rr-eyebrow {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 9px;
|
font-size: 11px;
|
||||||
letter-spacing: 1.4px;
|
letter-spacing: 1.4px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
margin: 0 0 6px;
|
margin: 0 0 7px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.rr-card-title {
|
.rr-card-title {
|
||||||
font-family: var(--font-serif);
|
font-family: var(--font-serif);
|
||||||
font-size: 16px;
|
font-size: 20px;
|
||||||
line-height: 1.2;
|
line-height: 1.25;
|
||||||
color: var(--on-surface);
|
color: var(--on-surface);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -529,20 +614,20 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
||||||
}
|
}
|
||||||
.rr-card:hover .rr-more,
|
.rr-card:hover .rr-more,
|
||||||
.rr-card:focus-visible .rr-more {
|
.rr-card:focus-visible .rr-more {
|
||||||
max-height: 280px;
|
max-height: 340px;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
margin-top: 10px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
.rr-desc {
|
.rr-desc {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
line-height: 1.55;
|
line-height: 1.6;
|
||||||
color: var(--on-surface-variant);
|
color: var(--on-surface-variant);
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
}
|
}
|
||||||
.rr-trail {
|
.rr-trail {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 9px;
|
font-size: 10px;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--on-surface-muted);
|
color: var(--on-surface-muted);
|
||||||
|
|
@ -661,7 +746,7 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
||||||
}
|
}
|
||||||
.rrm-eyebrow {
|
.rrm-eyebrow {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 9px;
|
font-size: 11px;
|
||||||
letter-spacing: 1.4px;
|
letter-spacing: 1.4px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
@ -669,21 +754,21 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
|
||||||
}
|
}
|
||||||
.rrm-title {
|
.rrm-title {
|
||||||
font-family: var(--font-serif);
|
font-family: var(--font-serif);
|
||||||
font-size: 18px;
|
font-size: 21px;
|
||||||
line-height: 1.2;
|
line-height: 1.25;
|
||||||
color: var(--on-surface);
|
color: var(--on-surface);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.rrm-desc {
|
.rrm-desc {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 13px;
|
font-size: 15px;
|
||||||
line-height: 1.55;
|
line-height: 1.6;
|
||||||
color: var(--on-surface-variant);
|
color: var(--on-surface-variant);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.rrm-trail {
|
.rrm-trail {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 9px;
|
font-size: 10px;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--on-surface-muted);
|
color: var(--on-surface-muted);
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ export interface LayoutOpts {
|
||||||
paddingX?: number; // default 60 — symmetric leading + trailing padding
|
paddingX?: number; // default 60 — symmetric leading + trailing padding
|
||||||
paddingLeft?: number; // overrides paddingX on the leading edge only
|
paddingLeft?: number; // overrides paddingX on the leading edge only
|
||||||
paddingRight?: number; // overrides paddingX on the trailing 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 {
|
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]}`;
|
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 };
|
return { trackWidth, pathD: d, itemX, itemY, cardSide, midY };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue