/** * @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.} 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.} 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.} 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.} 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.>} geometry.coordinates * Sequence of points along this route (as longitude/latitude pairs). */ /** * Navigation graph between OpenStreetMap nodes. * @typedef {Object.>} Navigation */ /** * Assemble a raw navigation graph from OSM data. * @param {Array.} elementsList List of elements retrieved from OSM. * @param {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 don’t 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.} keep ID of nodes that must be kept. * @param {Navigation} navigation Input navigation graph. * @param {Object.>} navigationReverse Reverse arcs. * @return {Set.} 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.>} navigationReverse Reverse arcs. * @param {Set.} 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.>} navigationReverse Reverse arcs. * @param {Set.} 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 we’re 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.>} 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.} keep ID of nodes that must be kept. * @param {Navigation} navigation Input navigation graph. * @param {Object.>} 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.} 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.} 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.} 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.} 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.} 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.} stops List of stops. * @param {Object.} 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.} stops List of stops. * @property {Object.} lines List of lines. * @property {Object.} 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 }; };