2020-01-14 13:08:08 +00:00
|
|
|
|
const geolib = require('geolib');
|
|
|
|
|
|
2020-01-14 23:19:26 +00:00
|
|
|
|
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',
|
2020-01-15 23:34:47 +00:00
|
|
|
|
'addtags=' + tags.join('%7C'),
|
2020-01-14 23:19:26 +00:00
|
|
|
|
].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;
|
|
|
|
|
}
|
2020-01-14 13:08:08 +00:00
|
|
|
|
|
2020-01-14 23:19:26 +00:00
|
|
|
|
if (!row)
|
|
|
|
|
{
|
|
|
|
|
res(stops);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2020-01-15 17:17:49 +00:00
|
|
|
|
let line = row.routeShortName;
|
|
|
|
|
|
|
|
|
|
if (line === '4')
|
|
|
|
|
{
|
|
|
|
|
line += row.directionId === '0' ? 'A' : 'B';
|
|
|
|
|
}
|
|
|
|
|
|
2020-01-14 23:19:26 +00:00
|
|
|
|
if (!(row.stopId in stops))
|
|
|
|
|
{
|
|
|
|
|
stops[row.stopId] = {
|
|
|
|
|
name: row.stopName,
|
2020-01-15 17:17:49 +00:00
|
|
|
|
lines: new Set([line]),
|
2020-01-14 23:19:26 +00:00
|
|
|
|
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.`);
|
|
|
|
|
}
|
2020-01-14 13:08:08 +00:00
|
|
|
|
|
2020-01-15 17:17:49 +00:00
|
|
|
|
stop.lines.add(line);
|
2020-01-14 23:19:26 +00:00
|
|
|
|
stop.directions.add(row.tripHeadsign);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Mapping for abbreviations used in stop names
|
|
|
|
|
const stopAbbreviations = {
|
2020-01-15 23:34:47 +00:00
|
|
|
|
st: 'saint',
|
2020-01-14 23:19:26 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
2020-01-15 23:34:47 +00:00
|
|
|
|
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
2020-01-14 23:19:26 +00:00
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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) =>
|
2020-01-14 13:08:08 +00:00
|
|
|
|
{
|
|
|
|
|
// Retrieve routes, ways and stops from OpenStreetMap
|
2020-01-14 23:19:26 +00:00
|
|
|
|
const rawData = await queryOverpass(`[out:json];
|
2020-01-14 13:08:08 +00:00
|
|
|
|
|
|
|
|
|
// 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;
|
2020-01-14 23:19:26 +00:00
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
// Retrieve stop associations from TaM
|
|
|
|
|
const associations = await fetchStopsRefAssociations();
|
2020-01-14 13:08:08 +00:00
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
}, {});
|
|
|
|
|
|
|
|
|
|
// Result object containing all stops
|
|
|
|
|
const stops = {};
|
|
|
|
|
|
|
|
|
|
// Result object containing all segments between stops
|
|
|
|
|
const segments = {};
|
|
|
|
|
|
|
|
|
|
// Result object containing all lines
|
|
|
|
|
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))
|
|
|
|
|
{
|
2020-01-14 23:19:26 +00:00
|
|
|
|
console.warn(`Stop ${stop.id} is missing a “ref” tag
|
|
|
|
|
Name: ${stop.tags.name}
|
2020-01-15 17:17:49 +00:00
|
|
|
|
Part of line: ${route.tags.name}
|
2020-01-14 23:19:26 +00:00
|
|
|
|
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(
|
2020-01-15 23:34:47 +00:00
|
|
|
|
([, {lines}]) => lines.has(route.tags.ref)
|
2020-01-14 23:19:26 +00:00
|
|
|
|
).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,
|
2020-01-15 23:34:47 +00:00
|
|
|
|
directionScore: directionScore1,
|
2020-01-14 23:19:26 +00:00
|
|
|
|
}, {
|
|
|
|
|
nameScore: nameScore2,
|
2020-01-15 23:34:47 +00:00
|
|
|
|
directionScore: directionScore2,
|
2020-01-14 23:19:26 +00:00
|
|
|
|
}) =>
|
|
|
|
|
(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('');
|
2020-01-14 13:08:08 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!(stop.tags.ref in stops))
|
|
|
|
|
{
|
|
|
|
|
stops[stop.tags.ref] = {
|
|
|
|
|
lat: stop.lat,
|
|
|
|
|
lon: stop.lon,
|
|
|
|
|
name: stop.tags.name,
|
|
|
|
|
lines: [lineRef],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
stops[stop.tags.ref].lines.push(lineRef);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add missing segments between stops
|
|
|
|
|
for (let route of rawRoutes)
|
|
|
|
|
{
|
|
|
|
|
const {from, to} = route.tags;
|
|
|
|
|
|
|
|
|
|
const stops = route.members
|
|
|
|
|
.filter(({role}) => role === 'stop')
|
|
|
|
|
.map(({ref}) => elements[ref])
|
|
|
|
|
.filter(stop => 'ref' in stop.tags)
|
|
|
|
|
.map(stop => ({
|
|
|
|
|
id: stop.id,
|
|
|
|
|
lat: stop.lat,
|
|
|
|
|
lon: stop.lon,
|
|
|
|
|
ref: stop.tags.ref,
|
|
|
|
|
name: stop.tags.name,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const ways = route.members
|
|
|
|
|
.filter(({role}) => role === '')
|
|
|
|
|
.map(({ref}) => ref);
|
|
|
|
|
|
|
|
|
|
// Construct a graph with all connected nodes
|
|
|
|
|
const nodeNeighbors = new Map();
|
|
|
|
|
|
|
|
|
|
for (let id of ways)
|
|
|
|
|
{
|
|
|
|
|
const {type, nodes, tags} = elements[id];
|
|
|
|
|
|
|
|
|
|
const isOneWay = (
|
|
|
|
|
tags.oneway === 'yes'
|
|
|
|
|
|| tags.junction === 'roundabout'
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const canGoBackward = (
|
|
|
|
|
!isOneWay
|
|
|
|
|
|| parseInt(tags['lanes:psv:backward'], 10) > 0
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (type === 'way' && nodes.length >= 1)
|
|
|
|
|
{
|
|
|
|
|
let previousNode = nodes[0];
|
|
|
|
|
|
|
|
|
|
if (!nodeNeighbors.has(previousNode))
|
|
|
|
|
{
|
|
|
|
|
nodeNeighbors.set(previousNode, new Set());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (let node of nodes.slice(1))
|
|
|
|
|
{
|
|
|
|
|
if (!nodeNeighbors.has(node))
|
|
|
|
|
{
|
|
|
|
|
nodeNeighbors.set(node, new Set());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
nodeNeighbors.get(previousNode).add(node);
|
|
|
|
|
|
|
|
|
|
if (canGoBackward)
|
|
|
|
|
{
|
|
|
|
|
nodeNeighbors.get(node).add(previousNode);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
previousNode = node;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Find way from first stop through the end using DFS
|
|
|
|
|
const numberOfStops = stops.length;
|
|
|
|
|
let currentStopIndex = 0;
|
|
|
|
|
|
|
|
|
|
while (currentStopIndex + 1 < numberOfStops)
|
|
|
|
|
{
|
|
|
|
|
const currentStop = stops[currentStopIndex];
|
|
|
|
|
const nextStop = stops[currentStopIndex + 1];
|
|
|
|
|
const segmentId = `${currentStop.ref}-${nextStop.ref}`;
|
|
|
|
|
|
|
|
|
|
if (!(segmentId in segments))
|
|
|
|
|
{
|
|
|
|
|
const visitedEdges = new Set();
|
|
|
|
|
const stack = [{
|
|
|
|
|
currentNode: currentStop.id,
|
|
|
|
|
segment: [currentStop.id],
|
|
|
|
|
}];
|
|
|
|
|
|
|
|
|
|
let found = false;
|
|
|
|
|
|
|
|
|
|
while (stack.length !== 0)
|
|
|
|
|
{
|
|
|
|
|
const {currentNode, segment} = stack.pop();
|
|
|
|
|
|
|
|
|
|
if (currentNode === nextStop.id)
|
|
|
|
|
{
|
|
|
|
|
// Arrived at next stop
|
|
|
|
|
segments[segmentId] = {
|
|
|
|
|
nodes: segment.map(id =>
|
|
|
|
|
{
|
|
|
|
|
const {lat, lon} = elements[id];
|
|
|
|
|
return {lat, lon};
|
|
|
|
|
}),
|
|
|
|
|
length: geolib.getPathLength(segment.map(id =>
|
|
|
|
|
{
|
|
|
|
|
const {lat, lon} = elements[id];
|
|
|
|
|
return {latitude: lat, longitude: lon};
|
|
|
|
|
})),
|
|
|
|
|
lines: [lineRef],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
found = true;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const neighbors = nodeNeighbors.get(currentNode) || [];
|
|
|
|
|
|
|
|
|
|
for (let nextNode of neighbors)
|
|
|
|
|
{
|
|
|
|
|
const edge = `${currentNode}-${nextNode}`;
|
|
|
|
|
|
|
|
|
|
if (!visitedEdges.has(edge))
|
|
|
|
|
{
|
|
|
|
|
visitedEdges.add(edge);
|
|
|
|
|
stack.push({
|
|
|
|
|
currentNode: nextNode,
|
|
|
|
|
segment: segment.concat([nextNode]),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!found)
|
|
|
|
|
{
|
|
|
|
|
throw new Error(`No way between stop \
|
|
|
|
|
“${currentStop.name}” (${currentStop.id}) and stop “${nextStop.name}” \
|
|
|
|
|
(${nextStop.id}) on line ${lineRef}’s route from “${from}” to “${to}”`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
segments[segmentId].lines.push(lineRef);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
++currentStopIndex;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Construct line objects
|
|
|
|
|
const routes = rawRoutes.map(route => ({
|
|
|
|
|
from: route.tags.from,
|
|
|
|
|
to: route.tags.to,
|
|
|
|
|
|
|
|
|
|
// Retrieve each stop’s information (stop order in the relation is
|
|
|
|
|
// assumed to reflect reality)
|
|
|
|
|
stops: route.members
|
|
|
|
|
.filter(({role}) => role === 'stop')
|
|
|
|
|
.map(({ref}) => elements[ref])
|
|
|
|
|
.filter(stop => 'ref' in stop.tags)
|
2020-01-15 23:34:47 +00:00
|
|
|
|
.map(stop => stop.tags.ref),
|
2020-01-14 13:08:08 +00:00
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
lines[lineRef] = {
|
|
|
|
|
color,
|
2020-01-15 23:34:47 +00:00
|
|
|
|
routes,
|
2020-01-14 13:08:08 +00:00
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {stops, segments, lines};
|
2020-01-14 23:19:26 +00:00
|
|
|
|
};
|
2020-01-14 13:08:08 +00:00
|
|
|
|
|
|
|
|
|
exports.fetch = fetch;
|