fix(route): span ~80% of viewport + scroll padding back to 60 + track 420

Three coupled fixes to the route's geometry:

- computeRouteLayout's width calculation flipped to Math.max(viewport
  * 0.80, itemCount * minSpacing + padding * 2). On a wide screen with
  few items the 80% target wins and the path stretches across the
  page; once item count makes the data-driven width exceed 80% (the
  carousel case), the data-driven value wins and the track extends
  past the viewport unchanged.
- .rr-scroll horizontal padding 140 → 60 each side. The previous 140
  was overcompensating; with the new 80% target the milestones already
  sit inside their own breathing room. 60 is card-half + 30px buffer,
  enough for a 220px card centred under a dot 60px from the edge.
  scroll-padding kept in sync at 60 for snap-stop landings.
- trackHeight default 580 → 420; midY 290 → 210. The 580 was bandaging
  the vertical-clipping issue — that fix lands in the next commit. With
  the clip properly addressed, 420 fits the path's amplitude 120 swing
  cleanly with no wasted vertical space.

Tests rewritten to match the new width semantics: 1 item @ 1000 →
920px (0.8 * 1000 + 120); 3 items @ 1400 → 1240px; 20 items @ 800 →
6200px (data-driven wins). midY assertion 290 → 210.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-12 14:32:29 +02:00
parent b76e1fc5c4
commit b4df8e10f1
3 changed files with 27 additions and 24 deletions

View file

@ -72,8 +72,8 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
<!-- The route — desktop horizontal --> <!-- The route — desktop horizontal -->
<div class="rr-wrap rr-desktop"> <div class="rr-wrap rr-desktop">
<div class="rr-scroll" id="rr-scroll"> <div class="rr-scroll" id="rr-scroll">
<div class="rr-track" style={`width: ${layout.trackWidth}px; height: 580px;`}> <div class="rr-track" style={`width: ${layout.trackWidth}px; height: 420px;`}>
<svg class="rr-path" width={layout.trackWidth} height="580" aria-hidden="true"> <svg class="rr-path" 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"/>
@ -243,12 +243,12 @@ 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;
/* Card-half (110px) + breathing buffer (30px) so first/last dots /* 60px is enough for a 220px card centred under a dot 60px from
have a card-width of clear space inside the viewport when scrolled the edge — card extends 50px past the dot, sits inside the
to the extremes. scroll-padding makes snap-stops land cleanly. */ padded container. */
padding: 0 140px 8px; padding: 0 60px 8px;
scroll-padding-left: 140px; scroll-padding-left: 60px;
scroll-padding-right: 140px; scroll-padding-right: 60px;
} }
.rr-scroll::-webkit-scrollbar { display: none; } .rr-scroll::-webkit-scrollbar { display: none; }
.rr-track { position: relative; } .rr-track { position: relative; }

View file

@ -33,7 +33,7 @@ export interface LayoutResult {
export function computeRouteLayout(opts: LayoutOpts): LayoutResult { export function computeRouteLayout(opts: LayoutOpts): LayoutResult {
const minSpacing = opts.minSpacingX ?? 320; const minSpacing = opts.minSpacingX ?? 320;
const trackHeight = opts.trackHeight ?? 580; const trackHeight = opts.trackHeight ?? 420;
const amplitude = opts.amplitude ?? 120; const amplitude = opts.amplitude ?? 120;
const padding = opts.paddingX ?? 60; const padding = opts.paddingX ?? 60;
const midY = trackHeight / 2; const midY = trackHeight / 2;
@ -51,11 +51,13 @@ export function computeRouteLayout(opts: LayoutOpts): LayoutResult {
}; };
} }
const usableWidth = Math.max( // Aim for ~80% of viewport for low item counts; data-driven minimum
opts.viewportWidth - padding * 2, // takes over once items × minSpacing exceeds that target (the carousel
(itemCount - 1) * minSpacing, // case — track extends past viewport).
); const targetUsableWidth = opts.viewportWidth * 0.80;
const trackWidth = usableWidth + padding * 2; const dataDrivenWidth = (itemCount - 1) * minSpacing;
const usableWidth = Math.max(targetUsableWidth, dataDrivenWidth);
const trackWidth = usableWidth + padding * 2;
const itemX: number[] = Array.from({ length: itemCount }, (_, i) => const itemX: number[] = Array.from({ length: itemCount }, (_, i) =>
itemCount === 1 itemCount === 1

View file

@ -10,12 +10,13 @@ describe('computeRouteLayout', () => {
it('1 item — produces a valid single-point M path on the centreline', () => { it('1 item — produces a valid single-point M path on the centreline', () => {
const out = computeRouteLayout({ itemCount: 1, viewportWidth: 1000 }); const out = computeRouteLayout({ itemCount: 1, viewportWidth: 1000 });
expect(out.itemX).toHaveLength(1); expect(out.itemX).toHaveLength(1);
expect(out.midY).toBe(290); // trackHeight 580 / 2 expect(out.midY).toBe(210); // trackHeight 420 / 2
expect(out.itemY).toEqual([out.midY]); expect(out.itemY).toEqual([out.midY]);
expect(out.cardSide).toEqual(['below']); expect(out.cardSide).toEqual(['below']);
expect(out.pathD.startsWith('M ')).toBe(true); expect(out.pathD.startsWith('M ')).toBe(true);
expect(out.pathD).not.toContain('C '); expect(out.pathD).not.toContain('C ');
expect(out.trackWidth).toBeGreaterThanOrEqual(1000); // Target usable width = 1000 * 0.8 = 800; trackWidth = 800 + 60*2 = 920
expect(out.trackWidth).toBe(920);
}); });
it('2 items — itemX strictly increasing, cardSide alternates below/above', () => { it('2 items — itemX strictly increasing, cardSide alternates below/above', () => {
@ -50,18 +51,18 @@ describe('computeRouteLayout', () => {
expect((out.pathD.match(/C /g) ?? []).length).toBe(6); expect((out.pathD.match(/C /g) ?? []).length).toBe(6);
}); });
it('20 items — track expands beyond viewport, spacing respects minSpacingX', () => { it('20 items — data-driven width wins over the 80% target', () => {
const out = computeRouteLayout({ itemCount: 20, viewportWidth: 800 }); const out = computeRouteLayout({ itemCount: 20, viewportWidth: 800 });
expect(isStrictlyIncreasing(out.itemX)).toBe(true); expect(isStrictlyIncreasing(out.itemX)).toBe(true);
expect(out.trackWidth).toBeGreaterThan(800); // (20 - 1) * 320 + 60 * 2 = 6200; clearly beats 800 * 0.8 + 120 = 760.
// With minSpacing 320 and 20 items, the route should be at least expect(out.trackWidth).toBe(6200);
// (20 - 1) * 320 + 2 * 60 = 6200px wide
expect(out.trackWidth).toBeGreaterThanOrEqual(6200);
}); });
it('trackWidth is never smaller than the viewport width', () => { it('few items on a wide viewport — track ≈ 80% of viewport + padding', () => {
const out = computeRouteLayout({ itemCount: 1, viewportWidth: 2000 }); // 3 items at viewport 1400. Data-driven = 2 * 320 + 120 = 760.
expect(out.trackWidth).toBeGreaterThanOrEqual(2000); // 80% target = 1400 * 0.8 + 120 = 1240 — should win.
const out = computeRouteLayout({ itemCount: 3, viewportWidth: 1400 });
expect(out.trackWidth).toBe(1240);
}); });
}); });