/** * 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 — symmetric leading + trailing padding paddingLeft?: number; // overrides paddingX on the leading edge only paddingRight?: number; // overrides paddingX on the trailing edge only tailLength?: number; // px to extend the drawn path past the final // milestone, easing back to the centreline — lets // the line keep going as the last item scrolls in } 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 ?? 420; const amplitude = opts.amplitude ?? 120; const paddingDef = opts.paddingX ?? 60; const paddingL = opts.paddingLeft ?? paddingDef; const paddingR = opts.paddingRight ?? paddingDef; const midY = trackHeight / 2; const itemCount = Math.max(0, opts.itemCount); if (itemCount === 0) { return { trackWidth: opts.viewportWidth, pathD: '', itemX: [], itemY: [], cardSide: [], midY, }; } // Aim for ~80% of viewport for low item counts; data-driven minimum // takes over once items × minSpacing exceeds that target (the carousel // case — track extends past viewport). const targetUsableWidth = opts.viewportWidth * 0.80; const dataDrivenWidth = (itemCount - 1) * minSpacing; const usableWidth = Math.max(targetUsableWidth, dataDrivenWidth); const trackWidth = paddingL + usableWidth + paddingR; const itemX: number[] = Array.from({ length: itemCount }, (_, i) => itemCount === 1 ? paddingL + usableWidth / 2 : paddingL + (i / (itemCount - 1)) * usableWidth, ); // First item on the centreline; subsequent items alternate up/down with // a varying amplitude so the path feels hand-planned rather than purely // sinusoidal. Multiplier ramps 0.78 (first off-axis) → ~1.18 (last item) // — closer items swing less, further items swing more. const denom = Math.max(1, itemCount - 1); const itemY: number[] = itemX.map((_, i) => { if (i === 0) return midY; const direction = i % 2 === 1 ? -1 : 1; const multiplier = 0.78 + (i / denom) * 0.4; return midY + direction * amplitude * multiplier; }); // 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 // 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]}`; } // Trailing tail: continue the path past the last milestone, easing it back // to the centreline so the line keeps going while the final item scrolls // toward the middle. Tangent stays flat at the last dot (control y = lastY). if (opts.tailLength && opts.tailLength > 0) { const lastX = itemX[itemCount - 1]; const lastY = itemY[itemCount - 1]; const tailEndX = lastX + opts.tailLength; const cx = (lastX + tailEndX) / 2; d += ` C ${cx} ${lastY}, ${cx} ${midY}, ${tailEndX} ${midY}`; } 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' | 'planned' | '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); }