2020-07-16 23:41:05 +00:00
|
|
|
|
/**
|
2020-07-25 16:05:43 +00:00
|
|
|
|
* @fileoverview
|
2020-07-17 10:13:25 +00:00
|
|
|
|
*
|
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.
|
2020-07-17 10:13:25 +00:00
|
|
|
|
*
|
|
|
|
|
* Because of the static nature of this data, it is cached in a
|
2021-05-21 21:59:51 +00:00
|
|
|
|
* 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
|
|
|
|
*/
|
|
|
|
|
|
2021-05-11 19:39:24 +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";
|
2020-01-14 23:19:26 +00:00
|
|
|
|
|
|
|
|
|
/**
|
2022-07-05 02:40:29 +00:00
|
|
|
|
* Rectangle area of geographical points.
|
|
|
|
|
*
|
|
|
|
|
* Should contain four values, [lat1, lon1, lat2, lon2], corresponding to two
|
|
|
|
|
* opposed corners of the rectangle.
|
|
|
|
|
* @typedef {Array.<string>} Bounds
|
2020-01-14 23:19:26 +00:00
|
|
|
|
*/
|
2021-05-21 21:59:51 +00:00
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Stop in a transport network (as a GeoJSON feature point).
|
|
|
|
|
* @typedef {Object} Stop
|
|
|
|
|
* @property {string} type Always equal to "Feature".
|
2022-07-05 02:40:29 +00:00
|
|
|
|
* @property {string} id Stop identifier (unique in each network).
|
2021-05-21 21:59:51 +00:00
|
|
|
|
* @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
|
2022-07-05 02:40:29 +00:00
|
|
|
|
* List of transport lines using this stop (as pairs containing the
|
|
|
|
|
* line number and line route identifier).
|
2021-05-21 21:59:51 +00:00
|
|
|
|
* @property {Object} geometry
|
|
|
|
|
* @property {string} geometry.type Always equal to "Point"
|
|
|
|
|
* @property {Array.<number>} geometry.coordinates
|
|
|
|
|
* Longitude and latitude of the stop point.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
/**
|
2022-07-05 02:40:29 +00:00
|
|
|
|
* Find all public transport stops matching a set of criteria.
|
|
|
|
|
* @param {string} network Name of the public transport network
|
|
|
|
|
* to which the stops must belong.
|
|
|
|
|
* @param {string} type Type of public transport vehicle.
|
|
|
|
|
* @param {Bounds} bounds Area in which stops are searched.
|
|
|
|
|
* @return {Object.<string,Stop>} List of stops indexed by their ID.
|
2021-05-21 21:59:51 +00:00
|
|
|
|
*/
|
2022-07-05 02:40:29 +00:00
|
|
|
|
const fetchStops = async (network, type, bounds) => {
|
|
|
|
|
const filter = osm.buildFilter({
|
|
|
|
|
public_transport: "stop_position",
|
|
|
|
|
ref: true,
|
|
|
|
|
network,
|
|
|
|
|
[type]: "yes",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const elementsList = (await osm.runQuery(`[out:json];
|
|
|
|
|
node${filter}(${bounds});
|
|
|
|
|
out body qt;
|
|
|
|
|
`)).elements;
|
|
|
|
|
const stops = {};
|
|
|
|
|
|
|
|
|
|
for (const stop of elementsList) {
|
|
|
|
|
stops[stop.tags.ref] = turfHelpers.point([
|
|
|
|
|
stop.lon,
|
|
|
|
|
stop.lat,
|
|
|
|
|
], {
|
|
|
|
|
name: stop.tags.name,
|
|
|
|
|
node: stop.id.toString(),
|
|
|
|
|
routes: [],
|
|
|
|
|
}, {
|
|
|
|
|
id: stop.tags.ref,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return stops;
|
|
|
|
|
};
|
2021-05-21 21:59:51 +00:00
|
|
|
|
|
|
|
|
|
/**
|
2022-07-05 02:40:29 +00:00
|
|
|
|
* Route between two OpenStreetMap nodes.
|
2021-05-21 21:59:51 +00:00
|
|
|
|
* @typedef {Object} NavigationEdge
|
|
|
|
|
* @property {string} type Always equal to "Feature".
|
|
|
|
|
* @property {Object} properties
|
2022-07-05 02:40:29 +00:00
|
|
|
|
* @property {string} properties.begin ID of the node at the beginning.
|
|
|
|
|
* @property {string} properties.end ID of the node at the end.
|
|
|
|
|
* @property {number} properties.length Length of this path (meters).
|
2021-05-21 21:59:51 +00:00
|
|
|
|
* @property {Object} geometry
|
|
|
|
|
* @property {string} geometry.type Always equal to "LineString".
|
|
|
|
|
* @property {Array.<Array.<number>>} geometry.coordinates
|
2022-07-05 02:40:29 +00:00
|
|
|
|
* Sequence of points along this route (as longitude/latitude pairs).
|
2021-05-21 21:59:51 +00:00
|
|
|
|
*/
|
|
|
|
|
|
2021-05-22 22:45:09 +00:00
|
|
|
|
/**
|
2022-07-05 02:40:29 +00:00
|
|
|
|
* Navigation graph between OpenStreetMap nodes.
|
2021-05-22 22:45:09 +00:00
|
|
|
|
* @typedef {Object.<string,Object.<string,NavigationEdge>>} Navigation
|
|
|
|
|
*/
|
|
|
|
|
|
2021-05-21 21:59:51 +00:00
|
|
|
|
/**
|
2022-07-05 02:40:29 +00:00
|
|
|
|
* Assemble a raw navigation graph from OSM data.
|
|
|
|
|
* @param {Array.<Object>} elementsList List of elements retrieved from OSM.
|
|
|
|
|
* @param {Object.<string,Object>} elementsById OSM elements indexed by ID.
|
2021-05-23 12:59:08 +00:00
|
|
|
|
* @return {Object} Resulting graph and reverse arcs.
|
2021-05-21 21:59:51 +00:00
|
|
|
|
*/
|
2021-05-22 22:45:09 +00:00
|
|
|
|
const createNavigationGraph = (elementsList, elementsById) => {
|
2021-05-21 21:59:51 +00:00
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2021-05-21 21:59: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
|
|
|
|
|
2022-07-05 02:40:29 +00:00
|
|
|
|
// Make sure we don’t switch between rails at railway crossings
|
2021-05-22 22:45:09 +00:00
|
|
|
|
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);
|
2021-05-21 21:59:51 +00:00
|
|
|
|
|
|
|
|
|
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-21 21:59:51 +00:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2021-05-22 22:45:09 +00:00
|
|
|
|
return { navigation, navigationReverse };
|
|
|
|
|
};
|
2021-05-21 21:59:51 +00:00
|
|
|
|
|
2021-05-22 22:45:09 +00:00
|
|
|
|
/**
|
2022-07-05 02:40:29 +00:00
|
|
|
|
* Identify intermediate nodes that can be simplified in a navigation graph.
|
|
|
|
|
* @param {Set.<string>} keep ID of nodes that must be kept.
|
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
|
|
|
|
*/
|
2022-07-05 02:40:29 +00:00
|
|
|
|
const findCompressibleNodes = (keep, navigation, navigationReverse) => {
|
2021-05-23 12:59:08 +00:00
|
|
|
|
const compressible = new Set();
|
2021-05-21 21:59:51 +00:00
|
|
|
|
|
2021-05-23 12:59:08 +00:00
|
|
|
|
for (const nodeId in navigation) {
|
2022-07-05 02:40:29 +00:00
|
|
|
|
if (keep.has(nodeId)) {
|
2021-05-23 12:59:08 +00:00
|
|
|
|
continue;
|
|
|
|
|
}
|
2021-05-21 21:59:51 +00:00
|
|
|
|
|
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) {
|
2021-05-21 21:59:51 +00:00
|
|
|
|
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-21 21:59:51 +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 (isSplit) {
|
|
|
|
|
continue;
|
2021-05-21 21:59:51 +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
|
|
|
|
// 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-21 21:59:51 +00:00
|
|
|
|
}
|
|
|
|
|
|
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-21 21:59:51 +00:00
|
|
|
|
}
|
2021-05-23 12:59:08 +00:00
|
|
|
|
}
|
2021-05-21 21:59:51 +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-21 21:59:51 +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
|
|
|
|
// 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-21 21:59:51 +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
|
2021-05-21 21:59:51 +00:00
|
|
|
|
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;
|
|
|
|
|
}
|
2021-05-21 21:59:51 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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-21 21:59:51 +00:00
|
|
|
|
|
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-21 21:59:51 +00:00
|
|
|
|
|
2021-05-23 12:59:08 +00:00
|
|
|
|
navigationReverse[trackback].delete(parent[trackback]);
|
|
|
|
|
delete navigation[parent[trackback]][trackback];
|
2021-05-21 21:59:51 +00:00
|
|
|
|
|
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 we’re compressing a cycle
|
|
|
|
|
if (endId !== beginId) {
|
|
|
|
|
path.push(beginId);
|
|
|
|
|
path.reverse();
|
2021-05-21 21:59:51 +00:00
|
|
|
|
|
2021-05-23 12:59:08 +00:00
|
|
|
|
begin[endId] = path;
|
|
|
|
|
navigationReverse[endId].add(beginId);
|
2021-05-21 21:59:51 +00:00
|
|
|
|
}
|
2021-05-16 10:01:51 +00:00
|
|
|
|
|
2021-05-23 12:59:08 +00:00
|
|
|
|
didCompress = true;
|
2021-05-21 21:59:51 +00:00
|
|
|
|
} else {
|
2021-05-23 12:59:08 +00:00
|
|
|
|
// Continue the traversal down compressible nodes
|
2021-05-21 21:59:51 +00:00
|
|
|
|
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.
|
2022-07-05 02:40:29 +00:00
|
|
|
|
* @param {Set.<string>} keep ID of nodes that must be kept.
|
2021-05-23 12:59:08 +00:00
|
|
|
|
* @param {Navigation} navigation Input navigation graph.
|
|
|
|
|
* @param {Object.<string,Set.<string>>} navigationReverse Reverse arcs.
|
|
|
|
|
*/
|
2022-07-05 02:40:29 +00:00
|
|
|
|
const compressNavigationGraph = (keep, navigation, navigationReverse) => {
|
2021-05-23 12:59:08 +00:00
|
|
|
|
let compressible = null;
|
|
|
|
|
let didCompress = true;
|
|
|
|
|
|
|
|
|
|
while (didCompress) {
|
|
|
|
|
let didRemove = true;
|
|
|
|
|
|
|
|
|
|
while (didRemove) {
|
|
|
|
|
compressible = findCompressibleNodes(
|
2022-07-05 02:40:29 +00:00
|
|
|
|
keep, navigation, navigationReverse
|
2021-05-23 12:59:08 +00:00
|
|
|
|
);
|
|
|
|
|
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) => {
|
2021-05-21 21:59:51 +00:00
|
|
|
|
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
|
2021-05-21 21:59:51 +00:00
|
|
|
|
]
|
|
|
|
|
), {
|
|
|
|
|
begin: beginId,
|
|
|
|
|
end: endId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
begin[endId].properties.length = 1000 * turfLength(begin[endId]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
2022-07-05 02:40:29 +00:00
|
|
|
|
* Fetch the network of routes that connect the given nodes.
|
|
|
|
|
* @param {Set.<string>} nodes ID of nodes to connect.
|
|
|
|
|
* @param {string} type Type of public transport vehicle.
|
|
|
|
|
* @param {Bounds} bounds Rectangle bounding the network.
|
|
|
|
|
* @return {Navigation} Resulting graph.
|
2021-05-21 21:59:51 +00:00
|
|
|
|
*/
|
2022-07-05 02:40:29 +00:00
|
|
|
|
const fetchNavigationGraph = async (nodes, type, bounds) => {
|
|
|
|
|
const filter = osm.buildFilter(osm.vehicleWayFilter(type));
|
|
|
|
|
const elementsList = (await osm.runQuery(`[out:json];
|
|
|
|
|
way${filter}(${bounds});
|
|
|
|
|
(._; >;);
|
|
|
|
|
out body qt;
|
|
|
|
|
`)).elements;
|
|
|
|
|
|
2021-05-21 21:59:51 +00:00
|
|
|
|
const elementsById = elementsList.reduce((prev, elt) => {
|
|
|
|
|
prev[elt.id] = elt;
|
|
|
|
|
return prev;
|
|
|
|
|
}, {});
|
|
|
|
|
|
2021-05-22 22:45:09 +00:00
|
|
|
|
const { navigation, navigationReverse } = createNavigationGraph(
|
|
|
|
|
elementsList, elementsById
|
|
|
|
|
);
|
|
|
|
|
|
2022-07-05 02:40:29 +00:00
|
|
|
|
compressNavigationGraph(nodes, navigation, navigationReverse);
|
2021-05-22 22:45:09 +00:00
|
|
|
|
makeNavigationSegments(navigation, elementsById);
|
2021-05-21 21:59:51 +00:00
|
|
|
|
|
2022-07-05 02:40:29 +00:00
|
|
|
|
return navigation;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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 route.
|
|
|
|
|
* @property {Array.<string>} stops Sequence of stop IDs along the route.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Line of a transport network.
|
|
|
|
|
* @typedef {Object} Line
|
|
|
|
|
* @property {string} color Hexadecimal color code of the line.
|
|
|
|
|
* @property {Array.<Route>} routes Routes of the line.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Find all public transport lines and routes matching a set of criteria.
|
|
|
|
|
* @param {string} network Name of the public transport network
|
|
|
|
|
* to which the lines must belong.
|
|
|
|
|
* @param {string} type Type of public transport vehicle.
|
|
|
|
|
* @param {Bounds} bounds Area bounding the public transport network.
|
|
|
|
|
* @return {Object.<string,Line>} Assembled information about lines and routes.
|
|
|
|
|
*/
|
|
|
|
|
const queryLines = async (network, type, bounds) => {
|
|
|
|
|
const routeFilter = osm.buildFilter({
|
|
|
|
|
type: "route",
|
|
|
|
|
route: type,
|
|
|
|
|
network,
|
|
|
|
|
});
|
|
|
|
|
const masterFilter = osm.buildFilter({
|
|
|
|
|
type: "route_master",
|
|
|
|
|
route_master: type,
|
|
|
|
|
network,
|
|
|
|
|
});
|
|
|
|
|
const elementsList = (await osm.runQuery(`[out:json];
|
|
|
|
|
relation${routeFilter}(${bounds});
|
|
|
|
|
relation${masterFilter}(br);
|
|
|
|
|
(._; >>;);
|
|
|
|
|
out body;
|
|
|
|
|
`)).elements;
|
|
|
|
|
|
|
|
|
|
const elementsById = elementsList.reduce((prev, elt) => {
|
|
|
|
|
prev[elt.id] = elt;
|
|
|
|
|
return prev;
|
|
|
|
|
}, {});
|
|
|
|
|
|
|
|
|
|
const lines = {};
|
|
|
|
|
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.toUpperCase() || "#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;
|
|
|
|
|
|
|
|
|
|
// 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 }) => osm.isInitialRouteRole(role)
|
|
|
|
|
)) {
|
|
|
|
|
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
|
|
|
|
|
const stops = route.members.slice(0, relationPivot)
|
|
|
|
|
.filter(({ role }) => role === "stop")
|
|
|
|
|
.map(({ ref }) => elementsById[ref].tags.ref);
|
|
|
|
|
|
|
|
|
|
routes.push({
|
|
|
|
|
from,
|
|
|
|
|
...(via && { via }),
|
|
|
|
|
to,
|
|
|
|
|
name,
|
|
|
|
|
stops,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lines[lineRef] = {
|
|
|
|
|
color,
|
|
|
|
|
routes
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return lines;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Record which lines use which stops.
|
|
|
|
|
* @param {Object.<string,Stop>} stops List of stops.
|
|
|
|
|
* @param {Object.<string,Line>} lines List of lines.
|
|
|
|
|
*/
|
|
|
|
|
const recordStops = (stops, lines) => {
|
|
|
|
|
for (const [lineRef, line] of Object.entries(lines)) {
|
|
|
|
|
for (const [routeRef, route] of line.routes.entries()) {
|
|
|
|
|
for (const stop of route.stops) {
|
|
|
|
|
const routes = stops[stop].properties.routes;
|
|
|
|
|
|
|
|
|
|
if (routes.findIndex(([testLineRef, testRouteRef]) =>
|
|
|
|
|
lineRef === testLineRef && routeRef === testRouteRef
|
|
|
|
|
) === -1) {
|
|
|
|
|
routes.push([lineRef, routeRef]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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 {Navigation} navigation Graph for navigating between stops.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Fetch information about a public transport network.
|
|
|
|
|
* @param {string} network Name of the public transport network.
|
|
|
|
|
* @param {string} type Type of public transport vehicle.
|
|
|
|
|
* @param {Bounds} bounds Area bounding the public transport network.
|
|
|
|
|
* @returns {Network} Network metadata extracted from OSM.
|
|
|
|
|
*/
|
|
|
|
|
export const fetch = async (network, type, bounds) => {
|
|
|
|
|
const stops = await fetchStops(network, type, bounds);
|
|
|
|
|
const stopIds = new Set(
|
|
|
|
|
Object.values(stops).map(stop => stop.properties.node)
|
|
|
|
|
);
|
|
|
|
|
const navigation = await fetchNavigationGraph(stopIds, type, bounds);
|
|
|
|
|
const lines = await queryLines(network, type, bounds);
|
|
|
|
|
recordStops(stops, lines);
|
|
|
|
|
return { navigation, stops, lines };
|
2020-01-14 23:19:26 +00:00
|
|
|
|
};
|