tracktracker/back/data/network.js

422 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const geolib = require('geolib');
const {choosePlural, joinSentence} = require('../util');
const {queryOverpass, fetchTamTheoretical} = require('./endpoints');
const osmViewNode = 'https://www.openstreetmap.org/node';
/**
* Create a link to remotely add tags into JOSM.
*
* @param objectId Identifier for the object to add the tags to.
* @param tags Tags to add.
* @return Link for remotely adding the tags.
*/
const josmAddTagToNode = (objectId, tags) =>
'http://127.0.0.1:8111/load_object?' + [
`objects=n${objectId}`,
'new_layer=false',
'addtags=' + tags.join('%7C'),
].join('&');
/**
* Use theoretical passings data to guess which lines use which stops in which
* direction.
*
* This is used for suggesting possible stop IDs for stops that dont have
* one in OSM.
*
* @return Map containing for each stop its abbreviated name, the lines that
* use it and in which directions it is used.
*/
const fetchStopsRefAssociations = () => new Promise((res, rej) =>
{
const stops = {};
fetchTamTheoretical((err, row) =>
{
if (err)
{
rej(err);
return;
}
if (!row)
{
res(stops);
return;
}
let line = row.routeShortName;
if (line === '4')
{
line += row.directionId === '0' ? 'A' : 'B';
}
if (!(row.stopId in stops))
{
stops[row.stopId] = {
name: row.stopName,
lines: new Set([line]),
directions: new Set([row.tripHeadsign]),
};
}
else
{
const stop = stops[row.stopId];
if (stop.name !== row.stopName)
{
console.warn(`Stop ${row.stopId} has multiple names: \
${row.stopName}” and “${stop.name}”. Only the first one will be considered.`);
}
stop.lines.add(line);
stop.directions.add(row.tripHeadsign);
}
});
});
// Mapping for abbreviations used in stop names
const stopAbbreviations = {
st: 'saint',
};
/**
* Convert a stop name to a canonical representation suitable for
* comparing two names.
*
* @param stopName Original stop name.
* @return List of normalized tokens in the name.
*/
const canonicalizeStopName = stopName => stopName
.toLowerCase()
// Remove diacritics
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
// Only keep alpha-numeric characters
.replace(/[^a-z0-9]/g, ' ')
// Split in tokens longer than two characters
.split(/\s+/g).filter(part => part.length >= 2)
// Resolve well-known abbreviations
.map(part => part in stopAbbreviations ? stopAbbreviations[part] : part);
/**
* Compute a matching score between two stop names.
*
* @param fullName Stop name in full.
* @param abbrName Abbreviated stop name.
* @return Matching score (number of common tokens).
*/
const matchStopNames = (fullName, abbrName) =>
{
const canonicalFullName = canonicalizeStopName(fullName);
const canonicalAbbrName = canonicalizeStopName(abbrName);
return canonicalFullName.filter(part =>
canonicalAbbrName.findIndex(abbrPart =>
part.startsWith(abbrPart)
) !== -1
).length;
};
/**
* Determine if an OSM way is oneway or not.
*
* @param tags Set of tags of the way.
* @return True iff. the way is oneway.
*/
const isOneWay = tags =>
('oneway' in tags && tags.oneway === 'yes')
|| ('junction' in tags && tags.junction === 'roundabout')
|| ('highway' in tags && tags.highway === 'motorway');
/**
* Fetch stops, segments and lines in the network.
*
* @param lineRefs List of lines to fetch.
* @return Object with a set of stops, segments and lines.
*/
const fetch = async (lineRefs) =>
{
// Retrieve routes, ways and stops from OpenStreetMap
const rawData = await queryOverpass(`[out:json];
// Find the public transport line bearing the requested reference
relation[network="TaM"][type="route_master"][ref~"^(${lineRefs.join('|')})$"];
// Recursively fetch routes, ways and stops inside the line
(._; >>;);
out body qt;
`);
// Retrieve stop associations from TaM
const associations = await fetchStopsRefAssociations();
// List of retrieved objects
const elementsList = JSON.parse(rawData).elements;
// List of retrieved lines
const routeMasters = elementsList.filter(elt =>
elt.tags && elt.tags.type === 'route_master'
);
// Retrieved objects indexed by ID
const elements = elementsList.reduce((prev, elt) =>
{
prev[elt.id] = elt;
return prev;
}, {});
// All stops in the network
const stops = {};
// All transport lines of the network
const lines = {};
for (let routeMaster of routeMasters)
{
const lineRef = routeMaster.tags.ref;
const color = routeMaster.tags.colour || '#000000';
// Extract all routes for the given line
const rawRoutes = routeMaster.members.map(({ref}) => elements[ref]);
// Add missing stops to the result object
for (let route of rawRoutes)
{
for (let {ref, role} of route.members)
{
if (role === 'stop')
{
const stop = elements[ref];
if (!('ref' in stop.tags))
{
console.warn(`Stop ${stop.id} is missing a “ref” tag
Name: ${stop.tags.name}
Part of line: ${route.tags.name}
URI: ${osmViewNode}/${stop.id}
`);
// Try to identify stops matching this stop in the
// TaM-provided data, using the stop name, line number
// and trip direction
const candidates = Object.entries(associations).filter(
([, {lines}]) => lines.has(route.tags.ref)
).map(([stopRef, {name, lines, directions}]) => ({
stopRef,
lines,
name,
nameScore: matchStopNames(stop.tags.name, name),
directions,
directionScore: Math.max(
...Array.from(directions).map(direction =>
matchStopNames(route.tags.to, direction)
)
),
}))
// Only keep non-zero scores for both criteria
.filter(({nameScore, directionScore}) =>
nameScore && directionScore
)
// Sort by best name score then best direction
.sort(({
nameScore: nameScore1,
directionScore: directionScore1,
}, {
nameScore: nameScore2,
directionScore: directionScore2,
}) =>
(nameScore2 - nameScore1)
|| (directionScore2 - directionScore1)
)
.slice(0, 4);
if (candidates.length === 0)
{
console.warn('No candidate found in TaM data.');
}
else
{
console.warn('Candidates:');
for (let candidate of candidates)
{
console.warn(`\
— Stop ${candidate.stopRef} with name “${candidate.name}” used by \
${choosePlural(candidate.lines.length, 'line', '.s')} \
${joinSentence(Array.from(candidate.lines), ', ', ' and ')} going to \
${joinSentence(Array.from(candidate.directions), ', ', ' or ')}
Apply in JOSM: ${josmAddTagToNode(stop.id, ['ref=' + candidate.stopRef])}
`);
}
}
console.warn('');
}
if (!(stop.tags.ref in stops))
{
stops[stop.tags.ref] = {
lat: stop.lat,
lon: stop.lon,
name: stop.tags.name,
lines: new Set([lineRef]),
};
}
else
{
stops[stop.tags.ref].lines.add(lineRef);
}
}
}
}
// Reconstruct the lines route from stop to stop
const routes = [];
for (let route of rawRoutes)
{
const {from, to} = route.tags;
// Human-readable description of the route for errors
const routeDescription = `line ${lineRef}s route from \
${route.tags.from}” to “${route.tags.to}`;
// 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 === ''
);
if (!route.members.slice(0, relationPivot).every(
({role}) => role === 'stop' || role === 'platform'
))
{
throw new Error(`Members with invalid roles in between stops
of ${routeDescription}`);
}
if (!route.members.slice(relationPivot).every(
({role}) => role === ''
))
{
throw new Error(`Members with invalid roles inside the path
of ${routeDescription}`);
}
// List of stops in the route, expected to be in the timetable
// order as per PTv2 and to be traversed in order by the sequence
// of ways extracted below
const stops = route.members.slice(0, relationPivot)
.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);
// Merge all used ways in a single path
let path = [];
let currentNode = stops[0];
for (let wayIndex = 0; wayIndex < ways.length; wayIndex += 1)
{
const {nodes: wayNodes, tags: wayTags}
= elements[ways[wayIndex]];
const wayNodesSet = new Set(wayNodes);
const curNodeIndex = wayNodes.indexOf(currentNode);
// If not the last way, find a connection point to the next way
// (there should be exactly one)
let nextNode = null;
let nextNodeIndex = null;
if (wayIndex + 1 < ways.length)
{
const nextNodeCandidates = elements[ways[wayIndex + 1]]
.nodes.filter(node => wayNodesSet.has(node));
if (nextNodeCandidates.length !== 1)
{
throw new Error(`There should be exactly one point
connecting way n°${wayIndex} and way n°${wayIndex + 1} in ${routeDescription},
but there are ${nextNodeCandidates.length}`);
}
nextNode = nextNodeCandidates[0];
nextNodeIndex = wayNodes.indexOf(nextNode);
}
else
{
nextNodeIndex = wayNodes.length;
}
if (curNodeIndex < nextNodeIndex)
{
// Use the way in its normal direction
path = path.concat(
wayNodes.slice(curNodeIndex, nextNodeIndex)
);
}
else
{
// Use the way in the reverse direction
if (isOneWay(wayTags))
{
throw new Error(`Way n°${wayIndex} in
${routeDescription} is one-way and cannot be used in reverse.`);
}
path = path.concat(
wayNodes.slice(nextNodeIndex + 1, curNodeIndex + 1)
.reverse()
);
}
currentNode = nextNode;
}
// Split the path into segments between stops
const segments = [];
for (let stopIndex = 0; stopIndex + 1 < stops.length; ++stopIndex)
{
segments.push(path.slice(
path.indexOf(stops[stopIndex]),
path.indexOf(stops[stopIndex + 1] + 1),
).map(id => ({
lat: elements[id].lat,
lon: elements[id].lon,
})));
}
routes.push({
from, to,
name: route.tags.name,
segments,
stops: stops.map(id => elements[id].tags.ref),
});
}
lines[lineRef] = {
color,
routes,
};
}
return {stops, lines};
};
exports.fetch = fetch;