93 lines
4.7 KiB
JavaScript
93 lines
4.7 KiB
JavaScript
import { measure, at, pointSegmentDistance } from './motion.js';
|
||
|
||
const TAU = Math.PI * 2;
|
||
const mod = value => (value % TAU + TAU) % TAU;
|
||
|
||
// One continuous trip includes the merge, perimeter and garage. Booking only
|
||
// the internal road would move the same queue to the perimeter entrance.
|
||
export function journey(departure, perimeter, finishPath) {
|
||
const lane = measure(perimeter.points);
|
||
const ringLength = mod(-departure.entry) * perimeter.unit;
|
||
const entryDistance = mod(departure.entry) * perimeter.unit;
|
||
const lanePoints = lane.points.slice(0, -1).map((point, i) => ({
|
||
point, ahead: mod((lane.lengths[i] - entryDistance) / perimeter.unit) * perimeter.unit,
|
||
})).filter(p => p.ahead > 1e-6 && p.ahead < ringLength - 1e-6).sort((a, b) => a.ahead - b.ahead);
|
||
const points = [...departure.path.points, ...lanePoints.map(p => p.point), ...finishPath.points];
|
||
// Remove coincident track endpoints so every timed segment has a duration.
|
||
const path = measure(points.filter((p, i) => !i || Math.hypot(p.x - points[i - 1].x, p.y - points[i - 1].y) > 1e-7));
|
||
const boardLength = departure.boardLength;
|
||
const timeAt = d => Math.min(d, boardLength) / 240 + Math.max(0, d - boardLength) / 180;
|
||
return { path, boardLength, ringStart: departure.path.length,
|
||
finishStart: departure.path.length + ringLength,
|
||
times: path.lengths.map(timeAt), duration: timeAt(path.length) };
|
||
}
|
||
|
||
export function journeyPosition(trip, elapsed) {
|
||
const boardTime = trip.boardLength / 240;
|
||
const distance = Math.min(trip.path.length, Math.max(0, Math.min(elapsed, boardTime) * 240 + Math.max(0, elapsed - boardTime) * 180));
|
||
return { ...at(trip.path, distance), distance };
|
||
}
|
||
|
||
function cursor(actor, time) {
|
||
const elapsed = time - actor.start, times = actor.journey.times;
|
||
let lo = 0, hi = times.length;
|
||
while (lo < hi) {
|
||
const mid = (lo + hi) >>> 1;
|
||
if (times[mid] <= elapsed + 1e-9) lo = mid + 1; else hi = mid;
|
||
}
|
||
return lo;
|
||
}
|
||
function position(actor, i, time) {
|
||
const { path, times } = actor.journey;
|
||
if (!i) return path.points[0];
|
||
const a = path.points[i - 1], b = path.points[i];
|
||
const t = (time - actor.start - times[i - 1]) / (times[i] - times[i - 1]);
|
||
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
|
||
}
|
||
|
||
// Exact closest approach on each overlapping pair of linear time segments.
|
||
// This checks the swept interval, including between rendered frames at 3×.
|
||
export function tripsConflict(a, b, now, clearance, parkedClearance = clearance) {
|
||
const end = Math.min(a.start + a.journey.duration, b.start + b.journey.duration);
|
||
let time = now, i = cursor(a, time), j = cursor(b, time);
|
||
while (time < end - 1e-8) {
|
||
const nextA = a.start + a.journey.times[i], nextB = b.start + b.journey.times[j];
|
||
const next = Math.min(end, nextA, nextB);
|
||
const p = position(a, i, time), q = position(b, j, time);
|
||
const r = position(a, i, next), s = position(b, j, next);
|
||
const gap = time < a.start - 1e-8 || time < b.start - 1e-8 ? parkedClearance : clearance;
|
||
if (pointSegmentDistance({ x: 0, y: 0 }, { x: p.x - q.x, y: p.y - q.y }, { x: r.x - s.x, y: r.y - s.y }) < gap) return true;
|
||
time = next;
|
||
if (nextA <= next + 1e-8) i++;
|
||
if (nextB <= next + 1e-8) j++;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// The caller supplies the parked-car dependency order. Each trip gets the first
|
||
// free time slot; unrelated trips can share a start or overlap most of a trip.
|
||
// All times use one simulation clock, so changing playback speed preserves gaps.
|
||
export function scheduleDepartures(candidates, { now = 0, active = [], parked = [], clearance, parkedClearance = clearance }) {
|
||
const planned = [], reservations = [...active];
|
||
const released = new Set();
|
||
for (const candidate of candidates) {
|
||
released.add(candidate.car.id);
|
||
const fixed = parked.filter(p => !released.has(p.id));
|
||
const points = candidate.journey.path.points;
|
||
if (fixed.some(p => points.some((b, i) => i && pointSegmentDistance(p, points[i - 1], b) < parkedClearance))) {
|
||
throw new Error(`Departure order crosses a parked car: ${candidate.car.id}`);
|
||
}
|
||
const actor = { ...candidate, start: now };
|
||
// After the last reserved arrival, a statically clear route is always free.
|
||
const horizon = Math.max(now, ...reservations.map(r => r.start + r.journey.duration));
|
||
while (actor.start < horizon && reservations.some(other => tripsConflict(actor, other, now, clearance, parkedClearance))) {
|
||
actor.start = Math.min(horizon, actor.start + .06);
|
||
}
|
||
if (reservations.some(other => tripsConflict(actor, other, now, clearance, parkedClearance))) {
|
||
throw new Error(`Existing traffic crosses a waiting car: ${candidate.car.id}`);
|
||
}
|
||
planned.push(actor); reservations.push(actor);
|
||
}
|
||
return planned;
|
||
}
|