Adds three things to the /roadmap horizontal route: - a scroll-proximity focus effect (centre milestone scales/brightens, edges recede) so the track feels alive as you move it; - eased, accumulating wheel/trackpad scrolling instead of instant jumps; - a trailing tail past the last milestone (opt-in tailLength) so the line keeps going and the final item can scroll toward the centre. Also enlarges the milestone card text for readability. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
137 lines
5.6 KiB
TypeScript
137 lines
5.6 KiB
TypeScript
/**
|
||
* 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);
|
||
}
|