Three coupled changes that all serve the same goal — less furniture above the route, more honest information below it. Progress dots gone. At 5 pills × ~400px per pill the strip was too coarse to feel meaningful; the arrows + edge fades already communicate scroll position. .rr-progress markup, the script logic that updated the .active class, and the .rr-progress / .rr-progress-dot styles are all deleted. Legend moves from beside 'The route' in the section header to below the track, centred. Reading order is now title → walk the path → key, which is the order it makes sense in. The header collapses to just the title on the left and the two arrow buttons on the right. Path amplitude is no longer constant. computeRouteLayout multiplies the base amplitude (120) by a per-item factor that ramps 0.78 (first off-axis item) → 1.18 (last item), so closer-in items swing tighter and further-out items swing wider. The visual effect is subtle but the path now feels hand-planned instead of strictly sinusoidal. Test updated to verify the multiplier — |itemY[2] - midY| now exceeds |itemY[1] - midY| in the 3-item case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
85 lines
3.9 KiB
TypeScript
85 lines
3.9 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; amplitude multiplier ramps', () => {
|
|
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 (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', () => {
|
|
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);
|
|
});
|
|
});
|