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