tracktracker/src/data/simulation.js

350 lines
10 KiB
JavaScript
Raw Permalink 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 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
* vehicles 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) {
// Were 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 };
};