tracktracker/src/data/simulation.js

330 lines
9.9 KiB
JavaScript
Raw 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 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) {
// Were 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 };
};