The first route item now starts where the dispatch banner's left edge sits (the page's content-max column), instead of 60px from the viewport edge. Looks intentional now — the route and the dispatch banner share a vertical anchor. - computeRouteLayout now accepts optional paddingLeft / paddingRight that override the symmetric paddingX. Existing call sites and tests are unchanged. - RoadmapRoute SSR + client recompute set paddingLeft = max(60, (vw - 1152) / 2), so on viewports ≤ 1152px nothing moves (degrades gracefully) and on wider screens the start migrates inward. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
123 lines
4.8 KiB
TypeScript
123 lines
4.8 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
|
||
}
|
||
|
||
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]}`;
|
||
}
|
||
|
||
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);
|
||
}
|