The route was clipping at three places: top and bottom of hovered cards (the track was only 460 tall) and at the left/right viewport edges (first card half-off-screen at scrollLeft 0, last card off the right at scrollEnd). Track height: default trackHeight in roadmap-layout 460 → 580; .rr-track inline-style and the SVG height matched. midY now 290. Path centreline stays in the visual centre and gains 60px breathing room above + 60px below — which is exactly the room a hovered card needs to expand into. Scroll-container padding: .rr-scroll gains 140px of horizontal padding plus matching scroll-padding-left/right so snap-stops land cleanly. The 140 figure is 220px card-width / 2 + 30px buffer, so the first and last cards have a full card-width of clear space inside the viewport at the scroll extremes. Layout helper test verifies midY === 290. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
81 lines
3.6 KiB
TypeScript
81 lines
3.6 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { computeRouteLayout, travelledStopFor } from '../src/lib/roadmap-layout.js';
|
|
|
|
function isStrictlyIncreasing(xs: number[]): boolean {
|
|
for (let i = 1; i < xs.length; i += 1) if (xs[i] <= xs[i - 1]) return false;
|
|
return true;
|
|
}
|
|
|
|
describe('computeRouteLayout', () => {
|
|
it('1 item — produces a valid single-point M path on the centreline', () => {
|
|
const out = computeRouteLayout({ itemCount: 1, viewportWidth: 1000 });
|
|
expect(out.itemX).toHaveLength(1);
|
|
expect(out.midY).toBe(290); // trackHeight 580 / 2
|
|
expect(out.itemY).toEqual([out.midY]);
|
|
expect(out.cardSide).toEqual(['below']);
|
|
expect(out.pathD.startsWith('M ')).toBe(true);
|
|
expect(out.pathD).not.toContain('C ');
|
|
expect(out.trackWidth).toBeGreaterThanOrEqual(1000);
|
|
});
|
|
|
|
it('2 items — itemX strictly increasing, cardSide alternates below/above', () => {
|
|
const out = computeRouteLayout({ itemCount: 2, viewportWidth: 1000 });
|
|
expect(out.itemX).toHaveLength(2);
|
|
expect(isStrictlyIncreasing(out.itemX)).toBe(true);
|
|
expect(out.cardSide).toEqual(['below', 'above']);
|
|
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', () => {
|
|
const out = computeRouteLayout({ itemCount: 3, viewportWidth: 1000 });
|
|
expect(out.cardSide).toEqual(['below', 'above', 'below']);
|
|
expect(isStrictlyIncreasing(out.itemX)).toBe(true);
|
|
// First item on centreline, second above (lower y in svg coords), third below
|
|
expect(out.itemY[0]).toBe(out.midY);
|
|
expect(out.itemY[1]).toBeLessThan(out.midY);
|
|
expect(out.itemY[2]).toBeGreaterThan(out.midY);
|
|
});
|
|
|
|
it('7 items — strictly increasing itemX + correct alternation', () => {
|
|
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']);
|
|
expect(out.pathD.startsWith('M ')).toBe(true);
|
|
expect((out.pathD.match(/C /g) ?? []).length).toBe(6);
|
|
});
|
|
|
|
it('20 items — track expands beyond viewport, spacing respects minSpacingX', () => {
|
|
const out = computeRouteLayout({ itemCount: 20, viewportWidth: 800 });
|
|
expect(isStrictlyIncreasing(out.itemX)).toBe(true);
|
|
expect(out.trackWidth).toBeGreaterThan(800);
|
|
// With minSpacing 320 and 20 items, the route should be at least
|
|
// (20 - 1) * 320 + 2 * 60 = 6200px wide
|
|
expect(out.trackWidth).toBeGreaterThanOrEqual(6200);
|
|
});
|
|
|
|
it('trackWidth is never smaller than the viewport width', () => {
|
|
const out = computeRouteLayout({ itemCount: 1, viewportWidth: 2000 });
|
|
expect(out.trackWidth).toBeGreaterThanOrEqual(2000);
|
|
});
|
|
});
|
|
|
|
describe('travelledStopFor', () => {
|
|
it('returns 0 when no items have shipped', () => {
|
|
expect(travelledStopFor(['exploring', 'considering'])).toBe(0);
|
|
expect(travelledStopFor([])).toBe(0);
|
|
});
|
|
|
|
it('returns (lastShippingIndex + 0.5) / itemCount', () => {
|
|
// [shipping, shipping, in_beta, exploring] → lastShipping = 1 → (1.5)/4 = 0.375
|
|
expect(travelledStopFor(['shipping', 'shipping', 'in_beta', 'exploring'])).toBeCloseTo(0.375, 5);
|
|
});
|
|
|
|
it('clamps to 0.98 when every item has shipped', () => {
|
|
expect(travelledStopFor(['shipping', 'shipping', 'shipping'])).toBeCloseTo(0.833, 2);
|
|
// even with 100 items all shipping, clamps to 0.98
|
|
const allShipping = Array(100).fill('shipping') as ('shipping')[];
|
|
expect(travelledStopFor(allShipping)).toBeLessThanOrEqual(0.98);
|
|
});
|
|
});
|