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": [ "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"
}
]
} }
} }

1005
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -1,6 +1,5 @@
{ {
"env": { "env": {
"browser": true, "browser": true
"commonjs": 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'); const { createMap } = require("./map");
createMap(/* map = */ '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 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 layers vector context is through the // 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` // 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 layers vector context from internal variables // 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.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;
} }

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): * 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 routes path through its stops // List of ways making up the routes 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;

View File

@ -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,99 +7,78 @@ 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) const courses = {};
{ const passings = tam.fetchRealtime();
res(currentCourses); const timing = (await passings.next()).value;
return;
}
const courses = {}; nextUpdate = timing.nextUpdate;
let lastUpdate = null;
tam.fetchRealtime((err, entry) => // Aggregate passings relative to the same course
{ for await (const passing of passings) {
if (err) const {
{ course: id,
rej(err); routeShortName: line,
return; 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 (const courseId of Object.keys(courses)) {
// Filter courses to only keep those referring to known data const course = courses[courseId];
for (let courseId of Object.keys(courses))
{
const course = courses[courseId];
if (!(course.line in network.lines)) if (!(course.line in network.lines)) {
{ delete courses[courseId];
delete courses[courseId]; } else {
} for (const stopId of Object.keys(course.nextPassings)) {
else if (!(stopId in network.stops)) {
{ delete courses[courseId];
for (let stopId of Object.keys(course.nextPassings)) break;
{
if (!(stopId in network.stops))
{
delete courses[courseId];
break;
}
} }
} }
} }
currentCourses = courses;
res(currentCourses);
return;
} }
if ('lastUpdate' in entry) currentCourses = courses;
{ }
// Metadata header
lastUpdate = entry.lastUpdate;
nextUpdate = entry.nextUpdate;
return;
}
const { return currentCourses;
course: id, };
routeShortName: line,
stopId,
destArCode: finalStop,
} = entry;
const arrivalTime = lastUpdate + parseInt(entry.delaySec, 10) * 1000; exports.fetch = fetch;
if (!(id in courses))
{
courses[id] = {
id, line, finalStop,
nextPassings: {[stopId]: arrivalTime},
};
}
else
{
courses[id].nextPassings[stopId] = arrivalTime;
}
});
});
exports.getCourses = getCourses;

View File

@ -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 were on the right `stopped`/`moving` state // Make sure were 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) {
{
// Were late, go to maximum speed // Were 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;

View File

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

View File

@ -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 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';
/** /**
* 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 =>
{
if (entry.type !== 'File' || entry.path !== tamTheoreticalFileName)
{
entry.autodrain();
return;
}
processTamPassingStream(entry, callback);
});
fileStream.on('error', err => callback(err));
}); });
const lastUpdate = new Date();
if (lastUpdate.getHours() < 4) {
lastUpdate.setDate(lastUpdate.getDate() - 1);
}
lastUpdate.setHours(4);
lastUpdate.setMinutes(0);
lastUpdate.setSeconds(0);
lastUpdate.setMilliseconds(0);
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;

View File

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