/** * @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.} 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.} 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.} 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.} 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.>} 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.>} geometry.coordinates * Sequence of points forming this edge (as longitude/latitude pairs). */ /** * 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 {Object.>} navigation * Graph for out-of-route navigation between stops. */ /** * Retrieve raw routes, ways and stops from OpenStreetMap for the given * transport lines. * @param {Array.} lineRefs List of lines to fetch. * @return {Array.} 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.} elementsList List of nodes retrieved from OSM. * @param {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 route’s 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.} stops List of stops. * @param {Array.} elementsList List of nodes retrieved from OSM. * @param {Object.} elementsById OSM nodes indexed by their ID. * @return {Object.} 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.} 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 }; };