diff --git a/package.json b/package.json index 2197cdf..1b5a381 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "type": "module", "scripts": { "back": "node src/back", - "front:dev": "vite", - "front:prod": "vite build", + "front:dev": "vite src/front", + "front:prod": "vite build src/front", "lint": "eslint ." }, "dependencies": { diff --git a/src/front/index.html b/src/front/index.html index d95b4d4..a31fd1a 100644 --- a/src/front/index.html +++ b/src/front/index.html @@ -33,6 +33,6 @@
- + diff --git a/src/front/index.js b/src/front/index.js index 2cb5387..3937b7a 100644 --- a/src/front/index.js +++ b/src/front/index.js @@ -1,6 +1,6 @@ import network from "../tam/network.json"; -import * as simulation from "../tam/simulation"; -import * as map from "./map/index"; +import * as simulation from "../tam/simulation.js"; +import * as map from "./map/index.js"; // Run courses simulation const coursesSimulation = simulation.start(); @@ -15,6 +15,18 @@ const displayTime = date => [ date.getSeconds() ].map(number => number.toString().padStart(2, "0")).join(":"); +const timeToHTML = time => { + const delta = Math.ceil((time - Date.now()) / 1000); + + if (delta <= 0) { + return `Imminent`; + } else if (delta < 60) { + return `${delta} s`; + } else { + return `${Math.floor(delta / 60)} min ${delta % 60} s`; + } +}; + setInterval(() => { let html = `
@@ -26,8 +38,6 @@ setInterval(() => { if (courseId !== null && courseId in coursesSimulation.courses) { const course = coursesSimulation.courses[courseId]; - const timeToHTML = time => Math.ceil((time - Date.now()) / 1000); - const stopToHTML = stopId => stopId in network.stops ? network.stops[stopId].properties.name : 'Arrêt inconnu'; @@ -39,6 +49,17 @@ setInterval(() => { `).join("\n"); + const state = ( + course.traveledDistance === 0 && course.speed === 0 + ? "stopped" : "moving" + ); + + let prevPassings = course.prevPassings; + + if (state === "moving") { + prevPassings = prevPassings.concat([[course.departureStop, course.departureTime]]); + } + html += `
ID
@@ -51,14 +72,14 @@ setInterval(() => {
${stopToHTML(course.finalStop)}
État
-
${course.state === "moving" +
${state === "moving" ? `Entre ${stopToHTML(course.departureStop)} et ${stopToHTML(course.arrivalStop)}` - : `À l’arrêt ${stopToHTML(course.currentStop)}`}
+ : `À l’arrêt ${stopToHTML(course.departureStop)}`} - ${course.state === "moving" ? ` + ${state === "moving" ? `
Arrivée dans
-
${timeToHTML(course.arrivalTime)} s
+
${timeToHTML(course.arrivalTime - 10000)}
Distance parcourue
${Math.ceil(course.traveledDistance)} m
@@ -67,12 +88,12 @@ setInterval(() => {
${Math.ceil(course.speed * 3600)} km/h
` : `
Départ dans
-
${timeToHTML(course.departureTime)} s
+
${timeToHTML(course.departureTime)}
`}

Arrêts précédents

- ${passingsToHTML(course.prevPassings)}
+ ${passingsToHTML(prevPassings)}

Arrêts suivants

${passingsToHTML(course.nextPassings)}
diff --git a/src/tam/simulation.js b/src/tam/simulation.js index 6fc7252..30aae2e 100644 --- a/src/tam/simulation.js +++ b/src/tam/simulation.js @@ -1,277 +1,275 @@ import axios from "axios"; import turfAlong from "@turf/along"; +import turfLength from "@turf/length"; +import * as turfHelpers from "@turf/helpers"; import * as turfProjection from "@turf/projection"; import network from "./network.json"; const server = "http://localhost:4321"; -const findRoute = (from, to) => { - const queue = [[from, []]]; +// Number of milliseconds to stay at each stop +const stopTime = 10000; - while (queue.length) { - const [head, path] = queue.shift(); +// Step used to compute the vehicle angle in meters +const angleStep = 10; - for (const successor of network.stops[head].properties.successors) { - if (successor === to) { - return path.concat([head, successor]); - } +// Maximum speed of a vehicle +const maxSpeed = 60 / 3600; - if (!path.includes(successor)) { - queue.push([successor, path.concat([head])]); - } - } - } +// Minimum speed of a vehicle +const minSpeed = 10 / 3600; - return null; -}; +// Normal speed of a vehicle +const normSpeed = (2 * maxSpeed + minSpeed) / 3; +/** Simulate the evolution of a vehicle course in the network. */ class Course { - constructor(data) { - this.id = data.id; - this.prevPassings = []; - this.nextPassings = []; - this.state = null; + constructor(id) { + // Unique identifier of this course + this.id = id; - // Attributes for the `stopped` state - this.currentStop = null; + // 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 (with timestamps) + this.prevPassings = []; + + // Next stops that this course will leave (with timestamps) + 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 this.departureTime = 0; - // Attributes for the `moving` state - this.departureStop = null; + // Next stop that this course will reach + // (if equal to departureStop, the course has reached its last stop) this.arrivalStop = null; + + // Time at which the next stop will be left this.arrivalTime = 0; + + // Segment of points between the current departure and arrival + this.segment = null; + + // Number of meters travelled between the two stops this.traveledDistance = 0; + + // Current vehicle speed in meters per millisecond this.speed = 0; + // Current vehicle latitude and longitude this.position = [0, 0]; - this.angle = 0; - this.history = []; + // Current vehicle bearing + this.angle = 0; } - get currentSegment() { - if (this.state !== "moving") { - return null; + /** Retrieve information about the current segment used by the vehicle. */ + updateSegment() { + if (this.departureStop === null || this.arrivalStop === null) { + this.segment = null; + return; } - return network.segments[`${this.departureStop}-${this.arrivalStop}`]; + const name = `${this.departureStop}-${this.arrivalStop}`; + + 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; + } + + this.segment = turfHelpers.lineString([ + network.stops[this.departureStop].geometry.coordinates, + network.stops[this.arrivalStop].geometry.coordinates, + ]); + this.segment.properties.length = turfLength(this.segment); } - updateData(data) { + /** Merge data received from the server. */ + receiveData(data) { this.line = data.line; this.direction = data.direction; this.finalStop = data.finalStopId; - this.nextPassings = data.passings; - const now = Date.now(); + const passings = Object.assign( + Object.fromEntries(this.nextPassings), + Object.fromEntries(data.passings), + ); - if (this.state === null) { - // Initialize the course on the first available segment - const index = this.nextPassings.findIndex( - ([, time]) => time >= now - ); + // Remove older passings from next passings + for (let [stop, time] of this.prevPassings) { + delete passings[stop]; + } - 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; - } + // Update departure time if still announced + if (this.departureStop !== null) { + if (this.departureStop in passings) { + this.departureTime = passings[this.departureStop]; + delete passings[this.departureStop]; } } - if (this.state === "moving") { - const segment = this.currentSegment; - const distance = segment.properties.length - this.traveledDistance; - const duration = this.arrivalTime - Date.now(); + // 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.speed = Course.computeSpeed(distance, duration); + 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; } - tick(time) { - if (this.state === "moving") { - // Integrate current speed in travelled distance - this.traveledDistance += this.speed * time; - const segment = this.currentSegment; + /** Integrate the current vehicle speed and update distance. */ + move(time) { + if (this.segment === null) { + return; + } - 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] + if (this.speed > 0) { + this.traveledDistance = Math.min( + this.traveledDistance + this.speed * time, + this.segment.properties.length, ); - - 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 + // 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] ); - } - /** - * 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; + this.position = positions[1]; } static computeSpeed(distance, duration) { if (duration <= 0) { // Late: go to maximum speed - return 50 / 3600; + return maxSpeed; } - if (distance / duration <= 10 / 3600) { + const speed = distance / duration; + + if (speed < minSpeed) { // Too slow: pause until speed is sufficient return 0; } - return distance / duration; + return Math.min(maxSpeed, speed); } } @@ -281,29 +279,19 @@ const updateData = async courses => { // 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]; - } + courses[id].receiveData(data); } 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 newCourse = new Course(data.id); + newCourse.receiveData(data); + courses[id] = newCourse; } } }; const tick = (courses, time) => { for (const course of Object.values(courses)) { - course.tick(time); + course.update(); + course.move(time); } }; @@ -326,5 +314,7 @@ export const start = () => { tick(courses, time); }; + window.__courses = courses; + return { courses, update }; };