project-bifrost-platform/src/lib/roadmap-layout.ts
Jonathan Hvid 220f8e0290 style(roadmap): align first milestone with content column left edge
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>
2026-05-12 17:06:30 +02:00

123 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);
}