diff --git a/src/lib/roadmap-layout.ts b/src/lib/roadmap-layout.ts index 0c7bf15..3d98469 100644 --- a/src/lib/roadmap-layout.ts +++ b/src/lib/roadmap-layout.ts @@ -77,11 +77,17 @@ export function computeRouteLayout(opts: LayoutOpts): LayoutResult { return midY + direction * amplitude * multiplier; }); - // Cards alternate: even index → below, odd index → above. First card sits - // below the dot at the centreline so the river starts visibly underlined. - const cardSide: ('above' | 'below')[] = itemX.map((_, i) => - i % 2 === 0 ? 'below' : 'above', - ); + // Cards hang TOWARD the centreline rather than away from it. A dot above + // centre (odd index) gets a card below; a dot below centre (even index >0) + // gets a card above. This keeps every card growing into the track height + // rather than out the top or bottom of the scroll container — which the + // CSS spec clips regardless of overflow-y: visible (browser computes + // overflow-y to auto whenever overflow-x is auto). i=0 sits on the + // centreline, no clipping risk either way; defaulting to below. + const cardSide: ('above' | 'below')[] = itemX.map((_, i) => { + if (i === 0) return 'below'; + return i % 2 === 1 ? 'below' : 'above'; + }); // Smooth cubic-bezier path. Control points use the midpoint x of each // segment, with control-y values matching the prior and next item diff --git a/tests/roadmap-layout.test.ts b/tests/roadmap-layout.test.ts index a4b7d5d..7f60fa6 100644 --- a/tests/roadmap-layout.test.ts +++ b/tests/roadmap-layout.test.ts @@ -19,34 +19,42 @@ describe('computeRouteLayout', () => { expect(out.trackWidth).toBe(920); }); - it('2 items — itemX strictly increasing, cardSide alternates below/above', () => { + it('2 items — cards hang toward centreline (both below: i=0 centre, i=1 above-centre)', () => { const out = computeRouteLayout({ itemCount: 2, viewportWidth: 1000 }); expect(out.itemX).toHaveLength(2); expect(isStrictlyIncreasing(out.itemX)).toBe(true); - expect(out.cardSide).toEqual(['below', 'above']); + // i=0: centreline (below by convention). i=1: dot above centre → card below. + expect(out.cardSide).toEqual(['below', 'below']); expect(out.pathD.startsWith('M ')).toBe(true); expect((out.pathD.match(/C /g) ?? []).length).toBe(1); }); - it('3 items — first card below, then above, then below; amplitude multiplier ramps', () => { + it('3 items — cards toward centreline; amplitude multiplier ramps', () => { const out = computeRouteLayout({ itemCount: 3, viewportWidth: 1000 }); - expect(out.cardSide).toEqual(['below', 'above', 'below']); + // i=0 centre (below), i=1 above-centre (card below), i=2 below-centre (card above). + expect(out.cardSide).toEqual(['below', 'below', 'above']); expect(isStrictlyIncreasing(out.itemX)).toBe(true); // First item on centreline, second above (smaller y), third below. expect(out.itemY[0]).toBe(out.midY); expect(out.itemY[1]).toBeLessThan(out.midY); expect(out.itemY[2]).toBeGreaterThan(out.midY); - // Amplitude varies: multiplier at index 1 is 0.78 + (1/2)*0.4 = 0.98; - // at index 2 it's 0.78 + (2/2)*0.4 = 1.18. So |itemY[2] - midY| should - // exceed |itemY[1] - midY| (further items swing wider). expect(Math.abs(out.itemY[2] - out.midY)).toBeGreaterThan(Math.abs(out.itemY[1] - out.midY)); }); - it('7 items — strictly increasing itemX + correct alternation', () => { + it('7 items — every card grows toward the centreline, never away from it', () => { const out = computeRouteLayout({ itemCount: 7, viewportWidth: 1200 }); expect(out.itemX).toHaveLength(7); expect(isStrictlyIncreasing(out.itemX)).toBe(true); - expect(out.cardSide).toEqual(['below', 'above', 'below', 'above', 'below', 'above', 'below']); + // i: 0 1 2 3 4 5 6 — odd indices dot-above-centre (card below), even + // indices >0 dot-below-centre (card above). i=0 default below. + expect(out.cardSide).toEqual(['below', 'below', 'above', 'below', 'above', 'below', 'above']); + // Spot-check: every non-i=0 card's side is opposite to its dot's + // offset from centre — i.e. cards always shrink toward midY. + for (let i = 1; i < out.itemX.length; i += 1) { + const dotAbove = out.itemY[i] < out.midY; + if (dotAbove) expect(out.cardSide[i]).toBe('below'); + else expect(out.cardSide[i]).toBe('above'); + } expect(out.pathD.startsWith('M ')).toBe(true); expect((out.pathD.match(/C /g) ?? []).length).toBe(6); });