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) <noreply@anthropic.com>