Code cleanup: Update ESLint conf, improve internal passings interface
This commit is contained in:
parent
35cd0e1e72
commit
079fbcf310
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"extends": [
|
||||
"eslint:recommended"
|
||||
"eslint"
|
||||
],
|
||||
"env": {
|
||||
"es6": true
|
||||
|
@ -10,7 +10,7 @@
|
|||
"SharedArrayBuffer": "readonly"
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2018,
|
||||
"ecmaVersion": 2019,
|
||||
"sourceType": "script"
|
||||
},
|
||||
"ignorePatterns": [
|
||||
|
@ -18,38 +18,12 @@
|
|||
"dist/"
|
||||
],
|
||||
"rules": {
|
||||
"indent": [
|
||||
"error",
|
||||
4
|
||||
],
|
||||
"linebreak-style": [
|
||||
"error",
|
||||
"unix"
|
||||
],
|
||||
"quotes": [
|
||||
"error",
|
||||
"single"
|
||||
],
|
||||
"semi": [
|
||||
"error",
|
||||
"always"
|
||||
],
|
||||
"no-irregular-whitespace": [
|
||||
"error",
|
||||
{
|
||||
"skipStrings": true,
|
||||
"skipTemplates": true
|
||||
}
|
||||
],
|
||||
"comma-dangle": [
|
||||
"error",
|
||||
{
|
||||
"arrays": "always-multiline",
|
||||
"objects": "always-multiline",
|
||||
"imports": "always-multiline",
|
||||
"exports": "always-multiline",
|
||||
"functions": "never"
|
||||
}
|
||||
]
|
||||
"strict": ["error", "never"],
|
||||
"no-console": ["error", {"allow": ["info", "warn", "error"]}],
|
||||
"no-multi-str": "off",
|
||||
"func-style": ["error", "expression"],
|
||||
"max-len": ["error", {"code": 80}],
|
||||
"lines-around-comment": ["error", {"allowBlockStart": true}],
|
||||
"jsdoc/require-returns": "off"
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "tamview",
|
||||
"version": "1.0.0",
|
||||
"version": "0.1.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
@ -9,13 +9,17 @@
|
|||
"front:prod": "npx parcel build src/front/index.html --no-source-maps --no-autoinstall",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"author": "Mattéo Delabre",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@turf/along": "^6.0.1",
|
||||
"@turf/helpers": "^6.1.4",
|
||||
"@turf/length": "^6.0.2",
|
||||
"@turf/projection": "^6.0.1",
|
||||
"@turf/turf": "^5.1.6",
|
||||
"axios": "^0.19.2",
|
||||
"color": "^3.1.2",
|
||||
|
@ -26,7 +30,9 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^6.8.0",
|
||||
"nodemon": "^2.0.2",
|
||||
"eslint-config-eslint": "^6.0.0",
|
||||
"eslint-plugin-jsdoc": "^30.0.3",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"parcel-bundler": "^1.12.4"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,16 +1,12 @@
|
|||
const express = require('express');
|
||||
|
||||
const util = require('../util');
|
||||
const realtime = require('../tam/realtime');
|
||||
const express = require("express");
|
||||
const realtime = require("../tam/realtime");
|
||||
|
||||
const app = express();
|
||||
const port = 4321;
|
||||
|
||||
app.get('/courses', async (req, res) =>
|
||||
{
|
||||
res.header('Access-Control-Allow-Origin', '*');
|
||||
const courses = await realtime.getCourses();
|
||||
return res.json(courses);
|
||||
app.get("/courses", async(req, res) => {
|
||||
res.header("Access-Control-Allow-Origin", "*");
|
||||
return res.json(await realtime.fetch());
|
||||
});
|
||||
|
||||
app.listen(port, () => console.log(`App listening on port ${port}`));
|
||||
app.listen(port, () => console.info(`App listening on port ${port}`));
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"commonjs": true
|
||||
"browser": true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
require('regenerator-runtime/runtime');
|
||||
// eslint-disable-next-line node/no-extraneous-require
|
||||
require("regenerator-runtime/runtime");
|
||||
|
||||
const {createMap} = require('./map');
|
||||
createMap(/* map = */ 'map');
|
||||
const { createMap } = require("./map");
|
||||
|
||||
createMap(/* map = */ "map");
|
||||
|
|
215
src/front/map.js
215
src/front/map.js
|
@ -1,73 +1,64 @@
|
|||
require('ol/ol.css');
|
||||
require("ol/ol.css");
|
||||
|
||||
const axios = require('axios');
|
||||
const {Map, View} = require('ol');
|
||||
const { Map, View } = require("ol");
|
||||
|
||||
const GeoJSON = require('ol/format/GeoJSON').default;
|
||||
const reader = new GeoJSON({featureProjection: 'EPSG:3857'});
|
||||
const GeoJSON = require("ol/format/GeoJSON").default;
|
||||
const reader = new GeoJSON({ featureProjection: "EPSG:3857" });
|
||||
|
||||
const TileLayer = require('ol/layer/Tile').default;
|
||||
const XYZSource = require('ol/source/XYZ').default;
|
||||
const TileLayer = require("ol/layer/Tile").default;
|
||||
const XYZSource = require("ol/source/XYZ").default;
|
||||
|
||||
const VectorLayer = require('ol/layer/Vector').default;
|
||||
const VectorSource = require('ol/source/Vector').default;
|
||||
const {getVectorContext} = require('ol/render');
|
||||
const VectorLayer = require("ol/layer/Vector").default;
|
||||
const VectorSource = require("ol/source/Vector").default;
|
||||
const { getVectorContext } = require("ol/render");
|
||||
|
||||
const Feature = require('ol/Feature').default;
|
||||
const Point = require('ol/geom/Point').default;
|
||||
const LineString = require('ol/geom/LineString').default;
|
||||
const Point = require("ol/geom/Point").default;
|
||||
|
||||
const proj = require('ol/proj');
|
||||
const proj = require("ol/proj");
|
||||
|
||||
const {Style, Fill, Stroke, Circle, Icon} = require('ol/style');
|
||||
const color = require('color');
|
||||
const { Style, Fill, Stroke, Circle, Icon } = require("ol/style");
|
||||
const colorModule = require("color");
|
||||
|
||||
const mapboxToken = `pk.eyJ1IjoibWF0dGVvZGVsYWJyZSIsImEiOiJja2NxaTUyMmUwcmFhMn\
|
||||
h0NmFsdzQ3emxqIn0.cyxF0h36emIMTk3cc4VqUw`;
|
||||
const mapboxToken = "pk.eyJ1IjoibWF0dGVvZGVsYWJyZSIsImEiOiJja2NxaTUyMmUwcmFhMn\
|
||||
h0NmFsdzQ3emxqIn0.cyxF0h36emIMTk3cc4VqUw";
|
||||
|
||||
const simulation = require('../tam/simulation');
|
||||
const network = require('../tam/network.json');
|
||||
const simulation = require("../tam/simulation");
|
||||
const network = require("../tam/network.json");
|
||||
|
||||
const lineFeaturesOrder = (feature1, feature2) =>
|
||||
{
|
||||
const lines1 = feature1.get('lines');
|
||||
const lineFeaturesOrder = (feature1, feature2) => {
|
||||
const lines1 = feature1.get("lines");
|
||||
|
||||
if (lines1.length === 0)
|
||||
{
|
||||
if (lines1.length === 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const lines2 = feature2.get('lines');
|
||||
const lines2 = feature2.get("lines");
|
||||
|
||||
if (lines2.length === 0)
|
||||
{
|
||||
if (lines2.length === 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.min(...lines1) - Math.min(...lines2);
|
||||
};
|
||||
|
||||
const makeDataSources = () =>
|
||||
{
|
||||
const makeDataSources = () => {
|
||||
const segmentsSource = new VectorSource();
|
||||
const stopsSource = new VectorSource();
|
||||
|
||||
const readFeatures = hash => Object.values(hash).map(json =>
|
||||
{
|
||||
const readFeatures = hash => Object.values(hash).map(json => {
|
||||
json.properties.lines = json.properties.routes.filter(
|
||||
|
||||
// Only consider normal routes (excluding alternate routes)
|
||||
([lineRef, routeRef]) =>
|
||||
network.lines[lineRef].routes[routeRef].state === 'normal'
|
||||
network.lines[lineRef].routes[routeRef].state === "normal"
|
||||
).map(([lineRef]) => lineRef);
|
||||
|
||||
if (json.properties.lines.length >= 1)
|
||||
{
|
||||
if (json.properties.lines.length >= 1) {
|
||||
json.properties.colors = json.properties.lines.map(
|
||||
lineRef => network.lines[lineRef].color);
|
||||
}
|
||||
else
|
||||
{
|
||||
json.properties.colors = ['#FFFFFF'];
|
||||
lineRef => network.lines[lineRef].color
|
||||
);
|
||||
} else {
|
||||
json.properties.colors = ["#FFFFFF"];
|
||||
}
|
||||
|
||||
return reader.readFeature(json);
|
||||
|
@ -75,19 +66,19 @@ const makeDataSources = () =>
|
|||
|
||||
segmentsSource.addFeatures(readFeatures(network.segments));
|
||||
stopsSource.addFeatures(readFeatures(network.stops));
|
||||
return {segmentsSource, stopsSource};
|
||||
return { segmentsSource, stopsSource };
|
||||
};
|
||||
|
||||
const makeBorderColor = mainColor =>
|
||||
{
|
||||
const hsl = color(mainColor).hsl();
|
||||
const makeBorderColor = mainColor => {
|
||||
const hsl = colorModule(mainColor).hsl();
|
||||
|
||||
hsl.color = Math.max(0, hsl.color[2] -= 20);
|
||||
return hsl.hex();
|
||||
};
|
||||
|
||||
const makeCourseColor = mainColor =>
|
||||
{
|
||||
const hsl = color(mainColor).hsl();
|
||||
const makeCourseColor = mainColor => {
|
||||
const hsl = colorModule(mainColor).hsl();
|
||||
|
||||
hsl.color = Math.max(0, hsl.color[2] += 10);
|
||||
return hsl.hex();
|
||||
};
|
||||
|
@ -100,43 +91,41 @@ const sizes = {
|
|||
courseSize: 15,
|
||||
courseOuterBorder: 13,
|
||||
courseBorder: 10,
|
||||
courseInnerBorder: 7,
|
||||
courseInnerBorder: 7
|
||||
};
|
||||
|
||||
const segmentBorderStyle = feature => new Style({
|
||||
stroke: new Stroke({
|
||||
color: makeBorderColor(feature.get('colors')[0]),
|
||||
width: sizes.segmentOuter,
|
||||
}),
|
||||
color: makeBorderColor(feature.get("colors")[0]),
|
||||
width: sizes.segmentOuter
|
||||
})
|
||||
});
|
||||
|
||||
const segmentInnerStyle = feature => new Style({
|
||||
stroke: new Stroke({
|
||||
color: feature.get('colors')[0],
|
||||
width: sizes.segmentInner,
|
||||
}),
|
||||
color: feature.get("colors")[0],
|
||||
width: sizes.segmentInner
|
||||
})
|
||||
});
|
||||
|
||||
const stopStyle = feature => new Style({
|
||||
image: new Circle({
|
||||
fill: new Fill({
|
||||
color: feature.get('colors')[0],
|
||||
color: feature.get("colors")[0]
|
||||
}),
|
||||
stroke: new Stroke({
|
||||
color: makeBorderColor(feature.get('colors')[0]),
|
||||
width: sizes.stopBorder,
|
||||
}),
|
||||
radius: sizes.stopRadius,
|
||||
color: makeBorderColor(feature.get("colors")[0]),
|
||||
width: sizes.stopBorder
|
||||
}),
|
||||
radius: sizes.stopRadius
|
||||
})
|
||||
});
|
||||
|
||||
const courseStyles = {};
|
||||
|
||||
const getCourseStyle = color =>
|
||||
{
|
||||
if (!(color in courseStyles))
|
||||
{
|
||||
const icon = document.createElement('canvas');
|
||||
const getCourseStyle = lineColor => {
|
||||
if (!(lineColor in courseStyles)) {
|
||||
const icon = window.document.createElement("canvas");
|
||||
|
||||
const shapeSize = sizes.courseSize;
|
||||
const iconSize = sizes.courseSize + sizes.courseOuterBorder;
|
||||
|
@ -147,18 +136,17 @@ const getCourseStyle = color =>
|
|||
const cx = icon.width / 2;
|
||||
const cy = icon.height / 2;
|
||||
|
||||
const iconCtx = icon.getContext('2d');
|
||||
const iconCtx = icon.getContext("2d");
|
||||
|
||||
for (let [color, size] of [
|
||||
[makeBorderColor(color), sizes.courseOuterBorder],
|
||||
[color, sizes.courseBorder],
|
||||
[makeCourseColor(color), sizes.courseInnerBorder]
|
||||
])
|
||||
{
|
||||
for (const [color, size] of [
|
||||
[makeBorderColor(lineColor), sizes.courseOuterBorder],
|
||||
[lineColor, sizes.courseBorder],
|
||||
[makeCourseColor(lineColor), sizes.courseInnerBorder]
|
||||
]) {
|
||||
iconCtx.fillStyle = color;
|
||||
iconCtx.strokeStyle = color;
|
||||
iconCtx.lineWidth = size;
|
||||
iconCtx.lineJoin = 'round';
|
||||
iconCtx.lineJoin = "round";
|
||||
iconCtx.miterLimit = 200000;
|
||||
|
||||
iconCtx.beginPath();
|
||||
|
@ -170,34 +158,34 @@ const getCourseStyle = color =>
|
|||
iconCtx.fill();
|
||||
}
|
||||
|
||||
courseStyles[color] = new Style({
|
||||
courseStyles[lineColor] = new Style({
|
||||
image: new Icon({
|
||||
img: icon,
|
||||
imgSize: [icon.width, icon.height],
|
||||
}),
|
||||
imgSize: [icon.width, icon.height]
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
return courseStyles[color];
|
||||
return courseStyles[lineColor];
|
||||
};
|
||||
|
||||
const createMap = target =>
|
||||
{
|
||||
const createMap = target => {
|
||||
|
||||
// Map background
|
||||
const backgroundSource = new XYZSource({
|
||||
url: 'https://api.mapbox.com/' + [
|
||||
'styles', 'v1', 'mapbox', 'streets-v11',
|
||||
'tiles', '512', '{z}', '{x}', '{y}',
|
||||
].join('/') + `?access_token=${mapboxToken}`,
|
||||
tileSize: [512, 512],
|
||||
url: `https://api.mapbox.com/${[
|
||||
"styles", "v1", "mapbox", "streets-v11",
|
||||
"tiles", "512", "{z}", "{x}", "{y}"
|
||||
].join("/")}?access_token=${mapboxToken}`,
|
||||
tileSize: [512, 512]
|
||||
});
|
||||
|
||||
const backgroundLayer = new TileLayer({
|
||||
source: backgroundSource,
|
||||
source: backgroundSource
|
||||
});
|
||||
|
||||
// Static data overlay
|
||||
const {segmentsSource, stopsSource} = makeDataSources();
|
||||
const { segmentsSource, stopsSource } = makeDataSources();
|
||||
|
||||
const segmentsBorderLayer = new VectorLayer({
|
||||
source: segmentsSource,
|
||||
|
@ -205,7 +193,7 @@ const createMap = target =>
|
|||
style: segmentBorderStyle,
|
||||
|
||||
updateWhileInteracting: true,
|
||||
updateWhileAnimating: true,
|
||||
updateWhileAnimating: true
|
||||
});
|
||||
|
||||
const segmentsInnerLayer = new VectorLayer({
|
||||
|
@ -214,7 +202,7 @@ const createMap = target =>
|
|||
style: segmentInnerStyle,
|
||||
|
||||
updateWhileInteracting: true,
|
||||
updateWhileAnimating: true,
|
||||
updateWhileAnimating: true
|
||||
});
|
||||
|
||||
const stopsLayer = new VectorLayer({
|
||||
|
@ -224,7 +212,7 @@ const createMap = target =>
|
|||
|
||||
minZoom: 13,
|
||||
updateWhileInteracting: true,
|
||||
updateWhileAnimating: true,
|
||||
updateWhileAnimating: true
|
||||
});
|
||||
|
||||
// Setup map
|
||||
|
@ -232,7 +220,7 @@ const createMap = target =>
|
|||
center: proj.fromLonLat([3.88, 43.605]),
|
||||
zoom: 14,
|
||||
maxZoom: 22,
|
||||
constrainResolution: true,
|
||||
constrainResolution: true
|
||||
});
|
||||
|
||||
const map = new Map({
|
||||
|
@ -241,9 +229,9 @@ const createMap = target =>
|
|||
backgroundLayer,
|
||||
segmentsBorderLayer,
|
||||
segmentsInnerLayer,
|
||||
stopsLayer,
|
||||
stopsLayer
|
||||
],
|
||||
view,
|
||||
view
|
||||
});
|
||||
|
||||
// Run courses simulation
|
||||
|
@ -252,26 +240,25 @@ const createMap = target =>
|
|||
// Course on which the view is currently focused
|
||||
let focusedCourse = null;
|
||||
|
||||
const startFocus = courseId =>
|
||||
{
|
||||
if (courseId in simulInstance.courses)
|
||||
{
|
||||
const startFocus = courseId => {
|
||||
if (courseId in simulInstance.courses) {
|
||||
const course = simulInstance.courses[courseId];
|
||||
|
||||
view.animate({
|
||||
center: course.position,
|
||||
duration: 500,
|
||||
}, () => focusedCourse = courseId);
|
||||
duration: 500
|
||||
}, () => {
|
||||
focusedCourse = courseId;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const stopFocus = () =>
|
||||
{
|
||||
const stopFocus = () => {
|
||||
focusedCourse = null;
|
||||
};
|
||||
|
||||
// Draw courses directly on the map
|
||||
map.on('postcompose', ev =>
|
||||
{
|
||||
map.on("postcompose", ev => {
|
||||
simulInstance.update();
|
||||
|
||||
// The normal way to access a layer’s vector context is through the
|
||||
|
@ -281,17 +268,16 @@ const createMap = target =>
|
|||
// if no stop is visible. This hack listens to the global `postcompose`
|
||||
// event, which is always triggered at every frame, and reconstructs
|
||||
// the stops layer’s vector context from internal variables
|
||||
if (stopsLayer.renderer_)
|
||||
{
|
||||
/* eslint-disable no-underscore-dangle */
|
||||
if (stopsLayer.renderer_) {
|
||||
ev.context = stopsLayer.renderer_.context;
|
||||
ev.inversePixelTransform
|
||||
= stopsLayer.renderer_.inversePixelTransform;
|
||||
ev.inversePixelTransform =
|
||||
stopsLayer.renderer_.inversePixelTransform;
|
||||
/* eslint-enable no-underscore-dangle */
|
||||
|
||||
const ctx = getVectorContext(ev);
|
||||
let rotation = 0;
|
||||
|
||||
for (let course of Object.values(simulInstance.courses))
|
||||
{
|
||||
for (const course of Object.values(simulInstance.courses)) {
|
||||
const color = network.lines[course.line].color;
|
||||
const style = getCourseStyle(color);
|
||||
|
||||
|
@ -299,10 +285,10 @@ const createMap = target =>
|
|||
ctx.setStyle(style);
|
||||
|
||||
const point = new Point(course.position);
|
||||
|
||||
ctx.drawGeometry(point);
|
||||
|
||||
if (course.id === focusedCourse)
|
||||
{
|
||||
if (course.id === focusedCourse) {
|
||||
view.setCenter(course.position);
|
||||
}
|
||||
}
|
||||
|
@ -313,20 +299,17 @@ const createMap = target =>
|
|||
|
||||
map.render();
|
||||
|
||||
map.on('singleclick', ev =>
|
||||
{
|
||||
map.on("singleclick", ev => {
|
||||
const mousePixel = map.getPixelFromCoordinate(ev.coordinate);
|
||||
const maxDistance = sizes.courseSize + sizes.courseOuterBorder;
|
||||
|
||||
for (let course of Object.values(simulInstance.courses))
|
||||
{
|
||||
for (const course of Object.values(simulInstance.courses)) {
|
||||
const coursePixel = map.getPixelFromCoordinate(course.position);
|
||||
const dx = mousePixel[0] - coursePixel[0];
|
||||
const dy = mousePixel[1] - coursePixel[1];
|
||||
const distance = dx * dx + dy * dy;
|
||||
|
||||
if (distance <= maxDistance * maxDistance)
|
||||
{
|
||||
if (distance <= maxDistance * maxDistance) {
|
||||
startFocus(course.id);
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"env": {
|
||||
"commonjs": true
|
||||
}
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* @file
|
||||
* @fileoverview
|
||||
*
|
||||
* Extract static information about the TaM network from OpenStreetMap (OSM):
|
||||
* tram and bus lines, stops and routes.
|
||||
|
@ -12,25 +12,24 @@
|
|||
* the `script/update-network` script.
|
||||
*/
|
||||
|
||||
const turfHelpers = require('@turf/helpers');
|
||||
const turfLength = require('@turf/length').default;
|
||||
const util = require('../util');
|
||||
const osm = require('./sources/osm');
|
||||
const tam = require('./sources/tam');
|
||||
const turfHelpers = require("@turf/helpers");
|
||||
const turfLength = require("@turf/length").default;
|
||||
const util = require("../util");
|
||||
const osm = require("./sources/osm");
|
||||
|
||||
/**
|
||||
* Fetch stops and lines of the network.
|
||||
*
|
||||
* @param lineRefs List of lines to fetch.
|
||||
* @return Object with a set of stops, segments and lines.
|
||||
* @param {string[]} lineRefs List of lines to fetch.
|
||||
* @returns {{stops: Object, lines: Object, segments: Object}} Set of stops,
|
||||
* segments and lines.
|
||||
*/
|
||||
const fetch = async (lineRefs) =>
|
||||
{
|
||||
const fetch = async lineRefs => {
|
||||
|
||||
// Retrieve routes, ways and stops from OpenStreetMap
|
||||
const rawData = await osm.runQuery(`[out:json];
|
||||
|
||||
// Find the public transport line bearing the requested reference
|
||||
relation[network="TaM"][type="route_master"][ref~"^(${lineRefs.join('|')})$"];
|
||||
relation[network="TaM"][type="route_master"][ref~"^(${lineRefs.join("|")})$"];
|
||||
|
||||
// Recursively fetch routes, ways and stops inside the line
|
||||
(._; >>;);
|
||||
|
@ -45,8 +44,7 @@ out body qt;
|
|||
const routeMasters = elementsList.filter(osm.isTransportLine);
|
||||
|
||||
// Retrieved objects indexed by ID
|
||||
const elements = elementsList.reduce((prev, elt) =>
|
||||
{
|
||||
const elements = elementsList.reduce((prev, elt) => {
|
||||
prev[elt.id] = elt;
|
||||
return prev;
|
||||
}, {});
|
||||
|
@ -60,46 +58,39 @@ out body qt;
|
|||
// All segments leading from one stop to another
|
||||
const segments = {};
|
||||
|
||||
for (let routeMaster of routeMasters)
|
||||
{
|
||||
for (const routeMaster of routeMasters) {
|
||||
const lineRef = routeMaster.tags.ref;
|
||||
const color = routeMaster.tags.colour || '#000000';
|
||||
const color = routeMaster.tags.colour || "#000000";
|
||||
|
||||
// Extract all routes for the given line
|
||||
const routes = [];
|
||||
|
||||
for (let [routeRef, {ref: routeId}] of routeMaster.members.entries())
|
||||
{
|
||||
for (const [routeRef, data] of routeMaster.members.entries()) {
|
||||
const routeId = data.ref;
|
||||
const route = elements[routeId];
|
||||
const {from, via, to, name} = route.tags;
|
||||
const state = route.tags.state || 'normal';
|
||||
const { from, via, to, name } = route.tags;
|
||||
const state = route.tags.state || "normal";
|
||||
|
||||
// Add missing stops to the global stops object
|
||||
for (let {ref, role} of route.members)
|
||||
{
|
||||
if (role === 'stop')
|
||||
{
|
||||
for (const { ref, role } of route.members) {
|
||||
if (role === "stop") {
|
||||
const stop = elements[ref];
|
||||
|
||||
if (!('ref' in stop.tags))
|
||||
{
|
||||
if (!("ref" in stop.tags)) {
|
||||
throw new Error(`Stop ${stop.id}
|
||||
(${osm.viewNode(stop.id)}) on line ${route.tags.name} is missing
|
||||
a “ref” tag`);
|
||||
}
|
||||
|
||||
if (!(stop.tags.ref in stops))
|
||||
{
|
||||
if (!(stop.tags.ref in stops)) {
|
||||
stops[stop.tags.ref] = turfHelpers.point([
|
||||
stop.lon,
|
||||
stop.lat
|
||||
], {
|
||||
name: stop.tags.name,
|
||||
routes: [[lineRef, routeRef]],
|
||||
routes: [[lineRef, routeRef]]
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
} else {
|
||||
stops[stop.tags.ref].properties.routes.push([
|
||||
lineRef,
|
||||
routeRef
|
||||
|
@ -111,21 +102,19 @@ a “ref” tag`);
|
|||
// Check that the route consists of a block of stops and platforms
|
||||
// followed by a block of routes as dictated by PTv2
|
||||
const relationPivot = route.members.findIndex(
|
||||
({role}) => role === ''
|
||||
({ role }) => role === ""
|
||||
);
|
||||
|
||||
if (!route.members.slice(0, relationPivot).every(
|
||||
({role}) => role === 'stop' || role === 'platform'
|
||||
))
|
||||
{
|
||||
({ role }) => role === "stop" || role === "platform"
|
||||
)) {
|
||||
throw new Error(`Members with invalid roles in between stops
|
||||
of ${name}`);
|
||||
}
|
||||
|
||||
if (!route.members.slice(relationPivot).every(
|
||||
({role}) => role === ''
|
||||
))
|
||||
{
|
||||
({ role }) => role === ""
|
||||
)) {
|
||||
throw new Error(`Members with invalid roles inside the path
|
||||
of ${name}`);
|
||||
}
|
||||
|
@ -134,22 +123,21 @@ of ${name}`);
|
|||
// order as per PTv2 and to be traversed in order by the sequence
|
||||
// of ways extracted below
|
||||
const lineStops = route.members.slice(0, relationPivot)
|
||||
.filter(({role}) => role === 'stop')
|
||||
.map(({ref}) => ref);
|
||||
.filter(({ role }) => role === "stop")
|
||||
.map(({ ref }) => ref);
|
||||
|
||||
// List of ways making up the route’s path through its stops
|
||||
// with each way connected to the next through a single point
|
||||
const ways = route.members.slice(relationPivot)
|
||||
.map(({ref}) => ref);
|
||||
.map(({ ref }) => ref);
|
||||
|
||||
// Merge all used ways in a single path
|
||||
let path = [];
|
||||
let currentNode = lineStops[0];
|
||||
|
||||
for (let wayIndex = 0; wayIndex < ways.length; wayIndex += 1)
|
||||
{
|
||||
for (let wayIndex = 0; wayIndex < ways.length; wayIndex += 1) {
|
||||
const way = elements[ways[wayIndex]];
|
||||
const {nodes: wayNodes, tags: wayTags} = way;
|
||||
const { nodes: wayNodes } = way;
|
||||
const wayNodesSet = new Set(wayNodes);
|
||||
|
||||
const curNodeIndex = wayNodes.indexOf(currentNode);
|
||||
|
@ -159,13 +147,11 @@ of ${name}`);
|
|||
let nextNode = null;
|
||||
let nextNodeIndex = null;
|
||||
|
||||
if (wayIndex + 1 < ways.length)
|
||||
{
|
||||
if (wayIndex + 1 < ways.length) {
|
||||
const nextNodeCandidates = elements[ways[wayIndex + 1]]
|
||||
.nodes.filter(node => wayNodesSet.has(node));
|
||||
|
||||
if (nextNodeCandidates.length !== 1)
|
||||
{
|
||||
if (nextNodeCandidates.length !== 1) {
|
||||
throw new Error(`There should be exactly one point
|
||||
connecting way n°${wayIndex} and way n°${wayIndex + 1} in ${name},
|
||||
but there are ${nextNodeCandidates.length}`);
|
||||
|
@ -173,24 +159,20 @@ but there are ${nextNodeCandidates.length}`);
|
|||
|
||||
nextNode = nextNodeCandidates[0];
|
||||
nextNodeIndex = wayNodes.indexOf(nextNode);
|
||||
}
|
||||
else
|
||||
{
|
||||
} else {
|
||||
nextNodeIndex = wayNodes.length;
|
||||
}
|
||||
|
||||
if (curNodeIndex < nextNodeIndex)
|
||||
{
|
||||
if (curNodeIndex < nextNodeIndex) {
|
||||
|
||||
// Use the way in its normal direction
|
||||
path = path.concat(
|
||||
wayNodes.slice(curNodeIndex, nextNodeIndex)
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
} else {
|
||||
|
||||
// Use the way in the reverse direction
|
||||
if (osm.isOneWay(way))
|
||||
{
|
||||
if (osm.isOneWay(way)) {
|
||||
throw new Error(`Way n°${wayIndex} in
|
||||
${name} is one-way and cannot be used in reverse.`);
|
||||
}
|
||||
|
@ -205,8 +187,7 @@ ${name} is one-way and cannot be used in reverse.`);
|
|||
}
|
||||
|
||||
// Split the path into segments between stops
|
||||
for (let stopIdx = 0; stopIdx + 1 < lineStops.length; ++stopIdx)
|
||||
{
|
||||
for (let stopIdx = 0; stopIdx + 1 < lineStops.length; ++stopIdx) {
|
||||
const begin = elements[lineStops[stopIdx]].tags.ref;
|
||||
const beginIdx = path.indexOf(lineStops[stopIdx]);
|
||||
const end = elements[lineStops[stopIdx + 1]].tags.ref;
|
||||
|
@ -218,29 +199,28 @@ ${name} is one-way and cannot be used in reverse.`);
|
|||
const id = `${begin}-${end}`;
|
||||
const nodesIds = path.slice(beginIdx, endIdx);
|
||||
|
||||
if (id in segments)
|
||||
{
|
||||
if (id in segments) {
|
||||
if (!util.arraysEqual(
|
||||
nodesIds,
|
||||
segments[id].properties.nodesIds
|
||||
))
|
||||
{
|
||||
)) {
|
||||
throw new Error(`Segment ${id} is defined as a
|
||||
different sequence of nodes in two or more lines.`);
|
||||
}
|
||||
|
||||
segments[id].properties.routes.push([lineRef, routeRef]);
|
||||
}
|
||||
else
|
||||
{
|
||||
segments[id] = turfHelpers.lineString(nodesIds.map(id => [
|
||||
elements[id].lon,
|
||||
elements[id].lat
|
||||
]), {
|
||||
} else {
|
||||
segments[id] = turfHelpers.lineString(nodesIds.map(
|
||||
nodeId => [
|
||||
elements[nodeId].lon,
|
||||
elements[nodeId].lat
|
||||
]
|
||||
), {
|
||||
|
||||
// Keep track of the original sequence of nodes to
|
||||
// compare with duplicates
|
||||
nodesIds,
|
||||
routes: [[lineRef, routeRef]],
|
||||
routes: [[lineRef, routeRef]]
|
||||
});
|
||||
|
||||
segments[id].properties.length = (
|
||||
|
@ -249,24 +229,26 @@ different sequence of nodes in two or more lines.`);
|
|||
}
|
||||
|
||||
routes.push({
|
||||
from, via, to,
|
||||
name, state,
|
||||
from,
|
||||
via,
|
||||
to,
|
||||
name,
|
||||
state
|
||||
});
|
||||
}
|
||||
|
||||
lines[lineRef] = {
|
||||
color,
|
||||
routes,
|
||||
routes
|
||||
};
|
||||
}
|
||||
|
||||
// Remove OSM nodes from segments that were only used for checking validity
|
||||
for (let segment of Object.values(segments))
|
||||
{
|
||||
for (const segment of Object.values(segments)) {
|
||||
delete segment.properties.nodesIds;
|
||||
}
|
||||
|
||||
return {stops, lines, segments};
|
||||
return { stops, lines, segments };
|
||||
};
|
||||
|
||||
exports.fetch = fetch;
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
const tam = require('./sources/tam');
|
||||
const util = require('../util');
|
||||
const network = require('./network.json');
|
||||
const tam = require("./sources/tam");
|
||||
const network = require("./network.json");
|
||||
|
||||
// Time at which the course data needs to be updated next
|
||||
let nextUpdate = null;
|
||||
|
@ -8,58 +7,67 @@ let nextUpdate = null;
|
|||
// Current information about courses
|
||||
let currentCourses = null;
|
||||
|
||||
/**
|
||||
* Information about the course of a vehicle.
|
||||
* @typedef {Object} Course
|
||||
* @property {string} id Unique identifier for this course.
|
||||
* @property {string} line Transport line number.
|
||||
* @property {string} finalStop Final stop to which the course is headed.
|
||||
* @property {Object.<string,number>} nextPassings Next stations to which
|
||||
* the vehicle will stop, associated to the passing timestamp.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fetch real-time information about active courses in the TaM network.
|
||||
*
|
||||
* New data will only be fetched from the TaM server once every minute,
|
||||
* otherwise pulling from the in-memory cache.
|
||||
*
|
||||
* The following information is provided for each active course:
|
||||
*
|
||||
* - `id`: Unique identifier for the course.
|
||||
* - `line`: Line number.
|
||||
* - `finalStop`: The final stop to which the course is headed.
|
||||
* - `nextPassings`: Next passings of the vehicle, as a dictionary associating
|
||||
* each next stop to the passing timestamp.
|
||||
*
|
||||
* @return Mapping from active course IDs to information about each course.
|
||||
* @returns {Object.<string,Course>} Mapping from active course IDs to
|
||||
* information about each course.
|
||||
*/
|
||||
const getCourses = () => new Promise((res, rej) =>
|
||||
{
|
||||
if (nextUpdate !== null && Date.now() < nextUpdate)
|
||||
{
|
||||
res(currentCourses);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetch = async() => {
|
||||
if (nextUpdate === null || Date.now() >= nextUpdate) {
|
||||
const courses = {};
|
||||
let lastUpdate = null;
|
||||
const passings = tam.fetchRealtime();
|
||||
const timing = (await passings.next()).value;
|
||||
|
||||
tam.fetchRealtime((err, entry) =>
|
||||
{
|
||||
if (err)
|
||||
{
|
||||
rej(err);
|
||||
return;
|
||||
nextUpdate = timing.nextUpdate;
|
||||
|
||||
// Aggregate passings relative to the same course
|
||||
for await (const passing of passings) {
|
||||
const {
|
||||
course: id,
|
||||
routeShortName: line,
|
||||
stopId,
|
||||
destArCode: finalStop
|
||||
} = passing;
|
||||
|
||||
const arrivalTime = (
|
||||
timing.lastUpdate +
|
||||
parseInt(passing.delaySec, 10) * 1000
|
||||
);
|
||||
|
||||
if (!(id in courses)) {
|
||||
courses[id] = {
|
||||
id,
|
||||
line,
|
||||
finalStop,
|
||||
nextPassings: { [stopId]: arrivalTime }
|
||||
};
|
||||
} else {
|
||||
courses[id].nextPassings[stopId] = arrivalTime;
|
||||
}
|
||||
}
|
||||
|
||||
if (!util.isObject(entry))
|
||||
{
|
||||
// Filter courses to only keep those referring to known data
|
||||
for (let courseId of Object.keys(courses))
|
||||
{
|
||||
for (const courseId of Object.keys(courses)) {
|
||||
const course = courses[courseId];
|
||||
|
||||
if (!(course.line in network.lines))
|
||||
{
|
||||
if (!(course.line in network.lines)) {
|
||||
delete courses[courseId];
|
||||
}
|
||||
else
|
||||
{
|
||||
for (let stopId of Object.keys(course.nextPassings))
|
||||
{
|
||||
if (!(stopId in network.stops))
|
||||
{
|
||||
} else {
|
||||
for (const stopId of Object.keys(course.nextPassings)) {
|
||||
if (!(stopId in network.stops)) {
|
||||
delete courses[courseId];
|
||||
break;
|
||||
}
|
||||
|
@ -68,39 +76,9 @@ const getCourses = () => new Promise((res, rej) =>
|
|||
}
|
||||
|
||||
currentCourses = courses;
|
||||
res(currentCourses);
|
||||
return;
|
||||
}
|
||||
|
||||
if ('lastUpdate' in entry)
|
||||
{
|
||||
// Metadata header
|
||||
lastUpdate = entry.lastUpdate;
|
||||
nextUpdate = entry.nextUpdate;
|
||||
return;
|
||||
}
|
||||
return currentCourses;
|
||||
};
|
||||
|
||||
const {
|
||||
course: id,
|
||||
routeShortName: line,
|
||||
stopId,
|
||||
destArCode: finalStop,
|
||||
} = entry;
|
||||
|
||||
const arrivalTime = lastUpdate + parseInt(entry.delaySec, 10) * 1000;
|
||||
|
||||
if (!(id in courses))
|
||||
{
|
||||
courses[id] = {
|
||||
id, line, finalStop,
|
||||
nextPassings: {[stopId]: arrivalTime},
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
courses[id].nextPassings[stopId] = arrivalTime;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
exports.getCourses = getCourses;
|
||||
exports.fetch = fetch;
|
||||
|
|
|
@ -1,14 +1,12 @@
|
|||
const axios = require('axios');
|
||||
const turfAlong = require('@turf/along').default;
|
||||
const turfProjection = require('@turf/projection');
|
||||
const network = require('./network.json');
|
||||
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 server = "http://localhost:4321";
|
||||
|
||||
class Course
|
||||
{
|
||||
constructor(data)
|
||||
{
|
||||
class Course {
|
||||
constructor(data) {
|
||||
this.id = data.id;
|
||||
this.passings = {};
|
||||
this.state = null;
|
||||
|
@ -29,18 +27,15 @@ class Course
|
|||
this.history = [];
|
||||
}
|
||||
|
||||
get currentSegment()
|
||||
{
|
||||
if (this.state !== 'moving')
|
||||
{
|
||||
return undefined;
|
||||
get currentSegment() {
|
||||
if (this.state !== "moving") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return network.segments[`${this.departureStop}-${this.arrivalStop}`];
|
||||
}
|
||||
|
||||
updateData(data)
|
||||
{
|
||||
updateData(data) {
|
||||
this.line = data.line;
|
||||
this.finalStop = data.finalStop;
|
||||
Object.assign(this.passings, data.nextPassings);
|
||||
|
@ -48,104 +43,84 @@ class Course
|
|||
const now = Date.now();
|
||||
|
||||
// Make sure we’re on the right `stopped`/`moving` state
|
||||
if (this.state === null)
|
||||
{
|
||||
if (this.state === null) {
|
||||
let previousStop = null;
|
||||
let departureTime = 0;
|
||||
|
||||
let nextStop = null;
|
||||
let arrivalTime = Infinity;
|
||||
|
||||
for (let [stopId, time] of Object.entries(this.passings))
|
||||
{
|
||||
if (time > now && time < arrivalTime)
|
||||
{
|
||||
for (const [stopId, time] of Object.entries(this.passings)) {
|
||||
if (time > now && time < arrivalTime) {
|
||||
nextStop = stopId;
|
||||
arrivalTime = time;
|
||||
}
|
||||
|
||||
if (time < now && time > departureTime)
|
||||
{
|
||||
if (time < now && time > departureTime) {
|
||||
previousStop = stopId;
|
||||
departureTime = time;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextStop === null)
|
||||
{
|
||||
if (nextStop === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (previousStop === null)
|
||||
{
|
||||
if (previousStop === null) {
|
||||
// Teleport to the first known stop
|
||||
this.arriveToStop(nextStop);
|
||||
}
|
||||
else
|
||||
{
|
||||
} else {
|
||||
|
||||
// Teleport to the first known segment
|
||||
this.arriveToStop(previousStop);
|
||||
this.moveToStop(nextStop, arrivalTime);
|
||||
}
|
||||
}
|
||||
else if (this.state === 'moving')
|
||||
{
|
||||
} else if (this.state === "moving") {
|
||||
if (this.passings[this.arrivalStop] <= now) {
|
||||
// Should already be at the next stop
|
||||
if (this.passings[this.arrivalStop] <= now)
|
||||
{
|
||||
this.arriveToStop(this.arrivalStop);
|
||||
}
|
||||
} else {
|
||||
// On the right track, update the arrival time
|
||||
else
|
||||
{
|
||||
this.arrivalTime = this.passings[this.arrivalStop];
|
||||
}
|
||||
}
|
||||
else // this.state === 'stopped'
|
||||
{
|
||||
} else {
|
||||
// (this.state === 'stopped')
|
||||
// Try moving to the next stop
|
||||
let nextStop = null;
|
||||
let arrivalTime = Infinity;
|
||||
|
||||
for (let [stopId, time] of Object.entries(this.passings))
|
||||
{
|
||||
if (time > now && time < arrivalTime)
|
||||
{
|
||||
for (const [stopId, time] of Object.entries(this.passings)) {
|
||||
if (time > now && time < arrivalTime) {
|
||||
nextStop = stopId;
|
||||
arrivalTime = time;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextStop === null)
|
||||
{
|
||||
if (nextStop === null) {
|
||||
// This course is finished
|
||||
return false;
|
||||
}
|
||||
|
||||
if (nextStop !== this.currentStop)
|
||||
{
|
||||
if (nextStop !== this.currentStop) {
|
||||
this.moveToStop(nextStop, arrivalTime);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.state === 'moving')
|
||||
{
|
||||
if (this.state === "moving") {
|
||||
this.speed = this.computeTheoreticalSpeed();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
tick(time)
|
||||
{
|
||||
if (this.state === 'moving')
|
||||
{
|
||||
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)
|
||||
{
|
||||
if (this.traveledDistance >= segment.properties.length) {
|
||||
this.arriveToStop(this.arrivalStop);
|
||||
return;
|
||||
}
|
||||
|
@ -164,7 +139,7 @@ class Course
|
|||
|
||||
this.angle = Math.atan2(
|
||||
positions[0][1] - positions[2][1],
|
||||
positions[2][0] - positions[0][0],
|
||||
positions[2][0] - positions[0][0]
|
||||
);
|
||||
|
||||
this.position = positions[1];
|
||||
|
@ -173,42 +148,40 @@ class Course
|
|||
|
||||
/**
|
||||
* Transition this course to a state where it has arrived to a stop.
|
||||
*
|
||||
* @param stop Identifier for the stop to which the course arrives.
|
||||
* @param {string} stop Identifier for the stop to which
|
||||
* the course arrives.
|
||||
* @returns {undefined}
|
||||
*/
|
||||
arriveToStop(stop)
|
||||
{
|
||||
this.state = 'stopped';
|
||||
arriveToStop(stop) {
|
||||
this.state = "stopped";
|
||||
this.currentStop = stop;
|
||||
this.position = (
|
||||
turfProjection.toMercator(network.stops[stop])
|
||||
.geometry.coordinates);
|
||||
this.history.push(['arriveToStop', stop]);
|
||||
this.history.push(["arriveToStop", stop]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition this course to a state where it is moving to a stop.
|
||||
*
|
||||
* @param stop Next stop for this course.
|
||||
* @param arrivalTime Planned arrival time to that stop.
|
||||
* @param {string} stop Next stop for this course.
|
||||
* @param {number} arrivalTime Planned arrival time to that stop.
|
||||
* @returns {undefined}
|
||||
*/
|
||||
moveToStop(stop, arrivalTime)
|
||||
{
|
||||
if (!(`${this.currentStop}-${stop}` in network.segments))
|
||||
{
|
||||
moveToStop(stop, arrivalTime) {
|
||||
if (!(`${this.currentStop}-${stop}` in network.segments)) {
|
||||
console.warn(`Course ${this.id} cannot go from stop
|
||||
${this.currentStop} to stop ${stop}. Teleporting to ${stop}`);
|
||||
this.arriveToStop(stop);
|
||||
return;
|
||||
}
|
||||
|
||||
this.state = 'moving';
|
||||
this.state = "moving";
|
||||
this.departureStop = this.currentStop;
|
||||
this.arrivalStop = stop;
|
||||
this.arrivalTime = arrivalTime;
|
||||
this.traveledDistance = 0;
|
||||
this.speed = 0;
|
||||
this.history.push(['moveToStop', stop, arrivalTime]);
|
||||
this.history.push(["moveToStop", stop, arrivalTime]);
|
||||
|
||||
console.info(`Course ${this.id} leaving stop ${this.currentStop} \
|
||||
with initial speed ${this.computeTheoreticalSpeed() * 3600} km/h`);
|
||||
|
@ -216,11 +189,10 @@ with initial speed ${this.computeTheoreticalSpeed() * 3600} km/h`);
|
|||
|
||||
/**
|
||||
* Compute the speed that needs to be maintained to arrive on time.
|
||||
* @returns {number} Speed in meters per millisecond.
|
||||
*/
|
||||
computeTheoreticalSpeed()
|
||||
{
|
||||
if (this.state !== 'moving')
|
||||
{
|
||||
computeTheoreticalSpeed() {
|
||||
if (this.state !== "moving") {
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
@ -230,47 +202,34 @@ with initial speed ${this.computeTheoreticalSpeed() * 3600} km/h`);
|
|||
segment.properties.length - this.traveledDistance
|
||||
);
|
||||
|
||||
if (remainingDistance <= 0)
|
||||
{
|
||||
if (remainingDistance <= 0) {
|
||||
return 0;
|
||||
}
|
||||
else if (remainingTime <= 0)
|
||||
{
|
||||
if (remainingTime <= 0) {
|
||||
// We’re late, go to maximum speed
|
||||
return 50 / 3600; // 50 km/h
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
return remainingDistance / remainingTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateData = async (courses) =>
|
||||
{
|
||||
const updateData = async courses => {
|
||||
const dataset = (await axios.get(`${server}/courses`)).data;
|
||||
|
||||
// Update or create new courses
|
||||
for (let [id, data] of Object.entries(dataset))
|
||||
{
|
||||
if (id in courses)
|
||||
{
|
||||
if (!courses[id].updateData(data))
|
||||
{
|
||||
for (const [id, data] of Object.entries(dataset)) {
|
||||
if (id in courses) {
|
||||
if (!courses[id].updateData(data)) {
|
||||
console.info(`Course ${id} is finished.`);
|
||||
delete courses[id];
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
} else {
|
||||
const newCourse = new Course(data);
|
||||
|
||||
if (!newCourse.updateData(data))
|
||||
{
|
||||
if (!newCourse.updateData(data)) {
|
||||
console.info(`Ignoring course ${id} which is outdated.`);
|
||||
}
|
||||
else
|
||||
{
|
||||
} else {
|
||||
console.info(`Course ${id} starting.`);
|
||||
courses[id] = newCourse;
|
||||
}
|
||||
|
@ -278,45 +237,39 @@ const updateData = async (courses) =>
|
|||
}
|
||||
|
||||
// Remove stale courses
|
||||
for (let id of Object.keys(courses))
|
||||
{
|
||||
if (!(id in dataset))
|
||||
{
|
||||
for (const id of Object.keys(courses)) {
|
||||
if (!(id in dataset)) {
|
||||
delete courses[id];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const tick = (courses, time) =>
|
||||
{
|
||||
for (let course of Object.values(courses))
|
||||
{
|
||||
const tick = (courses, time) => {
|
||||
for (const course of Object.values(courses)) {
|
||||
course.tick(time);
|
||||
}
|
||||
};
|
||||
|
||||
const start = () =>
|
||||
{
|
||||
const start = () => {
|
||||
const courses = {};
|
||||
let lastFrame = null;
|
||||
let lastUpdate = null;
|
||||
|
||||
const update = () =>
|
||||
{
|
||||
const update = () => {
|
||||
const now = Date.now();
|
||||
|
||||
if (lastUpdate === null || lastUpdate + 5000 <= 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};
|
||||
return { courses, update };
|
||||
};
|
||||
|
||||
exports.start = start;
|
||||
|
|
|
@ -1,77 +1,55 @@
|
|||
/**
|
||||
* @file
|
||||
* @fileoverview
|
||||
*
|
||||
* Interface with the OpenStreetMap collaborative mapping database.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const {isObject} = require('../../util');
|
||||
const axios = require("axios");
|
||||
const { isObject } = require("../../util");
|
||||
|
||||
/**
|
||||
* Submit a query to an Overpass endpoint.
|
||||
*
|
||||
* See <https://wiki.openstreetmap.org/wiki/Overpass_API/Overpass_QL> for more
|
||||
* See <https://wiki.osm.org/Overpass_API/Overpass_QL> for more
|
||||
* information on the Overpass Query Language (Overpass QL).
|
||||
*
|
||||
* @async
|
||||
* @param query Query to send.
|
||||
* @param [endpoint] Overpass endpoint to use.
|
||||
* @return Results returned by the endpoint. If JSON output is requested in
|
||||
* the query, the result will automatically be parsed into a JS object.
|
||||
* @param {string} query Query to send.
|
||||
* @param {string} [endpoint] Overpass endpoint to use.
|
||||
* @returns {string|Object} Results returned by the endpoint. If JSON output
|
||||
* is requested in the query, the result will automatically be parsed into
|
||||
* a JS object.
|
||||
*/
|
||||
const runQuery = (
|
||||
query,
|
||||
endpoint = 'https://lz4.overpass-api.de/api/interpreter'
|
||||
endpoint = "https://lz4.overpass-api.de/api/interpreter"
|
||||
) => (
|
||||
axios.post(endpoint, 'data=' + query)
|
||||
axios.post(endpoint, `data=${query}`)
|
||||
.then(res => res.data)
|
||||
);
|
||||
|
||||
exports.runQuery = runQuery;
|
||||
|
||||
/**
|
||||
* Create a link to add tags into JOSM.
|
||||
*
|
||||
* The JOSM remote control must be activated and JOSM must be running for this
|
||||
* link to work. See <https://wiki.openstreetmap.org/wiki/JOSM/RemoteControl>.
|
||||
*
|
||||
* @param id Identifier for the object to add the tags to.
|
||||
* @param tags List of tags to add, in the `key=value` format.
|
||||
* @return Link for remotely adding the tags.
|
||||
*/
|
||||
const addTagsToNode = (id, tags) => (
|
||||
'http://127.0.0.1:8111/load_object?' + [
|
||||
`objects=n${id}`,
|
||||
'new_layer=false',
|
||||
'addtags=' + tags.join('%7C'),
|
||||
].join('&')
|
||||
);
|
||||
|
||||
exports.addTagsToNode = addTagsToNode;
|
||||
|
||||
/**
|
||||
* Create a link to view a node.
|
||||
*
|
||||
* @param id Identifier for the node to view.
|
||||
* @return Link to view this node on the OSM website.
|
||||
* @param {string|number} id Identifier for the node to view.
|
||||
* @returns {string} Link to view this node on the OSM website.
|
||||
*/
|
||||
const viewNode = id => `https://www.openstreetmap.org/node/${id}`;
|
||||
const viewNode = id => `https://www.osm.org/node/${id}`;
|
||||
|
||||
exports.viewNode = viewNode;
|
||||
|
||||
/**
|
||||
* Determine if an OSM way is one-way or not.
|
||||
*
|
||||
* See <https://wiki.openstreetmap.org/wiki/Key:oneway> for details.
|
||||
*
|
||||
* @param tags Set of tags of the way.
|
||||
* @return True iff. the way is one-way.
|
||||
* See <https://wiki.osm.org/Key:oneway> for details.
|
||||
* @param {Object} obj OSM way object.
|
||||
* @returns {boolean} Whether the way is one-way.
|
||||
*/
|
||||
const isOneWay = object => (
|
||||
object.type === 'way'
|
||||
&& isObject(object.tags)
|
||||
&& (object.tags.oneway === 'yes' || object.tags.junction === 'roundabout'
|
||||
|| object.tags.highway === 'motorway')
|
||||
const isOneWay = obj => (
|
||||
obj.type === "way" &&
|
||||
isObject(obj.tags) &&
|
||||
(obj.tags.oneway === "yes" || obj.tags.junction === "roundabout" ||
|
||||
obj.tags.highway === "motorway")
|
||||
);
|
||||
|
||||
exports.isOneWay = isOneWay;
|
||||
|
@ -79,16 +57,15 @@ exports.isOneWay = isOneWay;
|
|||
/**
|
||||
* Determine if an OSM object is a public transport line (route master).
|
||||
*
|
||||
* See <https://wiki.openstreetmap.org/wiki/Relation:route_master>
|
||||
* and <https://wiki.openstreetmap.org/wiki/Public_transport#Route_Master_relations>.
|
||||
*
|
||||
* @param object OSM object.
|
||||
* @return True iff. the relation is a public transport line.
|
||||
* See <https://wiki.osm.org/Relation:route_master>
|
||||
* and <https://wiki.osm.org/Public_transport#Route_Master_relations>.
|
||||
* @param {Object} obj OSM relation object.
|
||||
* @returns {boolean} Whether the relation is a public transport line.
|
||||
*/
|
||||
const isTransportLine = object => (
|
||||
object.type === 'relation'
|
||||
&& isObject(object.tags)
|
||||
&& object.tags.type === 'route_master'
|
||||
const isTransportLine = obj => (
|
||||
obj.type === "relation" &&
|
||||
isObject(obj.tags) &&
|
||||
obj.tags.type === "route_master"
|
||||
);
|
||||
|
||||
exports.isTransportLine = isTransportLine;
|
||||
|
|
|
@ -1,123 +1,104 @@
|
|||
const unzip = require('unzip-stream');
|
||||
const csv = require('csv-parse');
|
||||
const axios = require('axios');
|
||||
const csv = require("csv-parse");
|
||||
const axios = require("axios");
|
||||
const { snakeToCamelCase, unzipFile } = require("../../util");
|
||||
|
||||
/**
|
||||
* Process a CSV stream to extract passings.
|
||||
* Data available for each passing of a vehicle at a station.
|
||||
*
|
||||
* @private
|
||||
* @param csvStream Stream containing CSV data.
|
||||
* @param callback See fetchRealtime for a description of the callback.
|
||||
* See also <http://data.montpellier3m.fr/node/10733/download>.
|
||||
* @typedef {Object} Passing
|
||||
* @property {string} course Identifier of the overall trip of the same vehicle
|
||||
* from one end of the route to another (unique for the day).
|
||||
* @property {string} stopCode Unused internal stop identifier.
|
||||
* @property {string} stopId Unique network identifier for the station at
|
||||
* which the vehicle will pass (same id as in GTFS).
|
||||
* @property {string} routeShortName Transport line number.
|
||||
* @property {string} tripHeadsign Name of the final stop of this trip.
|
||||
* @property {string} directionId Route identifier inside the line.
|
||||
* @property {string} departureTime Theoretical time at which the
|
||||
* vehicle will depart the stop (HH:MM:SS format).
|
||||
* @property {string} isTheorical (sic) Whether the arrival time is only
|
||||
* a theoretical information.
|
||||
* @property {string} delaySec Number of seconds before the vehicle arrives
|
||||
* at the station.
|
||||
* @property {string} destArCode Unique network identifier for the final
|
||||
* stop of this trip.
|
||||
*/
|
||||
const processTamPassingStream = (csvStream, callback) =>
|
||||
{
|
||||
const parser = csv({
|
||||
delimiter: ';',
|
||||
});
|
||||
|
||||
const rowStream = csvStream.pipe(parser);
|
||||
|
||||
rowStream.on('readable', () =>
|
||||
{
|
||||
let row;
|
||||
|
||||
while ((row = rowStream.read()))
|
||||
{
|
||||
if (row.length === 0 || row[0] === 'course')
|
||||
{
|
||||
// Ignore les lignes invalides et l’en-tête
|
||||
continue;
|
||||
}
|
||||
|
||||
callback(null, {
|
||||
course: row[0],
|
||||
stopCode: row[1],
|
||||
stopId: row[2],
|
||||
stopName: row[3],
|
||||
routeShortName: row[4],
|
||||
tripHeadsign: row[5],
|
||||
directionId: row[6],
|
||||
departureTime: row[7],
|
||||
isTheorical: row[8],
|
||||
delaySec: row[9],
|
||||
destArCode: row[10],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
rowStream.on('end', () => callback(null, null));
|
||||
rowStream.on('error', err => callback(err));
|
||||
};
|
||||
|
||||
const tamRealtimeEndpoint = 'http://data.montpellier3m.fr/node/10732/download';
|
||||
const realtimeEndpoint = "http://data.montpellier3m.fr/node/10732/download";
|
||||
|
||||
/**
|
||||
* Fetch realtime passings across the network.
|
||||
*
|
||||
* The callback always receives two arguments. If an error occurs, the first
|
||||
* argument will contain an object with information about the error. Otherwise,
|
||||
* it will be null and the second argument will be the payload.
|
||||
*
|
||||
* The first call will provide metadata, specifically the time at which
|
||||
* the data that follows was last updated (`lastUpdate`) and the time at
|
||||
* which it will be updated next (`nextUpdate`).
|
||||
*
|
||||
* Following calls will provide each passing of the dataset individually,
|
||||
* and will be closed with a call where both arguments are null.
|
||||
*
|
||||
* @param callback Called for each passing during parsing.
|
||||
* Fetch real time passings of vehicles across the network.
|
||||
* @yields {{{lastUpdate: number, nextUpdate: number}|Passing}} First value
|
||||
* is an object containing the time of last update and the time of next
|
||||
* update of this information. Next values are informations about each vehicle
|
||||
* passing.
|
||||
*/
|
||||
const fetchRealtime = callback =>
|
||||
{
|
||||
axios.get(tamRealtimeEndpoint, {
|
||||
responseType: 'stream'
|
||||
}).then(res =>
|
||||
{
|
||||
const lastUpdate = new Date(res.headers['last-modified']).getTime();
|
||||
const fetchRealtime = async function *() {
|
||||
const res = await axios.get(realtimeEndpoint, {
|
||||
responseType: "stream"
|
||||
});
|
||||
|
||||
// Data is advertised as being updated every minute. Add a small
|
||||
// margin to account for potential delays
|
||||
const lastUpdate = new Date(res.headers["last-modified"]).getTime();
|
||||
const nextUpdate = lastUpdate + 65 * 1000;
|
||||
|
||||
callback(null, {lastUpdate, nextUpdate});
|
||||
processTamPassingStream(res.data, callback);
|
||||
}).catch(err => callback(err));
|
||||
yield { lastUpdate, nextUpdate };
|
||||
|
||||
const parser = res.data.pipe(csv({
|
||||
delimiter: ";",
|
||||
columns: header => header.map(snakeToCamelCase)
|
||||
}));
|
||||
|
||||
for await (const passing of parser) {
|
||||
yield passing;
|
||||
}
|
||||
};
|
||||
|
||||
exports.fetchRealtime = fetchRealtime;
|
||||
|
||||
const tamTheoreticalEndpoint =
|
||||
'http://data.montpellier3m.fr/node/10731/download';
|
||||
const tamTheoreticalFileName = 'offre_du_jour.csv';
|
||||
const theoreticalEndpoint = "http://data.montpellier3m.fr/node/10731/download";
|
||||
|
||||
/**
|
||||
* Fetch theoretical passings for the current day across the network.
|
||||
*
|
||||
* @param callback Called for each passing during parsing. First argument will
|
||||
* be non-null only if an error occurred. Second argument will contain passings
|
||||
* or be null if the end was reached.
|
||||
* @yields {{{lastUpdate: number, nextUpdate: number}|Passing}} First value
|
||||
* is an object containing the time of last update and the time of next
|
||||
* update of this information. Next values are informations about each vehicle
|
||||
* passing.
|
||||
*/
|
||||
const fetchTheoretical = callback =>
|
||||
{
|
||||
axios.get(tamTheoreticalEndpoint, {
|
||||
responseType: 'stream'
|
||||
}).then(res =>
|
||||
{
|
||||
const fileStream = res.data.pipe(unzip.Parse());
|
||||
const fetchTheoretical = async function *() {
|
||||
const res = await axios.get(theoreticalEndpoint, {
|
||||
responseType: "stream"
|
||||
});
|
||||
|
||||
fileStream.on('entry', entry =>
|
||||
{
|
||||
if (entry.type !== 'File' || entry.path !== tamTheoreticalFileName)
|
||||
{
|
||||
entry.autodrain();
|
||||
return;
|
||||
const lastUpdate = new Date();
|
||||
|
||||
if (lastUpdate.getHours() < 4) {
|
||||
lastUpdate.setDate(lastUpdate.getDate() - 1);
|
||||
}
|
||||
|
||||
processTamPassingStream(entry, callback);
|
||||
});
|
||||
lastUpdate.setHours(4);
|
||||
lastUpdate.setMinutes(0);
|
||||
lastUpdate.setSeconds(0);
|
||||
lastUpdate.setMilliseconds(0);
|
||||
|
||||
fileStream.on('error', err => callback(err));
|
||||
});
|
||||
const nextUpdate = new Date(lastUpdate);
|
||||
|
||||
nextUpdate.setDate(nextUpdate.getDate() + 1);
|
||||
|
||||
yield {
|
||||
lastUpdate: lastUpdate.getTime(),
|
||||
nextUpdate: nextUpdate.getTime()
|
||||
};
|
||||
|
||||
const stream = await unzipFile(res.data, "offre_du_jour.csv");
|
||||
const parser = stream.pipe(csv({
|
||||
delimiter: ";",
|
||||
columns: header => header.map(snakeToCamelCase)
|
||||
}));
|
||||
|
||||
for await (const passing of parser) {
|
||||
yield passing;
|
||||
}
|
||||
};
|
||||
|
||||
exports.fetchTheoretical = fetchTheoretical;
|
||||
|
|
115
src/util.js
115
src/util.js
|
@ -1,86 +1,65 @@
|
|||
/**
|
||||
* Choose between singular or plural form based on the number of elements.
|
||||
*
|
||||
* @example
|
||||
* > choosePlural(1, 'example', '.s')
|
||||
* 'example'
|
||||
* > choosePlural(4, 'example', '.s')
|
||||
* 'examples'
|
||||
* > choosePlural(0, 'radius', 'radii')
|
||||
* 'radii'
|
||||
*
|
||||
* @param count Number of elements.
|
||||
* @param singular Singular form.
|
||||
* @param plural Plural form. An initial dot will be replaced by `singular`.
|
||||
* @return Appropriate form.
|
||||
*/
|
||||
const choosePlural = (count, singular, plural) =>
|
||||
{
|
||||
if (count === 1)
|
||||
{
|
||||
return singular;
|
||||
}
|
||||
else
|
||||
{
|
||||
return plural.replace(/^\./, singular);
|
||||
}
|
||||
};
|
||||
|
||||
exports.choosePlural = choosePlural;
|
||||
const unzip = require("unzip-stream");
|
||||
|
||||
/**
|
||||
* Join elements with the given separator and a special separator for the last
|
||||
* element.
|
||||
*
|
||||
* @example
|
||||
* > joinSentence(['apple', 'orange', 'banana'], ', ', ' and ')
|
||||
* 'apple, orange and banana'
|
||||
* > joinSentence(['apple', 'banana'], ', ', ' and ')
|
||||
* 'apple and banana'
|
||||
* > joinSentence(['banana'], ', ', ' and ')
|
||||
* 'banana'
|
||||
*
|
||||
* @param array Sequence of strings to join.
|
||||
* @param separator Separator for all elements but the last one.
|
||||
* @param lastSeparator Separator for the last element.
|
||||
* @return Joined string.
|
||||
* Convert a snake-cased string to a camel-cased one.
|
||||
* @param {string} str Original string.
|
||||
* @returns {string} Transformed string.
|
||||
*/
|
||||
const joinSentence = (array, separator, lastSeparator) =>
|
||||
{
|
||||
if (array.length <= 2)
|
||||
{
|
||||
return array.join(lastSeparator);
|
||||
}
|
||||
const snakeToCamelCase = str => str.replace(/([-_][a-z])/gu, group =>
|
||||
group.toUpperCase().replace("-", "").replace("_", ""));
|
||||
|
||||
return (
|
||||
array.slice(0, -1).join(separator)
|
||||
+ lastSeparator
|
||||
+ array[array.length - 1]
|
||||
);
|
||||
};
|
||||
|
||||
exports.joinSentence = joinSentence;
|
||||
exports.snakeToCamelCase = snakeToCamelCase;
|
||||
|
||||
/**
|
||||
* Check if a value is a JS object.
|
||||
*
|
||||
* @param value Value to check.
|
||||
* @return True iff. `value` is a JS object.
|
||||
* @param {*} value Value to check.
|
||||
* @returns {boolean} Whether `value` is a JS object.
|
||||
*/
|
||||
const isObject = value => value !== null && typeof value === 'object';
|
||||
const isObject = value => value !== null && typeof value === "object";
|
||||
|
||||
exports.isObject = isObject;
|
||||
|
||||
/**
|
||||
* Check if two arrays are equal in a shallow manner.
|
||||
*
|
||||
* @param array1 First array.
|
||||
* @param array2 Second array.
|
||||
* @return True iff. the two arrays are equal.
|
||||
* @param {Array} array1 First array.
|
||||
* @param {Array} array2 Second array.
|
||||
* @returns {boolean} Whether the two arrays are equal.
|
||||
*/
|
||||
const arraysEqual = (array1, array2) => (
|
||||
array1.length === array2.length
|
||||
&& array1.every((elt1, index) => elt1 === array2[index])
|
||||
array1.length === array2.length &&
|
||||
array1.every((elt1, index) => elt1 === array2[index])
|
||||
);
|
||||
|
||||
exports.arraysEqual = arraysEqual;
|
||||
|
||||
/**
|
||||
* Find a file in a zipped stream and unzip it.
|
||||
* @param {stream.Readable} data Input zipped stream.
|
||||
* @param {string} fileName Name of the file to find.
|
||||
* @returns {Promise.<stream.Readable>} Stream of the unzipped file.
|
||||
*/
|
||||
const unzipFile = (data, fileName) => new Promise((res, rej) => {
|
||||
// eslint-disable-next-line new-cap
|
||||
const stream = data.pipe(unzip.Parse());
|
||||
let found = false;
|
||||
|
||||
stream.on("entry", entry => {
|
||||
if (entry.type !== "File" || entry.path !== fileName) {
|
||||
entry.autodrain();
|
||||
return;
|
||||
}
|
||||
|
||||
found = true;
|
||||
res(entry);
|
||||
});
|
||||
|
||||
stream.on("end", () => {
|
||||
if (!found) {
|
||||
rej(new Error(`File ${fileName} not found in archive`));
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("error", err => rej(err));
|
||||
});
|
||||
|
||||
exports.unzipFile = unzipFile;
|
||||
|
|
Loading…
Reference in New Issue