tracktracker/src/tam/network.js

745 lines
25 KiB
JavaScript
Raw Normal View History

2020-07-16 23:41:05 +00:00
/**
* @fileoverview
*
2020-07-16 23:41:05 +00:00
* Extract static information about the TaM network from OpenStreetMap (OSM):
* tram and bus lines, stops and routes.
*
2021-05-11 13:35:03 +00:00
* 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.
2020-07-16 23:41:05 +00:00
*/
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).
*/
/**
2021-05-22 22:45:09 +00:00
* Edge of the graph for navigating 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).
*/
2021-05-22 22:45:09 +00:00
/**
* Graph for navigating between stops.
* @typedef {Object.<string,Object.<string,NavigationEdge>>} Navigation
*/
/**
* 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.
2021-05-22 22:45:09 +00:00
* @property {Navigation} 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];
2021-05-16 10:01:51 +00:00
// Find the public transport lines bearing the requested references
relation[network="TaM"][type="route_master"][ref~"^(${lineRefs.join("|")})$"];
2021-05-16 10:01:51 +00:00
(
._;
// 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;
};
2021-05-16 10:01:51 +00:00
/**
* 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);
2021-05-16 10:01:51 +00:00
// 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)
2020-07-16 22:16:54 +00:00
.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;
2020-07-24 23:16:36 +00:00
const beginIdx = path.indexOf(lineStops[stopIdx]);
const end = elementsById[lineStops[stopIdx + 1]].tags.ref;
2020-07-24 23:16:36 +00:00
const endIdx = path.indexOf(
lineStops[stopIdx + 1],
beginIdx
) + 1;
const id = `${begin}-${end}`;
2020-07-24 23:16:36 +00:00
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 };
};
/**
2021-05-22 22:45:09 +00:00
* Create a graph for navigating between stops.
* @param {Array.<Object>} elementsList List of nodes retrieved from OSM.
* @param {Object.<string,Object>} elementsById OSM nodes indexed by their ID.
2021-05-23 12:59:08 +00:00
* @return {Object} Resulting graph and reverse arcs.
*/
2021-05-22 22:45:09 +00:00
const createNavigationGraph = (elementsList, elementsById) => {
const navigation = {};
const navigationReverse = {};
// Create graph nodes from OSM nodes
2021-05-16 10:01:51 +00:00
for (const obj of elementsList) {
if (obj.type === "node") {
2021-05-22 22:45:09 +00:00
navigation[obj.id] = {};
2021-05-23 12:59:08 +00:00
navigationReverse[obj.id] = new Set();
2021-05-16 10:01:51 +00:00
}
}
// Link up graph edges with OSM ways
2021-05-16 10:01:51 +00:00
for (const obj of elementsList) {
if (obj.type === "way") {
const oneWay = osm.isOneWay(obj);
2021-05-22 22:45:09 +00:00
for (let i = 0; i + 1 < obj.nodes.length; ++i) {
2021-05-23 12:59:08 +00:00
const from = obj.nodes[i].toString();
let to = obj.nodes[i + 1].toString();
let path = [from, to];
2021-05-22 22:45:09 +00:00
// Make sure we cant jump between rails at railway crossings
if (i + 2 < obj.nodes.length
&& osm.isRailwayCrossing(elementsById[to])) {
2021-05-23 12:59:08 +00:00
const next = obj.nodes[i + 2].toString();
path = [from, to, next];
2021-05-22 22:45:09 +00:00
to = next;
i += 1;
}
navigation[from][to] = path;
2021-05-23 12:59:08 +00:00
navigationReverse[to].add(from);
if (!oneWay) {
2021-05-22 22:45:09 +00:00
const reversePath = [...path];
reversePath.reverse();
navigation[to][from] = reversePath;
2021-05-23 12:59:08 +00:00
navigationReverse[from].add(to);
}
}
}
}
2021-05-22 22:45:09 +00:00
return { navigation, navigationReverse };
};
2021-05-22 22:45:09 +00:00
/**
2021-05-23 12:59:08 +00:00
* Identify intermediate nodes of the navigation graph that can be simplified.
* @param {Set.<string>} stopsSet OSM IDs of stop nodes.
2021-05-22 22:45:09 +00:00
* @param {Navigation} navigation Input navigation graph.
2021-05-23 12:59:08 +00:00
* @param {Object.<string,Set.<string>>} navigationReverse Reverse arcs.
* @return {Set.<string>} Set of compressible nodes.
2021-05-22 22:45:09 +00:00
*/
2021-05-23 12:59:08 +00:00
const findCompressibleNodes = (stopsSet, navigation, navigationReverse) => {
const compressible = new Set();
2021-05-23 12:59:08 +00:00
for (const nodeId in navigation) {
if (stopsSet.has(nodeId)) {
// Keep stop nodes
continue;
}
2021-05-23 12:59:08 +00:00
const entries = 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) {
continue;
}
2021-05-23 12:59:08 +00:00
let isSplit = false;
2021-05-22 22:45:09 +00:00
2021-05-23 12:59:08 +00:00
if (exits.size === 2) {
for (const entry of entries) {
if (!exits.has(entry)) {
isSplit = true;
break;
}
}
2021-05-23 12:59:08 +00:00
}
2021-05-22 22:45:09 +00:00
2021-05-23 12:59:08 +00:00
if (isSplit) {
continue;
}
2021-05-23 12:59:08 +00:00
}
2021-05-22 22:45:09 +00:00
2021-05-23 12:59:08 +00:00
// 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) {
continue;
}
2021-05-22 22:45:09 +00:00
2021-05-23 12:59:08 +00:00
let isJunction = false;
2021-05-22 22:45:09 +00:00
2021-05-23 12:59:08 +00:00
if (entries.size === 2) {
for (const exit of exits) {
if (!entries.has(exit)) {
isJunction = true;
break;
2021-05-22 22:45:09 +00:00
}
}
2021-05-23 12:59:08 +00:00
}
2021-05-22 22:45:09 +00:00
2021-05-23 12:59:08 +00:00
if (isJunction) {
continue;
2021-05-22 22:45:09 +00:00
}
2021-05-23 12:59:08 +00:00
}
// Compress all other nodes
compressible.add(nodeId);
}
return compressible;
};
/**
* Remove nodes that are not used to link up two kept nodes.
* @param {Navigation} navigation Input navigation graph.
* @param {Object.<string,Set.<string>>} navigationReverse Reverse arcs.
* @param {Set.<string>} compressible Set of nodes that will not be kept.
* @return {boolean} True if some dead-ends were removed.
*/
const removeDeadEnds = (navigation, navigationReverse, compressible) => {
let didRemove = false;
2021-05-22 22:45:09 +00:00
2021-05-23 12:59:08 +00:00
// Find dead-ends starting from kept nodes
for (const beginId in navigation) {
if (compressible.has(beginId)) {
continue;
}
2021-05-23 12:59:08 +00:00
const begin = navigation[beginId];
const stack = [];
const parent = {[beginId]: beginId};
2021-05-22 22:45:09 +00:00
2021-05-23 12:59:08 +00:00
for (const succId in begin) {
if (compressible.has(succId)) {
stack.push(succId);
parent[succId] = beginId;
}
2021-05-23 12:59:08 +00:00
}
2021-05-23 12:59:08 +00:00
while (stack.length > 0) {
const endId = stack.pop();
const end = navigation[endId];
2021-05-22 22:45:09 +00:00
2021-05-23 12:59:08 +00:00
if (compressible.has(endId)) {
let hasSuccessor = false;
2021-05-22 22:45:09 +00:00
2021-05-23 12:59:08 +00:00
for (const succId in end) {
if (succId !== parent[endId]) {
parent[succId] = endId;
stack.push(succId);
hasSuccessor = true;
}
2021-05-22 22:45:09 +00:00
}
2021-05-23 12:59:08 +00:00
if (!hasSuccessor) {
// Remove the dead-end path
let trackback = endId;
2021-05-22 22:45:09 +00:00
while (trackback !== beginId) {
2021-05-23 12:59:08 +00:00
navigationReverse[trackback].delete(parent[trackback]);
delete navigation[parent[trackback]][trackback];
2021-05-22 22:45:09 +00:00
trackback = parent[trackback];
}
2021-05-23 12:59:08 +00:00
didRemove = true;
}
}
}
2021-05-23 12:59:08 +00:00
}
2021-05-22 22:45:09 +00:00
2021-05-23 12:59:08 +00:00
// Find dead-ends starting from compressible source nodes
for (const beginId in navigation) {
if (!compressible.has(beginId)) {
continue;
}
if (navigationReverse[beginId].size > 0) {
continue;
}
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];
2021-05-22 22:45:09 +00:00
2021-05-23 12:59:08 +00:00
if (compressible.has(endId)) {
for (const succId in end) {
if (succId !== parent[endId]) {
parent[succId] = endId;
stack.push(succId);
}
2021-05-22 22:45:09 +00:00
}
2021-05-23 12:59:08 +00:00
} else {
// Remove the dead-end path
let trackback = endId;
2021-05-22 22:45:09 +00:00
2021-05-23 12:59:08 +00:00
while (trackback !== beginId) {
navigationReverse[trackback].delete(parent[trackback]);
delete navigation[parent[trackback]][trackback];
trackback = parent[trackback];
2021-05-22 22:45:09 +00:00
}
2021-05-23 12:59:08 +00:00
didRemove = true;
2021-05-22 22:45:09 +00:00
}
}
}
2021-05-23 12:59:08 +00:00
return didRemove;
};
/**
* Compress the given set of nodes.
* @param {Navigation} navigation Input navigation graph.
* @param {Object.<string,Set.<string>>} navigationReverse Reverse arcs.
* @param {Set.<string>} compressible Set of nodes to compress.
* @return {boolean} True if some nodes were compressed.
*/
const removeCompressibleNodes = (navigation, navigationReverse, compressible) => {
let didCompress = false;
2021-05-22 22:45:09 +00:00
for (const beginId in navigation) {
2021-05-23 12:59:08 +00:00
if (compressible.has(beginId)) {
2021-05-22 22:45:09 +00:00
continue;
}
2021-05-23 12:59:08 +00:00
// Start a DFS from each kept node
const begin = navigation[beginId];
const stack = [];
const parent = {[beginId]: beginId};
for (const succId in begin) {
2021-05-23 12:59:08 +00:00
if (compressible.has(succId)) {
2021-05-22 22:45:09 +00:00
stack.push(succId);
parent[succId] = beginId;
}
}
while (stack.length > 0) {
const endId = stack.pop();
const end = navigation[endId];
2021-05-23 12:59:08 +00:00
if (!compressible.has(endId)) {
2021-05-22 22:45:09 +00:00
// Found another kept node
2021-05-23 12:59:08 +00:00
// Collect and remove intermediate path
let path = [];
let trackback = endId;
2021-05-23 12:59:08 +00:00
do {
const segment = [...navigation[parent[trackback]][trackback]];
segment.reverse();
path = path.concat(segment.slice(0, -1));
2021-05-23 12:59:08 +00:00
navigationReverse[trackback].delete(parent[trackback]);
delete navigation[parent[trackback]][trackback];
2021-05-23 12:59:08 +00:00
trackback = parent[trackback];
} while (trackback !== beginId);
2021-05-22 22:45:09 +00:00
2021-05-23 12:59:08 +00:00
// Make sure not to add loops if were compressing a cycle
if (endId !== beginId) {
path.push(beginId);
path.reverse();
2021-05-23 12:59:08 +00:00
begin[endId] = path;
navigationReverse[endId].add(beginId);
}
2021-05-16 10:01:51 +00:00
2021-05-23 12:59:08 +00:00
didCompress = true;
} else {
2021-05-23 12:59:08 +00:00
// Continue the traversal down compressible nodes
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}`);
}
}
}
2021-05-16 10:01:51 +00:00
}
}
}
2021-05-23 12:59:08 +00:00
return didCompress;
};
/**
* Find nodes in the graph that have no exits nor entries and remove them.
* @param {Navigation} navigation Input navigation graph.
* @param {Object.<string,Set.<string>>} navigationReverse Reverse arcs.
*/
const cleanUpIsolatedNodes = (navigation, navigationReverse) => {
for (const nodeId in navigation) {
if (
Object.keys(navigation[nodeId]).length === 0
&& navigationReverse[nodeId].size === 0
) {
delete navigation[nodeId];
delete navigationReverse[nodeId];
}
}
};
/**
* Remove and relink nodes that connect only two nodes or less.
* @param {Object.<string,Stop>} stops List of stops.
* @param {Navigation} navigation Input navigation graph.
* @param {Object.<string,Set.<string>>} navigationReverse Reverse arcs.
*/
const compressNavigationGraph = (stops, navigation, navigationReverse) => {
const stopsSet = new Set(
Object.values(stops).map(stop => stop.properties.node)
);
let compressible = null;
let didCompress = true;
while (didCompress) {
let didRemove = true;
while (didRemove) {
compressible = findCompressibleNodes(
stopsSet, navigation, navigationReverse
);
didRemove = removeDeadEnds(
navigation, navigationReverse, compressible
);
}
didCompress = removeCompressibleNodes(
navigation, navigationReverse, compressible
);
cleanUpIsolatedNodes(navigation, navigationReverse);
}
2021-05-22 22:45:09 +00:00
};
2021-05-16 10:01:51 +00:00
2021-05-22 22:45:09 +00:00
/**
* Transform navigation graph edges into GeoJSON segments.
* @param {Navigation} navigation Input navigation graph.
* @param {Object.<string,Object>} elementsById OSM nodes indexed by their ID.
*/
const makeNavigationSegments = (navigation, elementsById) => {
for (const [beginId, begin] of Object.entries(navigation)) {
for (const endId in begin) {
begin[endId] = turfHelpers.lineString(begin[endId].map(
nodeId => [
2021-05-22 22:45:09 +00:00
elementsById[nodeId].lon,
elementsById[nodeId].lat
]
), {
begin: beginId,
end: endId,
});
begin[endId].properties.length = 1000 * turfLength(begin[endId]);
}
}
};
/**
* 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);
2021-05-22 22:45:09 +00:00
const { navigation, navigationReverse } = createNavigationGraph(
elementsList, elementsById
);
compressNavigationGraph(stops, navigation, navigationReverse);
makeNavigationSegments(navigation, elementsById);
return { navigation, lines, stops, segments };
};