const axios = require("axios"); const turfAlong = require("@turf/along").default; const turfProjection = require("@turf/projection"); const network = require("./network.json"); const server = "http://localhost:4321"; const findRoute = (from, to) => { const queue = [[from, []]]; while (queue.length) { const [head, path] = queue.shift(); for (const successor of network.stops[head].properties.successors) { if (successor === to) { return path.concat([head, successor]); } if (!path.includes(successor)) { queue.push([successor, path.concat([head])]); } } } return null; }; class Course { constructor(data) { this.id = data.id; this.prevPassings = []; this.nextPassings = []; this.state = null; // Attributes for the `stopped` state this.currentStop = null; this.departureTime = 0; // Attributes for the `moving` state this.departureStop = null; this.arrivalStop = null; this.arrivalTime = 0; this.traveledDistance = 0; this.speed = 0; this.position = [0, 0]; this.angle = 0; this.history = []; } get currentSegment() { if (this.state !== "moving") { return null; } return network.segments[`${this.departureStop}-${this.arrivalStop}`]; } updateData(data) { this.line = data.line; this.finalStop = data.finalStop; this.nextPassings = data.nextPassings; const now = Date.now(); if (this.state === null) { // Initialize the course on the first available segment const index = this.nextPassings.findIndex( ([, time]) => time >= now ); if (index === -1) { return false; } if (index === 0) { this.arriveToStop(this.nextPassings[index][0]); } else { this.arriveToStop(this.nextPassings[index - 1][0]); this.moveToStop(...this.nextPassings[index]); } } else if (this.state === "moving") { const index = this.nextPassings.findIndex( ([stop]) => stop === this.arrivalStop ); if (index === -1 || this.nextPassings[index][1] <= now) { // Next stop is not announced or in the past, // move towards it as fast as possible this.arrivalTime = now; } else { // On the right track, update the arrival time this.arrivalTime = this.nextPassings[index][1]; } } else { // (this.state === 'stopped') // Try moving to the next stop const index = this.nextPassings.findIndex( ([stop]) => stop === this.currentStop ); if (index !== -1) { if (this.nextPassings[index][1] <= now) { // Current stop is still announced but in the past if (index + 1 < this.nextPassings.length) { // Move to next stop this.moveToStop(...this.nextPassings[index + 1]); } else { // No next stop announced, end of course return false; } } else { // Cannot move yet, departure is in the future this.departureTime = this.nextPassings[index][1]; } } else { // Current stop is not announced, find the first stop // announced in the future to which is connection is // possible let found = false; for ( let nextIndex = 0; nextIndex < this.nextPassings.length; ++nextIndex ) { const [stop, arrivalTime] = this.nextPassings[nextIndex]; if (arrivalTime > now) { const route = findRoute(this.currentStop, stop); if (route !== null) { // Move to the first intermediate stop, guess the // arrival time based on the final arrival time and // the relative distance of the stops const midDistance = network.segments[ `${route[0]}-${route[1]}` ].properties.length; let totalDistance = midDistance; for ( let midIndex = 1; midIndex + 1 < route.length; ++midIndex ) { totalDistance += network.segments[ `${route[midIndex]}-${route[midIndex + 1]}` ].properties.length; } const midTime = now + (arrivalTime - now) * midDistance / totalDistance; this.moveToStop(route[1], midTime); found = true; break; } } } if (!found) { // No valid next stop available return false; } } } if (this.state === "moving") { const segment = this.currentSegment; const distance = segment.properties.length - this.traveledDistance; const duration = this.arrivalTime - Date.now(); this.speed = Course.computeSpeed(distance, duration); } return true; } tick(time) { if (this.state === "moving") { // Integrate current speed in travelled distance this.traveledDistance += this.speed * time; const segment = this.currentSegment; if (this.traveledDistance >= segment.properties.length) { this.arriveToStop(this.arrivalStop); return; } // Compute updated position and angle based on a small step const step = 10; // In meters const positions = [ Math.max(0, this.traveledDistance - step / 2), this.traveledDistance, this.traveledDistance + step / 2 ].map(distance => turfProjection.toMercator(turfAlong( 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]; } } /** * Transition this course to a state where it has arrived to a stop. * @param {string} stop Identifier for the stop to which * the course arrives. * @returns {undefined} */ arriveToStop(stop) { this.state = "stopped"; this.currentStop = stop; this.departureTime = Date.now(); this.prevPassings.push([stop, Date.now()]); this.position = ( turfProjection.toMercator(network.stops[stop]) .geometry.coordinates ); } /** * Transition this course to a state where it is moving to a stop. * @param {string} stop Next stop for this course. * @param {number} arrivalTime Planned arrival time to that stop. * @returns {undefined} */ moveToStop(stop, arrivalTime) { const segmentId = `${this.currentStop}-${stop}`; if (!(segmentId in network.segments)) { console.warn(`Course ${this.id} cannot go from stop ${this.currentStop} to stop ${stop}. Teleporting to ${stop}`); this.arriveToStop(stop); return; } const distance = network.segments[segmentId].properties.length; const duration = arrivalTime - Date.now(); if (Course.computeSpeed(distance, duration) === 0) { // Speed would be too low, better wait for some time return; } this.state = "moving"; this.departureStop = this.currentStop; this.arrivalStop = stop; this.arrivalTime = arrivalTime; this.traveledDistance = 0; this.speed = 0; } static computeSpeed(distance, duration) { if (duration <= 0) { // Late: go to maximum speed return 50 / 3600; } if (distance / duration <= 10 / 3600) { // Too slow: pause until speed is sufficient return 0; } return distance / duration; } } 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) { if (!courses[id].updateData(data)) { delete courses[id]; } } else { const newCourse = new Course(data); if (newCourse.updateData(data)) { courses[id] = newCourse; } } } // Remove stale courses for (const id of Object.keys(courses)) { if (!(id in dataset)) { delete courses[id]; } } }; const tick = (courses, time) => { for (const course of Object.values(courses)) { course.tick(time); } }; 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; tick(courses, time); }; return { courses, update }; }; exports.start = start;