import axios from "axios"; import turfAlong from "@turf/along"; import turfBearing from "@turf/bearing"; 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 = 15000; // 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; /** * GeoJSON feature representing a vehicle course with simulation of the * vehicle’s movement along the course. */ class Course { constructor(id) { this.type = "Feature"; // Current vehicle position (latitude and longitude) this.geometry = {}; this.geometry.type = "Point"; this.geometry.coordinates = [0, 0]; this.properties = {}; // Unique identifier of this course this.properties.id = id; // Line on which this vehicle operates this.properties.line = null; // Line direction of this course this.properties.direction = null; // Stop to which this course is headed this.properties.finalStop = null; // Previous stops that this course left (stop id/timestamp pairs) this.properties.prevPassings = []; // Next stops that this course will leave (stop id/timestamp pairs) this.properties.nextPassings = []; // Stop that this course just left or will leave this.properties.departureStop = null; // Time at which the last stop was left or will be left (timestamp) this.properties.departureTime = 0; // Next stop that this course will reach this.properties.arrivalStop = null; // Time at which the next stop will be left (timestamp) this.properties.arrivalTime = 0; // Route between the current departure and arrival stops this.properties.segment = null; // Distance already travelled between the two stops (meters) this.properties.traveledDistance = 0; // Current vehicle speed (meters per millisecond) this.properties.speed = 0; // Current vehicle bearing (clockwise degrees from north) this.properties.bearing = 0; } /** Find a route between the current departure and arrival stops. */ updateSegment() { const props = this.properties; if (props.departureStop === null || props.arrivalStop === null) { props.segment = null; return; } const name = `${props.departureStop}-${props.arrivalStop}`; if (!(props.departureStop in network.stops)) { console.warn(`Unknown stop: ${props.departureStop}`); props.segment = null; return; } if (!(props.arrivalStop in network.stops)) { console.warn(`Unknown stop: ${props.arrivalStop}`); props.segment = null; return; } // Compute a custom route between two stops props.segment = routing.findSegment( props.departureStop, props.arrivalStop, ); if (props.segment === null) { console.warn(`No route from ${props.departureStop} \ to ${props.arrivalStop}`); } } /** Merge passings data received from the server. */ receiveData(data) { const props = this.properties; props.line = data.line; props.direction = data.direction; props.finalStop = data.finalStopId; const passings = Object.assign( Object.fromEntries(props.nextPassings), Object.fromEntries(data.passings), ); // Remove older passings from next passings for (let [stop, _] of props.prevPassings) { delete passings[stop]; } // Update departure time if still announced if (props.departureStop !== null) { if (props.departureStop in passings) { props.departureTime = passings[props.departureStop]; delete passings[props.departureStop]; } } // Update arrival time if (props.arrivalStop !== null) { if (props.arrivalStop in passings) { // Use announced time if available props.arrivalTime = passings[props.arrivalStop]; delete passings[props.arrivalStop]; } else { // Otherwise, arrive using a normal speed from current position const segment = props.segment; const distance = segment.properties.length - props.traveledDistance; const time = Math.floor(distance / normSpeed); props.arrivalTime = Date.now() + time; } } props.nextPassings = Object.entries(passings).sort( ([, time1], [, time2]) => time1 - time2 ); } /** Update the vehicle state. */ update() { const props = this.properties; const now = Date.now(); // When initializing, use the first available passing as start if (props.departureStop === null) { if (props.nextPassings.length > 0) { const [stopId, time] = props.nextPassings.shift(); props.departureStop = stopId; props.departureTime = time; this.updateSegment(); } } // …and the second one as the arrival if (props.arrivalStop === null) { if (props.nextPassings.length > 0) { const [stopId, time] = props.nextPassings.shift(); props.arrivalStop = stopId; props.arrivalTime = time; this.updateSegment(); } } if (props.segment !== null) { const segment = props.segment; const distance = segment.properties.length - props.traveledDistance; const duration = props.arrivalTime - stopTime - now; // Arrive to the next stop if (distance === 0) { props.prevPassings.push([ props.departureStop, props.departureTime ]); props.departureStop = props.arrivalStop; props.departureTime = props.arrivalTime; if (props.nextPassings.length > 0) { const [stopId, time] = props.nextPassings.shift(); props.arrivalStop = stopId; props.arrivalTime = time; } else { props.arrivalStop = null; props.arrivalTime = 0; } props.traveledDistance = 0; this.updateSegment(); } if (props.departureTime > now) { // Wait for departure props.speed = 0; } else { if (props.traveledDistance === 0 && props.speed === 0) { // We’re late, record the actual departure time props.departureTime = now; } // Update current speed to arrive on time if possible props.speed = Course.computeSpeed(distance, duration); } } return true; } /** Integrate the current vehicle speed and update distance. */ move(time) { const props = this.properties; const segment = props.segment; if (props.segment === null) { return; } if (props.speed > 0) { props.traveledDistance = Math.min( props.traveledDistance + props.speed * time, segment.properties.length, ); } // Compute angle based on a small step along the segment let positionBehind; let positionAhead; if (props.traveledDistance < angleStep / 2) { positionBehind = props.traveledDistance; positionAhead = angleStep; } else { positionBehind = props.traveledDistance - angleStep / 2; positionAhead = props.traveledDistance + angleStep / 2; } const positions = [ positionBehind, props.traveledDistance, positionAhead, ].map(distance => turfAlong( props.segment, distance / 1000 )); this.geometry.coordinates = positions[1].geometry.coordinates; props.bearing = turfBearing(positions[0], positions[2]); } /** Check if a course is finished. */ isFinished() { const props = this.properties; return props.departureStop === props.finalStop; } /** * 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].isFinished()) { 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 }; };