diff --git a/src/components/RoadmapRoute.astro b/src/components/RoadmapRoute.astro index a3ae81f..d798c91 100644 --- a/src/components/RoadmapRoute.astro +++ b/src/components/RoadmapRoute.astro @@ -210,9 +210,7 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex milestones.forEach((m, i) => { m.style.left = `${itemX[i]}px`; }); } - /* Edge state — fades + advance disable. The smooth glide animation - on advance click lands in the next commit; for now the click - uses native smooth scrollBy. */ + /* Edge state — fades + advance disable. */ function updateNav() { const max = scroll!.scrollWidth - scroll!.clientWidth; const atStart = scroll!.scrollLeft <= 2; @@ -222,8 +220,128 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex if (advance) advance.classList.toggle('rr-at-end', atEnd); } + /* ── Unified scroll handling: wheel, drag, animated glide. ── + No CSS scroll-snap and no scroll-behavior: smooth — both fight + the JS-driven smooth motion. Drag has momentum; wheel translates + vertical to horizontal; arrow click runs a cubic-ease animation. */ + + let isDragging = false; + let dragStartX = 0; + let dragStartScrollLeft = 0; + let dragTotalMovement = 0; + let lastMoveX = 0; + let lastMoveTime = 0; + let velocity = 0; // px/ms, signed (positive = pointer moving right) + let momentumRAF: number | null = null; + let animateRAF: number | null = null; + + function cancelAnims() { + if (momentumRAF !== null) { cancelAnimationFrame(momentumRAF); momentumRAF = null; } + if (animateRAF !== null) { cancelAnimationFrame(animateRAF); animateRAF = null; } + } + + function animateScrollTo(target: number, durationMs: number) { + cancelAnims(); + const start = scroll!.scrollLeft; + const delta = target - start; + const startTime = performance.now(); + const easeOut = (t: number) => 1 - Math.pow(1 - t, 3); + const step = () => { + const t = Math.min(1, (performance.now() - startTime) / durationMs); + scroll!.scrollLeft = start + delta * easeOut(t); + updateNav(); + if (t < 1) animateRAF = requestAnimationFrame(step); + else animateRAF = null; + }; + animateRAF = requestAnimationFrame(step); + } + + // Wheel — vertical wheel becomes horizontal scroll on this element. + // Trackpads sending horizontal deltaX go through unchanged (1:1, no scaling). + scroll.addEventListener('wheel', (e) => { + 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(); + }, { passive: false }); + + // Drag — pointer events; momentum on release. + scroll.addEventListener('pointerdown', (e) => { + if (e.button !== undefined && e.button !== 0) return; + // Don't start a drag when the click target is the advance button. + if (advance && advance.contains(e.target as Node)) return; + + isDragging = true; + dragStartX = e.pageX; + dragStartScrollLeft = scroll!.scrollLeft; + dragTotalMovement = 0; + lastMoveX = e.pageX; + lastMoveTime = performance.now(); + velocity = 0; + + cancelAnims(); + try { scroll!.setPointerCapture(e.pointerId); } catch { /* not all envs */ } + scroll!.classList.add('rr-dragging'); + }); + + scroll.addEventListener('pointermove', (e) => { + if (!isDragging) return; + const dx = e.pageX - dragStartX; + scroll!.scrollLeft = dragStartScrollLeft - dx; + dragTotalMovement = Math.max(dragTotalMovement, Math.abs(dx)); + + const now = performance.now(); + const dt = now - lastMoveTime; + if (dt > 0) velocity = (e.pageX - lastMoveX) / dt; + lastMoveX = e.pageX; + lastMoveTime = now; + + updateNav(); + }); + + function endDrag() { + if (!isDragging) return; + isDragging = false; + scroll!.classList.remove('rr-dragging'); + + // Click vs drag: anything under 5px total movement is a click — + // skip momentum and let the underlying card's handle the click. + if (dragTotalMovement < 5) return; + + // Otherwise it's a real drag — suppress the synthetic click that + // follows so a drag-then-release-over-a-card doesn't navigate. + const suppressClick = (ev: Event) => { + ev.stopPropagation(); + ev.preventDefault(); + scroll!.removeEventListener('click', suppressClick, true); + }; + scroll!.addEventListener('click', suppressClick, true); + + // Momentum: signed velocity, decay 0.93 per frame, stop under 0.4 px/frame. + // Direction inverted because dragging right moves scrollLeft left. + let v = -velocity * 16; + const step = () => { + if (Math.abs(v) < 0.4) { momentumRAF = null; return; } + scroll!.scrollLeft += v; + v *= 0.93; + updateNav(); + momentumRAF = requestAnimationFrame(step); + }; + momentumRAF = requestAnimationFrame(step); + } + + scroll.addEventListener('pointerup', endDrag); + scroll.addEventListener('pointercancel', endDrag); + + // Advance arrow — animated glide of 60% viewport width. advance?.addEventListener('click', () => { - scroll!.scrollBy({ left: scroll!.clientWidth * 0.6, behavior: 'smooth' }); + const target = Math.min( + scroll!.scrollLeft + scroll!.clientWidth * 0.6, + scroll!.scrollWidth - scroll!.clientWidth, + ); + animateScrollTo(target, 480); }); scroll.addEventListener('scroll', updateNav, { passive: true }); @@ -268,25 +386,31 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex 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. - The previous fix bandaged it with extra trackHeight; this is the - real fix. The .rr-scroll-inner wrapper is the spec-recommended - belt-and-braces in case a browser misbehaves on this combination. */ + /* overflow-x: auto + overflow-y: visible lets hovered cards expand + above/below the track without being clipped. .rr-scroll-inner is + the spec-recommended belt-and-braces wrapper in case a browser + misbehaves on the combination. + NO scroll-snap-type and NO scroll-behavior: smooth — both fight + the JS drag-momentum + animated-glide implementation below. The + path is meant to glide continuously, not click into fixed + positions. */ overflow-x: auto; overflow-y: visible; - scroll-snap-type: x mandatory; - scroll-behavior: smooth; scrollbar-width: none; - /* 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; + + /* Drag affordance: cursor + suppress native horizontal swipe so + horizontal drag triggers our handler while vertical drag still + scrolls the page. user-select stops drag from selecting text. */ + cursor: grab; + touch-action: pan-y; + user-select: none; } .rr-scroll::-webkit-scrollbar { display: none; } + .rr-scroll.rr-dragging { cursor: grabbing; } + /* Pointer-events off the cards mid-drag — prevents accidental hover + reveal while the track is being dragged past. */ + .rr-scroll.rr-dragging .rr-card { pointer-events: none; } .rr-scroll-inner { /* structural — keeps the track on its own layer */ } .rr-track { position: relative; } .rr-path { position: absolute; top: 0; left: 0; pointer-events: none; } @@ -302,7 +426,6 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex border-radius: 50%; box-shadow: 0 0 0 5px var(--background); /* halo cuts the path under the dot */ transition: transform .25s ease, box-shadow .25s ease; - scroll-snap-align: center; } .rr-dot.rr-current { transform: scale(1.3);