422 lines
13 KiB
JavaScript
422 lines
13 KiB
JavaScript
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 don’t 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 line’s 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 route’s path through its stops
|
||
// with each way connected to the next through a single point
|
||
const ways = route.members.slice(relationPivot)
|
||
.map(({ref}) => ref);
|
||
|
||
// 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;
|