Code cleanup: Update ESLint conf, improve internal passings interface

This commit is contained in:
Mattéo Delabre 2020-07-25 18:05:43 +02:00
parent 35cd0e1e72
commit 079fbcf310
Signed by: matteo
GPG Key ID: AE3FBD02DC583ABB
14 changed files with 660 additions and 1506 deletions

View File

@ -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"
}
}

1005
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View File

@ -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}`));

View File

@ -1,6 +1,5 @@
{
"env": {
"browser": true,
"commonjs": true
"browser": true
}
}

View File

@ -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");

View File

@ -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 layers 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 layers 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;
}

5
src/tam/.eslintrc.json Normal file
View File

@ -0,0 +1,5 @@
{
"env": {
"commonjs": true
}
}

View File

@ -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 routes 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;

View File

@ -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;

View File

@ -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 were 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) {
// Were 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;

View File

@ -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;

View File

@ -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 len-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;

View File

@ -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 JSobject.
* @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;