tracktracker/src/tam/simulation.js

338 lines
10 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 turfLength from "@turf/length";
import * as turfHelpers from "@turf/helpers";
import * as turfProjection from "@turf/projection";
import * as routing from "./routing.js";
import network from "./network.json";
const server = "http://localhost:4321";
// Number of milliseconds to stay at each stop
const stopTime = 10000;
// Step used to compute the vehicle angle in meters
const angleStep = 10;
// Maximum speed of a vehicle
const maxSpeed = 60 / 3600;
// Minimum speed of a vehicle
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 (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;
// 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];
// Current vehicle bearing
this.angle = 0;
}
/** Retrieve information about the current segment used by the vehicle. */
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
const route = routing.findRoute(this.departureStop, this.arrivalStop);
if (route === null) {
console.warn(`No route from ${this.departureStop} \
to ${this.arrivalStop}`);
}
this.segment = turfHelpers.lineString(
route.map(node => [
network.navigation[node].lon,
network.navigation[node].lat,
])
);
this.segment.properties.length = 1000 * turfLength(this.segment);
}
/** Merge 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, time] 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];
}
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);
}
}
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];
}
}
};
const tick = (courses, time) => {
for (const course of Object.values(courses)) {
course.update();
course.move(time);
}
};
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;
tick(courses, time);
};
return { courses, update };
};