Files

93 lines
4.7 KiB
JavaScript
Raw Permalink 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.
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;
}