import axios from "axios"; import turfAlong from "@turf/along"; import * as turfProjection from "@turf/projection"; import * as routing from "./routing.js"; import network from "./network.json"; const server = "http://localhost:4321"; // Time to stay at each stop (milliseconds) const stopTime = 10000; // Step used to compute the vehicle bearing (meters) const angleStep = 10; // Maximum speed of a vehicle (meters per millisecond) const maxSpeed = 60 / 3600; // Minimum speed of a vehicle (meters per millisecond) const minSpeed = 10 / 3600; // Normal speed of a vehicle const normSpeed = (2 * maxSpeed + minSpeed) / 3; /** Simulate the evolution of a vehicle course in the network. */ class Course { constructor(id) { // Unique identifier of this course this.id = id; // Line on which this vehicle operates this.line = null; // Line direction of this course this.direction = null; // Stop to which this course is headed this.finalStop = null; // Previous stops that this course left (stop id/timestamp pairs) this.prevPassings = []; // Next stops that this course will leave (stop id/timestamp pairs) this.nextPassings = []; // Stop that this course just left or will leave this.departureStop = null; // Time at which the last stop was left or will be left (timestamp) this.departureTime = 0; // Next stop that this course will reach this.arrivalStop = null; // Time at which the next stop will be left (timestamp) this.arrivalTime = 0; // Route between the current departure and arrival stops this.segment = null; // Distance already travelled between the two stops (meters) this.traveledDistance = 0; // Current vehicle speed (meters per millisecond) this.speed = 0; // Current vehicle latitude and longitude this.position = [0, 0]; // Current vehicle bearing (clockwise degrees from north) this.angle = 0; } /** Find a route between the current departure and arrival stops. */ updateSegment() { if (this.departureStop === null || this.arrivalStop === null) { this.segment = null; return; } const name = `${this.departureStop}-${this.arrivalStop}`; // Use predefined segment if it exists if (name in network.segments) { this.segment = network.segments[name]; return; } if (!(this.departureStop in network.stops)) { console.warn(`Unknown stop: ${this.departureStop}`); this.segment = null; return; } if (!(this.arrivalStop in network.stops)) { console.warn(`Unknown stop: ${this.arrivalStop}`); this.segment = null; return; } // Compute a custom route between two stops this.segment = routing.findSegment(this.departureStop, this.arrivalStop); if (this.segment === null) { console.warn(`No route from ${this.departureStop} \ to ${this.arrivalStop}`); } } /** Merge passings data received from the server. */ receiveData(data) { this.line = data.line; this.direction = data.direction; this.finalStop = data.finalStopId; const passings = Object.assign( Object.fromEntries(this.nextPassings), Object.fromEntries(data.passings), ); // Remove older passings from next passings for (let [stop, _] of this.prevPassings) { delete passings[stop]; } // Update departure time if still announced if (this.departureStop !== null) { if (this.departureStop in passings) { this.departureTime = passings[this.departureStop]; delete passings[this.departureStop]; } } // Update arrival time if (this.arrivalStop !== null) { if (this.arrivalStop in passings) { // Use announced time if available this.arrivalTime = passings[this.arrivalStop]; delete passings[this.arrivalStop]; } else { // Otherwise, arrive using a normal speed from current position const segment = this.segment; const distance = segment.properties.length - this.traveledDistance; const time = Math.floor(distance / normSpeed); this.arrivalTime = Date.now() + time; } } this.nextPassings = Object.entries(passings).sort( ([, time1], [, time2]) => time1 - time2 ); } /** Update the vehicle state. */ update() { const now = Date.now(); // When initializing, use the first available passing as start if (this.departureStop === null) { if (this.nextPassings.length > 0) { const [stopId, time] = this.nextPassings.shift(); this.departureStop = stopId; this.departureTime = time; this.updateSegment(); } } // …and the second one as the arrival if (this.arrivalStop === null) { if (this.nextPassings.length > 0) { const [stopId, time] = this.nextPassings.shift(); this.arrivalStop = stopId; this.arrivalTime = time; this.updateSegment(); } } if (this.segment !== null) { const segment = this.segment; const distance = segment.properties.length - this.traveledDistance; const duration = this.arrivalTime - stopTime - now; // Arrive to the next stop if (distance === 0) { this.prevPassings.push([this.departureStop, this.departureTime]); this.departureStop = this.arrivalStop; this.departureTime = this.arrivalTime; if (this.nextPassings.length > 0) { const [stopId, time] = this.nextPassings.shift(); this.arrivalStop = stopId; this.arrivalTime = time; } else { this.arrivalStop = null; this.arrivalTime = 0; } this.traveledDistance = 0; this.updateSegment(); } if (this.departureTime > now) { // Wait for departure this.speed = 0; } else { if (this.traveledDistance === 0 && this.speed === 0) { // We’re late, record the actual departure time this.departureTime = now; } // Update current speed to arrive on time if possible this.speed = Course.computeSpeed(distance, duration); } } return true; } /** Integrate the current vehicle speed and update distance. */ move(time) { if (this.segment === null) { return; } if (this.speed > 0) { this.traveledDistance = Math.min( this.traveledDistance + this.speed * time, this.segment.properties.length, ); } // Compute updated position and angle based on a small step let positionBehind; let positionInFront; if (this.traveledDistance < angleStep / 2) { positionBehind = this.traveledDistance; positionInFront = angleStep; } else { positionBehind = this.traveledDistance - angleStep / 2; positionInFront = this.traveledDistance + angleStep / 2; } const positions = [ positionBehind, this.traveledDistance, positionInFront, ].map(distance => turfProjection.toMercator(turfAlong( this.segment, distance / 1000 )).geometry.coordinates); this.angle = Math.atan2( positions[0][1] - positions[2][1], positions[2][0] - positions[0][0] ); this.position = positions[1]; } /** * Compute the optimal speed to arrive on time. * @param {number} distance Distance to cover (meters) * @param {number} duration Remaining time (seconds) * @return {number} Optimal speed (meters per second) */ static computeSpeed(distance, duration) { if (duration <= 0) { // Late: go to maximum speed return maxSpeed; } const speed = distance / duration; if (speed < minSpeed) { // Too slow: pause until speed is sufficient return 0; } return Math.min(maxSpeed, speed); } } /** Fetch passing data from the server and update simulation. */ const updateData = async courses => { const dataset = (await axios.get(`${server}/courses`)).data; // Update or create new courses for (const [id, data] of Object.entries(dataset)) { if (id in courses) { courses[id].receiveData(data); } else { const newCourse = new Course(data.id); newCourse.receiveData(data); courses[id] = newCourse; } } // Remove stale courses for (const id of Object.keys(courses)) { if (courses[id].departureStop === courses[id].finalStop) { delete courses[id]; } } }; export const start = () => { const courses = {}; let lastFrame = null; let lastUpdate = null; const update = () => { const now = Date.now(); if (lastUpdate === null || lastUpdate + 5000 <= now) { lastUpdate = now; updateData(courses); } const time = lastFrame === null ? 0 : now - lastFrame; lastFrame = now; for (const course of Object.values(courses)) { course.update(); course.move(time); } }; return { courses, update }; };