tracktracker/src/data/network.js

665 lines
21 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";
/**
* 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
*/
/**
* 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 containing the
* line number and line route identifier).
* @property {Object} geometry
* @property {string} geometry.type Always equal to "Point"
* @property {Array.<number>} geometry.coordinates
* Longitude and latitude of the stop point.
*/
/**
* 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.
*/
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;
};
/**
* Route between two OpenStreetMap nodes.
* @typedef {Object} NavigationEdge
* @property {string} type Always equal to "Feature".
* @property {Object} properties
* @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).
* @property {Object} geometry
* @property {string} geometry.type Always equal to "LineString".
* @property {Array.<Array.<number>>} geometry.coordinates
* Sequence of points along this route (as longitude/latitude pairs).
*/
/**
* Navigation graph between OpenStreetMap nodes.
* @typedef {Object.<string,Object.<string,NavigationEdge>>} Navigation
*/
/**
* 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.
* @return {Object} Resulting graph and reverse arcs.
*/
const createNavigationGraph = (elementsList, elementsById) => {
const navigation = {};
const navigationReverse = {};
// Create graph nodes from OSM nodes
for (const obj of elementsList) {
if (obj.type === "node") {
navigation[obj.id] = {};
navigationReverse[obj.id] = new Set();
}
}
// Link up graph edges with OSM ways
for (const obj of elementsList) {
if (obj.type === "way") {
const oneWay = osm.isOneWay(obj);
for (let i = 0; i + 1 < obj.nodes.length; ++i) {
const from = obj.nodes[i].toString();
let to = obj.nodes[i + 1].toString();
let path = [from, to];
// Make sure we dont switch between rails at railway crossings
if (i + 2 < obj.nodes.length
&& osm.isRailwayCrossing(elementsById[to])) {
const next = obj.nodes[i + 2].toString();
path = [from, to, next];
to = next;
i += 1;
}
navigation[from][to] = path;
navigationReverse[to].add(from);
if (!oneWay) {
const reversePath = [...path];
reversePath.reverse();
navigation[to][from] = reversePath;
navigationReverse[from].add(to);
}
}
}
}
return { navigation, navigationReverse };
};
/**
* Identify intermediate nodes that can be simplified in a navigation graph.
* @param {Set.<string>} keep ID of nodes that must be kept.
* @param {Navigation} navigation Input navigation graph.
* @param {Object.<string,Set.<string>>} navigationReverse Reverse arcs.
* @return {Set.<string>} Set of compressible nodes.
*/
const findCompressibleNodes = (keep, navigation, navigationReverse) => {
const compressible = new Set();
for (const nodeId in navigation) {
if (keep.has(nodeId)) {
continue;
}
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;
}
let isSplit = false;
if (exits.size === 2) {
for (const entry of entries) {
if (!exits.has(entry)) {
isSplit = true;
break;
}
}
}
if (isSplit) {
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) {
continue;
}
let isJunction = false;
if (entries.size === 2) {
for (const exit of exits) {
if (!entries.has(exit)) {
isJunction = true;
break;
}
}
}
if (isJunction) {
continue;
}
}
// 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;
// Find dead-ends starting from kept nodes
for (const beginId in navigation) {
if (compressible.has(beginId)) {
continue;
}
const begin = navigation[beginId];
const stack = [];
const parent = {[beginId]: beginId};
for (const succId in begin) {
if (compressible.has(succId)) {
stack.push(succId);
parent[succId] = beginId;
}
}
while (stack.length > 0) {
const endId = stack.pop();
const end = navigation[endId];
if (compressible.has(endId)) {
let hasSuccessor = false;
for (const succId in end) {
if (succId !== parent[endId]) {
parent[succId] = endId;
stack.push(succId);
hasSuccessor = true;
}
}
if (!hasSuccessor) {
// Remove the dead-end path
let trackback = endId;
while (trackback !== beginId) {
navigationReverse[trackback].delete(parent[trackback]);
delete navigation[parent[trackback]][trackback];
trackback = parent[trackback];
}
didRemove = true;
}
}
}
}
// 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];
if (compressible.has(endId)) {
for (const succId in end) {
if (succId !== parent[endId]) {
parent[succId] = endId;
stack.push(succId);
}
}
} else {
// Remove the dead-end path
let trackback = endId;
while (trackback !== beginId) {
navigationReverse[trackback].delete(parent[trackback]);
delete navigation[parent[trackback]][trackback];
trackback = parent[trackback];
}
didRemove = true;
}
}
}
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;
for (const beginId in navigation) {
if (compressible.has(beginId)) {
continue;
}
// Start a DFS from each kept node
const begin = navigation[beginId];
const stack = [];
const parent = {[beginId]: beginId};
for (const succId in begin) {
if (compressible.has(succId)) {
stack.push(succId);
parent[succId] = beginId;
}
}
while (stack.length > 0) {
const endId = stack.pop();
const end = navigation[endId];
if (!compressible.has(endId)) {
// Found another kept node
// Collect and remove intermediate path
let path = [];
let trackback = endId;
do {
const segment = [...navigation[parent[trackback]][trackback]];
segment.reverse();
path = path.concat(segment.slice(0, -1));
navigationReverse[trackback].delete(parent[trackback]);
delete navigation[parent[trackback]][trackback];
trackback = parent[trackback];
} while (trackback !== beginId);
// Make sure not to add loops if were compressing a cycle
if (endId !== beginId) {
path.push(beginId);
path.reverse();
begin[endId] = path;
navigationReverse[endId].add(beginId);
}
didCompress = true;
} else {
// 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}`);
}
}
}
}
}
}
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 {Set.<string>} keep ID of nodes that must be kept.
* @param {Navigation} navigation Input navigation graph.
* @param {Object.<string,Set.<string>>} navigationReverse Reverse arcs.
*/
const compressNavigationGraph = (keep, navigation, navigationReverse) => {
let compressible = null;
let didCompress = true;
while (didCompress) {
let didRemove = true;
while (didRemove) {
compressible = findCompressibleNodes(
keep, navigation, navigationReverse
);
didRemove = removeDeadEnds(
navigation, navigationReverse, compressible
);
}
didCompress = removeCompressibleNodes(
navigation, navigationReverse, compressible
);
cleanUpIsolatedNodes(navigation, navigationReverse);
}
};
/**
* 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 => [
elementsById[nodeId].lon,
elementsById[nodeId].lat
]
), {
begin: beginId,
end: endId,
});
begin[endId].properties.length = 1000 * turfLength(begin[endId]);
}
}
};
/**
* 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.
*/
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;
const elementsById = elementsList.reduce((prev, elt) => {
prev[elt.id] = elt;
return prev;
}, {});
const { navigation, navigationReverse } = createNavigationGraph(
elementsList, elementsById
);
compressNavigationGraph(nodes, navigation, navigationReverse);
makeNavigationSegments(navigation, elementsById);
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 };
};