diff --git a/src/components/RoadmapRoute.astro b/src/components/RoadmapRoute.astro index f6fce0c..54053a9 100644 --- a/src/components/RoadmapRoute.astro +++ b/src/components/RoadmapRoute.astro @@ -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('.route').forEach((section) => { const scroll = section.querySelector('#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; - updateNav(); + + // 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); diff --git a/src/lib/roadmap-layout.ts b/src/lib/roadmap-layout.ts index 4b19497..69c0d96 100644 --- a/src/lib/roadmap-layout.ts +++ b/src/lib/roadmap-layout.ts @@ -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 }; }