feat(ticket-to-ride): Support curved routes with quadratic bezier curves

Introduce `ROUTE_CURVES` configuration and helper functions (`bezierPolyline`,
`polylineLength`, `pointAtDist`) to sample and measure polylines. Update
`routeSegments` and `routeMidpoint` to distribute cars and calculate midpoints
along curved paths instead of straight lines, improving board visual fidelity
and route alignment.
This commit is contained in:
Brian Fertig 2026-05-30 20:49:20 -06:00
parent b6d85e30be
commit a932cc5797
1 changed files with 86 additions and 24 deletions

View File

@ -180,18 +180,44 @@ const ROUTE_DEFS = [
[25, 35, 6, 'red'], // New Orleans Miami
];
// Optional curve overrides for routes that should arc rather than run straight.
// Key is 'minCityId-maxCityId'. dir is a cardinal screen direction; pct is the
// control-point displacement as a fraction of the straight-line length.
const ROUTE_CURVES = {
'4-14': { dir: 'down', pct: 0.35 }, // Los Angeles El Paso
'5-6': { dir: 'up', pct: 0.25 }, // Calgary Winnipeg
'3-4': { dir: 'left', pct: 0.20 }, // San Francisco Los Angeles
'2-3': { dir: 'left', pct: 0.20 }, // Portland San Francisco
'11-13': { dir: 'left', pct: 0.20 }, // Phoenix Denver
'9-10': { dir: 'right', pct: 0.20 }, // Salt Lake City Las Vegas
'13-17': { dir: 'down', pct: 0.25 }, // Denver Oklahoma City
'13-16': { dir: 'down', pct: 0.10 }, // Denver Kansas City
'13-15': { dir: 'up', pct: 0.10 }, // Denver Omaha
'21-31': { dir: 'up', pct: 0.10 }, // Chicago Pittsburgh
'25-35': { dir: 'up', pct: 0.35 }, // New Orleans Miami
'26-35': { dir: 'right', pct: 0.20 }, // Atlanta Miami
'14-19': { dir: 'down', pct: 0.25 }, // El Paso Houston
'12-17': { dir: 'down', pct: 0.20 }, // Santa Fe Oklahoma City
'1-5': { dir: 'down', pct: 0.30 }, // Seattle Calgary
'23-28': { dir: 'up', pct: 0.25 }, // Sault Ste Marie Montreal
'27-28': { dir: 'up', pct: 0.20 }, // Toronto Montreal
'28-30': { dir: 'left', pct: 0.25 }, // Montreal New York
};
// Expand ROUTE_DEFS into the flat ROUTES array, generating ids, double-route
// grouping, and parallelSide so the two strips of a double render side-by-side.
export const ROUTES = (() => {
const out = [];
for (const [a, b, length, colour] of ROUTE_DEFS) {
const curveKey = `${Math.min(a, b)}-${Math.max(a, b)}`;
const curve = ROUTE_CURVES[curveKey] ?? null;
if (Array.isArray(colour)) {
const group = `${a}-${b}`;
colour.forEach((c, i) => {
out.push({ id: out.length, a, b, length, color: c, doubleGroup: group, parallelSide: i });
out.push({ id: out.length, a, b, length, color: c, doubleGroup: group, parallelSide: i, curve });
});
} else {
out.push({ id: out.length, a, b, length, color: colour, doubleGroup: null, parallelSide: 0 });
out.push({ id: out.length, a, b, length, color: colour, doubleGroup: null, parallelSide: 0, curve });
}
}
return out;
@ -254,30 +280,66 @@ const CITY_MARGIN = 30; // keep car slots clear of the city dots
const CAR_GAP = 6; // pixel gap between adjacent cars
const CAR_WIDTH = 16; // perpendicular thickness of a car
const DOUBLE_OFFSET = 12; // perpendicular shift for each strip of a double route
const CURVE_SAMPLES = 10; // total bezier sample points (including endpoints)
// Returns one slot per train-length: { cx, cy, angle, w, h } rotated to the A→B
// line. parallelSide shifts the whole strip perpendicular so double routes sit
// side-by-side.
// Samples a quadratic bezier through a displaced midpoint control, returning
// CURVE_SAMPLES {x,y} points that form a smooth polyline.
function bezierPolyline(A, B, curve) {
const shift = curve.pct * Math.hypot(B.x - A.x, B.y - A.y);
const C = {
x: (A.x + B.x) / 2 + (curve.dir === 'right' ? shift : curve.dir === 'left' ? -shift : 0),
y: (A.y + B.y) / 2 + (curve.dir === 'down' ? shift : curve.dir === 'up' ? -shift : 0),
};
const pts = [];
for (let i = 0; i < CURVE_SAMPLES; i++) {
const t = i / (CURVE_SAMPLES - 1);
const mt = 1 - t;
pts.push({ x: mt*mt*A.x + 2*mt*t*C.x + t*t*B.x, y: mt*mt*A.y + 2*mt*t*C.y + t*t*B.y });
}
return pts;
}
function polylineLength(pts) {
let len = 0;
for (let i = 1; i < pts.length; i++) len += Math.hypot(pts[i].x - pts[i-1].x, pts[i].y - pts[i-1].y);
return len;
}
// Returns {x, y, angle} at distance d along pts, clamped to the final segment.
function pointAtDist(pts, d) {
let acc = 0;
for (let i = 1; i < pts.length; i++) {
const segLen = Math.hypot(pts[i].x - pts[i-1].x, pts[i].y - pts[i-1].y);
if (acc + segLen >= d || i === pts.length - 1) {
const t = segLen > 0 ? Math.min((d - acc) / segLen, 1) : 0;
return {
x: pts[i-1].x + t * (pts[i].x - pts[i-1].x),
y: pts[i-1].y + t * (pts[i].y - pts[i-1].y),
angle: Math.atan2(pts[i].y - pts[i-1].y, pts[i].x - pts[i-1].x),
};
}
acc += segLen;
}
}
// Returns one slot per train-length: { cx, cy, angle, w, h }.
// Curved routes distribute cars along a bezier polyline; each car is still a
// straight rectangle aligned to its local polyline segment.
export function routeSegments(route) {
const A = CITIES[route.a];
const B = CITIES[route.b];
const dx = B.x - A.x;
const dy = B.y - A.y;
const len = Math.hypot(dx, dy) || 1;
const ux = dx / len, uy = dy / len; // unit vector along the line
const px = -uy, py = ux; // unit perpendicular
const off = route.doubleGroup ? (route.parallelSide === 0 ? -DOUBLE_OFFSET : DOUBLE_OFFSET) : 0;
const span = len - CITY_MARGIN * 2;
const pts = route.curve ? bezierPolyline(A, B, route.curve) : [A, B];
const totalLen = polylineLength(pts);
const n = route.length;
const carLen = (span - CAR_GAP * (n - 1)) / n;
const angle = Math.atan2(dy, dx);
const carLen = (totalLen - CITY_MARGIN * 2 - CAR_GAP * (n - 1)) / n;
const segs = [];
for (let i = 0; i < n; i++) {
const t = CITY_MARGIN + carLen / 2 + i * (carLen + CAR_GAP);
const pos = pointAtDist(pts, CITY_MARGIN + carLen / 2 + i * (carLen + CAR_GAP));
segs.push({
cx: A.x + ux * t + px * off,
cy: A.y + uy * t + py * off,
angle,
cx: pos.x - Math.sin(pos.angle) * off,
cy: pos.y + Math.cos(pos.angle) * off,
angle: pos.angle,
w: carLen,
h: CAR_WIDTH,
});
@ -289,15 +351,15 @@ export function routeSegments(route) {
export function routeMidpoint(route) {
const A = CITIES[route.a];
const B = CITIES[route.b];
const dx = B.x - A.x, dy = B.y - A.y;
const len = Math.hypot(dx, dy) || 1;
const px = -dy / len, py = dx / len;
const off = route.doubleGroup ? (route.parallelSide === 0 ? -DOUBLE_OFFSET : DOUBLE_OFFSET) : 0;
const pts = route.curve ? bezierPolyline(A, B, route.curve) : [A, B];
const totalLen = polylineLength(pts);
const pos = pointAtDist(pts, totalLen / 2);
return {
x: (A.x + B.x) / 2 + px * off,
y: (A.y + B.y) / 2 + py * off,
angle: Math.atan2(dy, dx),
length: len - CITY_MARGIN * 2,
x: pos.x - Math.sin(pos.angle) * off,
y: pos.y + Math.cos(pos.angle) * off,
angle: pos.angle,
length: totalLen - CITY_MARGIN * 2,
width: CAR_WIDTH + 8,
};
}