From 66b460c35f90b8458ba90ff9ee9f9429faaa3e7c Mon Sep 17 00:00:00 2001 From: Jonathan Hvid Date: Tue, 12 May 2026 11:40:26 +0200 Subject: [PATCH] =?UTF-8?q?feat(lib):=20roadmap-layout=20=E2=80=94=20coord?= =?UTF-8?q?inate=20generation=20for=20the=20route=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/lib/roadmap-layout.ts | 107 +++++++++++++++++++++++++++++++++++ tests/roadmap-layout.test.ts | 80 ++++++++++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 src/lib/roadmap-layout.ts create mode 100644 tests/roadmap-layout.test.ts diff --git a/src/lib/roadmap-layout.ts b/src/lib/roadmap-layout.ts new file mode 100644 index 0000000..8fa805a --- /dev/null +++ b/src/lib/roadmap-layout.ts @@ -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); +} diff --git a/tests/roadmap-layout.test.ts b/tests/roadmap-layout.test.ts new file mode 100644 index 0000000..c0b2b02 --- /dev/null +++ b/tests/roadmap-layout.test.ts @@ -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); + }); +});