tracktracker/src/tam/network.js

568 lines
19 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.

/**
* @fileoverview
*
* Extract static information about the TaM network from OpenStreetMap (OSM):
* tram and bus lines, stops and routes.
*
* Functions in this file also report inconsistencies 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,
* run the `script/update-network.js` script.
*/
import * as turfHelpers from "@turf/helpers";
import turfLength from "@turf/length";
import * as util from "../util.js";
import * as osm from "./sources/osm.js";
/**
* Route of a line of a transport network.
* @typedef {Object} Route
* @property {string} from Name of the starting point of the route.
* @property {string} to Name of the ending point of the route.
* @property {string?} via Optional name of a major intermediate
* stop of the route.
* @property {string} name Human-readable name of the line.
*/
/**
* Line of a transport network.
* @typedef {Object} Line
* @property {string} color Hexadecimal color code of this line.
* @property {Array.<Route>} routes Routes of this line.
*/
/**
* Stop in a transport network (as a GeoJSON feature point).
* @typedef {Object} Stop
* @property {string} type Always equal to "Feature".
* @property {string} id Stop identifier (unique in each network.).
* @property {Object} properties
* @property {string} properties.name Human-readable name of the stop.
* @property {string} properties.node Associated node ID in OpenStreetMap.
* @property {Array.<Array.[string,number]>} properties.routes
* List of transport lines using this stop (as pairs of
* line and route identifiers).
* @property {Object} geometry
* @property {string} geometry.type Always equal to "Point"
* @property {Array.<number>} geometry.coordinates
* Longitude and latitude of the stop point.
*/
/**
* Planned segment routing between two stops of a transport network
* (as a GeoJSON feature line string).
* @typedef {Object} Segment
* @property {string} type Always equal to "Feature".
* @property {string} id Segment identifier (format: `{begin}-{end}`).
* @property {Object} properties
* @property {string} properties.begin ID of the stop at the beginning.
* @property {string} properties.end ID of the stop at the end.
* @property {number} properties.length Length of this segment (meters).
* @property {Array.<Array.[string,number]>} properties.routes
* List of transport lines using this segment (as pairs of
* line identifiers and line direction numbers).
* @property {Object} geometry
* @property {string} geometry.type Always equal to "LineString".
* @property {Array.<Array.<number>>} geometry.coordinates
* Sequence of points forming this segment (as longitude/latitude pairs).
*/
/**
* Edge of the graph for out-of-route navigation between stops.
* @typedef {Object} NavigationEdge
* @property {string} type Always equal to "Feature".
* @property {Object} properties
* @property {string} properties.begin ID of the stop or node at the beginning.
* @property {string} properties.end ID of the stop or node at the end.
* @property {number} properties.length Length of this edge (meters).
* @property {Object} geometry
* @property {string} geometry.type Always equal to "LineString".
* @property {Array.<Array.<number>>} geometry.coordinates
* Sequence of points forming this edge (as longitude/latitude pairs).
*/
/**
* Information about a public transport network.
* @typedef {Object} Network
* @property {Object.<string,Stop>} stops List of stops.
* @property {Object.<string,Line>} lines List of lines.
* @property {Object.<string,Segment>} segments List of segments.
* @property {Object.<string,Object.<string,NavigationEdge>>} navigation
* Graph for out-of-route navigation between stops.
*/
/**
* Retrieve raw routes, ways and stops from OpenStreetMap for the given
* transport lines.
* @param {Array.<string>} lineRefs List of lines to fetch.
* @return {Array.<Object>} List of objects returned by OSM.
*/
// Retrieve routes, ways and stops from OpenStreetMap
const queryLines = async lineRefs => {
return (await osm.runQuery(`[out:json];
// Find the public transport lines bearing the requested references
relation[network="TaM"][type="route_master"][ref~"^(${lineRefs.join("|")})$"];
(
._;
// Recursively fetch routes, ways and stops inside the lines
>>;
// Find adjacent tracks (used for out-of-route navigation)
complete {
way(around:0)[railway="tram"];
};
>;
);
out body qt;
`)).elements;
};
/**
* Assemble information about lines, stops and segments from the raw
* OpenStreetMap data.
* @param {Array.<Object>} elementsList List of nodes retrieved from OSM.
* @param {Object.<string,Object>} elementsById OSM nodes indexed by their ID.
* @return {Object} Assembled information about lines, stops and segments.
*/
const processRoutes = (elementsList, elementsById) => {
const lines = {};
const stops = {};
const segments = {};
const routeMasters = elementsList.filter(osm.isTransportLine);
// Extract lines, associated stops and planned routes
for (const routeMaster of routeMasters) {
const lineRef = routeMaster.tags.ref;
const color = routeMaster.tags.colour || "#000000";
const routes = [];
for (const [routeRef, data] of routeMaster.members.entries()) {
const routeId = data.ref;
const route = elementsById[routeId];
const { from, via, to, name } = route.tags;
const state = route.tags.state || "normal";
// Add missing stops to the global stops object
for (const { ref, role } of route.members) {
if (role === "stop") {
const stop = elementsById[ref];
if (!("ref" in stop.tags)) {
throw new Error(`Stop ${stop.id}
(${osm.viewNode(stop.id)}) on line ${route.tags.name} is missing
a “ref” tag`);
}
if (!(stop.tags.ref in stops)) {
stops[stop.tags.ref] = turfHelpers.point([
stop.lon,
stop.lat
], {
name: stop.tags.name,
node: ref.toString(),
routes: [[lineRef, routeRef]],
}, {
id: stop.tags.ref,
});
} else {
stops[stop.tags.ref].properties.routes.push([
lineRef,
routeRef
]);
}
}
}
// 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 lineStops = 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 = lineStops[0];
for (let wayIndex = 0; wayIndex < ways.length; wayIndex += 1) {
const way = elementsById[ways[wayIndex]];
const { nodes: wayNodes } = 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 = elementsById[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 stopIdx = 0; stopIdx + 1 < lineStops.length; ++stopIdx) {
const begin = elementsById[lineStops[stopIdx]].tags.ref;
const beginIdx = path.indexOf(lineStops[stopIdx]);
const end = elementsById[lineStops[stopIdx + 1]].tags.ref;
const endIdx = path.indexOf(
lineStops[stopIdx + 1],
beginIdx
) + 1;
const id = `${begin}-${end}`;
const nodesIds = path.slice(beginIdx, endIdx);
if (id in segments) {
if (!util.arraysEqual(
nodesIds,
segments[id].properties.nodesIds
)) {
throw new Error(`Segment ${id} is defined as a
different sequence of nodes in two or more lines.`);
}
segments[id].properties.routes.push([lineRef, routeRef]);
} else {
segments[id] = turfHelpers.lineString(nodesIds.map(
nodeId => [
elementsById[nodeId].lon,
elementsById[nodeId].lat
]
), {
// Keep track of the original sequence of nodes to
// compare with duplicates
nodesIds,
routes: [[lineRef, routeRef]],
begin: begin,
end: end,
}, { id });
segments[id].properties.length = (
1000 * turfLength(segments[id]));
}
}
routes.push({
from,
via,
to,
name,
state
});
}
lines[lineRef] = {
color,
routes
};
}
// Remove OSM nodes from segments that were only used for checking validity
for (const segment of Object.values(segments)) {
delete segment.properties.nodesIds;
}
return { lines, stops, segments };
};
/**
* Create a graph for navigating between stops outside of
* regular planned routes.
* @property {Object.<string,Stop>} stops List of stops.
* @param {Array.<Object>} elementsList List of nodes retrieved from OSM.
* @param {Object.<string,Object>} elementsById OSM nodes indexed by their ID.
* @return {Object.<string,Object.<string,NavigationEdge>} Resulting graph.
*/
const createNavigationGraph = (stops, elementsList, elementsById) => {
// Graph of network stops and junctions
const navigation = {};
// Predecessors of each graph node
const navigationReverse = {};
// Stops indexed by their OSM ID instead of their network ID
const stopsReverse = Object.fromEntries(
Object.entries(stops).map(([id, stop]) => [stop.properties.node, id])
);
// Get the ID of a node in the navigation graph
// (its network ID if it is a network object, otherwise its OSM id)
const getNavigationId = objId => (
objId in stopsReverse
? stopsReverse[objId]
: objId.toString()
);
// Get the OSM ID of a navigation object
const getOSMId = navId => (
navId in stops
? stops[navId].properties.node
: navId
);
// Create graph nodes from OSM nodes
for (const obj of elementsList) {
if (obj.type === "node") {
navigation[getNavigationId(obj.id)] = {};
navigationReverse[getNavigationId(obj.id)] = {};
}
}
// Link up graph edges with OSM ways
for (const obj of elementsList) {
if (obj.type === "way") {
const oneWay = osm.isOneWay(obj);
const pairs = obj.nodes.slice(0, -1).map(
(node, i) => [
getNavigationId(node),
getNavigationId(obj.nodes[i + 1]),
]
);
for (const [from, to] of pairs) {
navigation[from][to] = [from, to];
navigationReverse[to][from] = true;
if (!oneWay) {
navigation[to][from] = [to, from];
navigationReverse[from][to] = true;
}
}
}
}
// Mark nodes of the graph to be kept
const nodesToKeep = {};
for (const nodeId in navigation) {
if (nodeId in stops) {
// Keep stop nodes
nodesToKeep[nodeId] = true;
continue;
}
const entries = new Set(Object.keys(navigationReverse[nodeId]));
const exits = new Set(Object.keys(navigation[nodeId]));
// Keep split nodes, i.e. nodes with at least two exit nodes
// and one entry node that are all distinct from each other
if (entries.size >= 1) {
if (exits.size >= 3) {
nodesToKeep[nodeId] = true;
continue;
}
if (exits.size === 2) {
for (const entry of entries) {
if (!exits.has(entry)) {
nodesToKeep[nodeId] = true;
continue;
}
}
}
}
// Keep junction nodes, i.e. nodes with at least two entry nodes
// and one exit node that are all distinct from each other
if (exits.size >= 1) {
if (entries.size >= 3) {
nodesToKeep[nodeId] = true;
continue;
}
if (entries.size === 2) {
for (const exit of exits) {
if (!entries.has(exit)) {
nodesToKeep[nodeId] = true;
continue;
}
}
}
}
}
// Compress edges between nodes of interest
for (const beginId in nodesToKeep) {
const begin = navigation[beginId];
const stack = [];
const parent = {[beginId]: beginId};
for (const succId in begin) {
stack.push(succId);
parent[succId] = beginId;
}
while (stack.length > 0) {
const endId = stack.pop();
const end = navigation[endId];
if (endId in nodesToKeep) {
if (endId in begin) {
continue;
}
const reversePath = [endId];
let trackback = parent[endId];
let oneWay = !(trackback in end);
while (trackback !== beginId) {
reversePath.push(trackback);
oneWay = oneWay || !(parent[trackback] in navigation[trackback]);
delete navigation[trackback];
trackback = parent[trackback];
}
reversePath.push(beginId);
const forwardPath = [...reversePath];
forwardPath.reverse();
delete begin[forwardPath[1]];
if (!(endId in begin)) {
begin[endId] = forwardPath;
}
if (!oneWay) {
delete end[reversePath[1]];
if (!(beginId in end)) {
end[beginId] = reversePath;
}
}
} else {
let isFirst = true;
for (const succId in end) {
if (succId !== parent[endId]) {
if (isFirst) {
parent[succId] = endId;
stack.push(succId);
isFirst = false;
} else {
throw new Error(`Multiple successors in \
non-junction node ${endId}`);
}
}
}
if (isFirst) {
// Reached a dead-end: remove the path
let trackback = endId;
while (parent[trackback] !== beginId) {
delete navigation[trackback];
trackback = parent[trackback];
}
delete navigation[trackback];
delete begin[trackback];
}
}
}
}
// Convert graph edges to GeoJSON line strings
for (const [beginId, begin] of Object.entries(navigation)) {
for (const endId in begin) {
begin[endId] = turfHelpers.lineString(begin[endId].map(
nodeId => [
elementsById[getOSMId(nodeId)].lon,
elementsById[getOSMId(nodeId)].lat
]
), {
begin: beginId,
end: endId,
});
begin[endId].properties.length = 1000 * turfLength(begin[endId]);
}
}
return navigation;
};
/**
* Fetch information about the network.
* @param {Array.<string>} lineRefs List of lines to fetch.
* @returns {Network} Network metadata extracted from OSM.
*/
export const fetch = async lineRefs => {
const elementsList = await queryLines(lineRefs);
const elementsById = elementsList.reduce((prev, elt) => {
prev[elt.id] = elt;
return prev;
}, {});
const { lines, stops, segments } = processRoutes(elementsList, elementsById);
const navigation = createNavigationGraph(stops, elementsList, elementsById);
return { navigation, lines, stops, segments };
};