tracktracker/src/tam/network.js

449 lines
14 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.

/**
* @file
*
* Extract static information about the TaM network from OpenStreetMap (OSM):
* tram and bus lines, stops and routes.
*
* Functions in this file also report and offer to correct errors that may
* occur in OSM data.
*
* Because of the static nature of this data, it is cached in a
* version-controlled file `network.json` next to this file. To update it, use
* the `script/update-network` script.
*/
const geolib = require('geolib');
const util = require('../util');
const osm = require('./sources/osm');
const tam = require('./sources/tam');
/**
* 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 = {};
tam.fetchTheoretical((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;
};
/**
* Fetch stops and lines of 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 osm.runQuery(`[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 = rawData.elements;
// List of retrieved lines
const routeMasters = elementsList.filter(osm.isTransportLine);
// 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 = {};
// All segments leading from one stop to another
const segments = {};
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: ${osm.viewNode(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 \
${util.choosePlural(candidate.lines.length, 'line', '.s')} \
${util.joinSentence(Array.from(candidate.lines), ', ', ' and ')} going to \
${util.joinSentence(Array.from(candidate.directions), ', ', ' or ')}
Apply in JOSM: ${osm.addTagsToNode(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, name} = route.tags;
// 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 ${name}`);
}
if (!route.members.slice(relationPivot).every(
({role}) => role === ''
))
{
throw new Error(`Members with invalid roles inside the path
of ${name}`);
}
// 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 way = elements[ways[wayIndex]];
const {nodes: wayNodes, tags: wayTags} = way;
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 ${name},
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 (osm.isOneWay(way))
{
throw new Error(`Way n°${wayIndex} in
${name} 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
for (let stopIndex = 0; stopIndex + 1 < stops.length; ++stopIndex)
{
const begin = elements[stops[stopIndex]].tags.ref;
const end = elements[stops[stopIndex + 1]].tags.ref;
const id = `${begin}-${end}`;
const nodes = path.slice(
path.indexOf(stops[stopIndex]),
path.indexOf(stops[stopIndex + 1]) + 1,
);
if (id in segments)
{
if (!util.arraysEqual(nodes, segments[id].nodes))
{
throw new Error(`Segment ${id} is defined as a
different sequence of nodes in two or more lines.`);
}
segments[id].lines.add(lineRef);
}
else
{
const points = nodes.map(id => ({
lat: elements[id].lat,
lon: elements[id].lon
}));
if (points.length)
{
// Augment each point with the distance to the start
points[0].distance = 0;
for (let i = 1; i < points.length; ++i)
{
points[i].distance = geolib.getPreciseDistance(
points[i - 1],
points[i],
) + points[i - 1].distance;
}
}
segments[id] = {
// Keep track of the original sequence of nodes to
// compare with duplicates
nodes,
points,
lines: new Set([lineRef]),
};
}
}
routes.push({
from, to, name,
stops: stops.map(id => elements[id].tags.ref),
});
}
lines[lineRef] = {
color,
routes,
};
}
// Remove OSM nodes from segments that were only used for checking validity
for (let segment of Object.values(segments))
{
delete segment.nodes;
}
return {stops, lines, segments};
};
exports.fetch = fetch;