feat(lib): roadmap-layout — coordinate generation for the route component
Pure math, no DOM. computeRouteLayout(opts) takes itemCount + viewport width and returns trackWidth, pathD, itemX, itemY, cardSide, midY: - itemX evenly distributes items between padding and viewport-padding, expanding the canvas beyond the viewport when itemCount * minSpacingX exceeds the available width. Single-item case centres the dot. - itemY puts the first item on the centreline; subsequent items alternate +amplitude / -amplitude so the path snakes gently up and down. The route reads as a river rather than a saw-tooth because the cubic-bezier control points use the segment midpoint x — that holds the tangent flat at each milestone. - cardSide alternates 'below' / 'above' starting from 'below' on item 0. Cards hang from their dot via a thin vertical connector in the consuming component. Also adds travelledStopFor(statuses) — the stop position on the path stroke gradient where 'travelled' fades into 'ahead'. Clamps to 0.98 even when every item is shipping so the fade is always visible. 9 unit tests cover itemCount 1/2/3/7/20 plus the travelledStop edge cases (no shipping → 0; all shipping → ≤ 0.98; mixed → exact midpoint). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d17d9b93a7
commit
66b460c35f
2 changed files with 187 additions and 0 deletions
107
src/lib/roadmap-layout.ts
Normal file
107
src/lib/roadmap-layout.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
/**
|
||||||
|
* Coordinate-generation for the /roadmap horizontal route component.
|
||||||
|
*
|
||||||
|
* Given an itemCount + viewport width, produces:
|
||||||
|
* - itemX / itemY: position of each milestone dot on the SVG canvas
|
||||||
|
* - cardSide: which side ('below' or 'above') the milestone's card hangs on
|
||||||
|
* - pathD: a smooth cubic-bezier SVG path string snaking through all dots
|
||||||
|
* - trackWidth: total scroll width of the track (≥ viewportWidth so the
|
||||||
|
* page always offers visible content + scroll affordance)
|
||||||
|
* - midY: vertical centreline of the track, returned for callers that want
|
||||||
|
* to place additional decoration relative to the centre
|
||||||
|
*
|
||||||
|
* No DOM access here — pure math. Tested directly in roadmap-layout.test.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LayoutOpts {
|
||||||
|
itemCount: number;
|
||||||
|
viewportWidth: number;
|
||||||
|
minSpacingX?: number; // default 320
|
||||||
|
trackHeight?: number; // default 460
|
||||||
|
amplitude?: number; // default 120
|
||||||
|
paddingX?: number; // default 60
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayoutResult {
|
||||||
|
trackWidth: number;
|
||||||
|
pathD: string;
|
||||||
|
itemX: number[];
|
||||||
|
itemY: number[];
|
||||||
|
cardSide: ('above' | 'below')[];
|
||||||
|
midY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeRouteLayout(opts: LayoutOpts): LayoutResult {
|
||||||
|
const minSpacing = opts.minSpacingX ?? 320;
|
||||||
|
const trackHeight = opts.trackHeight ?? 460;
|
||||||
|
const amplitude = opts.amplitude ?? 120;
|
||||||
|
const padding = opts.paddingX ?? 60;
|
||||||
|
const midY = trackHeight / 2;
|
||||||
|
|
||||||
|
const itemCount = Math.max(0, opts.itemCount);
|
||||||
|
|
||||||
|
if (itemCount === 0) {
|
||||||
|
return {
|
||||||
|
trackWidth: opts.viewportWidth,
|
||||||
|
pathD: '',
|
||||||
|
itemX: [],
|
||||||
|
itemY: [],
|
||||||
|
cardSide: [],
|
||||||
|
midY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const usableWidth = Math.max(
|
||||||
|
opts.viewportWidth - padding * 2,
|
||||||
|
(itemCount - 1) * minSpacing,
|
||||||
|
);
|
||||||
|
const trackWidth = usableWidth + padding * 2;
|
||||||
|
|
||||||
|
const itemX: number[] = Array.from({ length: itemCount }, (_, i) =>
|
||||||
|
itemCount === 1
|
||||||
|
? padding + usableWidth / 2
|
||||||
|
: padding + (i / (itemCount - 1)) * usableWidth,
|
||||||
|
);
|
||||||
|
|
||||||
|
// First item on the centreline; subsequent items alternate up/down.
|
||||||
|
// Odd indices rise above; even indices (>0) drop below.
|
||||||
|
const itemY: number[] = itemX.map((_, i) =>
|
||||||
|
i === 0
|
||||||
|
? midY
|
||||||
|
: (i % 2 === 1 ? midY - amplitude : midY + amplitude),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Smooth cubic-bezier path. Control points use the midpoint x of each
|
||||||
|
// segment, with control-y values matching the prior and next item
|
||||||
|
// respectively — this keeps the curve tangent flat at each milestone
|
||||||
|
// (the "river" feel rather than the "zigzag" feel).
|
||||||
|
let d = `M ${itemX[0]} ${itemY[0]}`;
|
||||||
|
for (let i = 1; i < itemCount; i += 1) {
|
||||||
|
const cx = (itemX[i - 1] + itemX[i]) / 2;
|
||||||
|
d += ` C ${cx} ${itemY[i - 1]}, ${cx} ${itemY[i]}, ${itemX[i]} ${itemY[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { trackWidth, pathD: d, itemX, itemY, cardSide, midY };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The travelled-portion stop position on the path stroke gradient.
|
||||||
|
* - No shipping items: 0 (path is entirely "ahead" tone)
|
||||||
|
* - Some shipping items: (lastShippingIndex + 0.5) / itemCount
|
||||||
|
* - Clamped to [0, 0.98] so the fade-to-ahead is always visible
|
||||||
|
*/
|
||||||
|
export function travelledStopFor(
|
||||||
|
statuses: ReadonlyArray<'shipping' | 'in_beta' | 'exploring' | 'considering'>,
|
||||||
|
): number {
|
||||||
|
if (statuses.length === 0) return 0;
|
||||||
|
let last = -1;
|
||||||
|
statuses.forEach((s, i) => { if (s === 'shipping') last = i; });
|
||||||
|
if (last < 0) return 0;
|
||||||
|
return Math.min(0.98, (last + 0.5) / statuses.length);
|
||||||
|
}
|
||||||
80
tests/roadmap-layout.test.ts
Normal file
80
tests/roadmap-layout.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
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.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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue