/** * @file * * Extract static information about the TaM network from OpenStreetMap (OSM): * tram and bus lines, stops and routes. * * Functions in this file also report and offer to correct errors that may * occur 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, use * the `script/update-network` script. */ const geolib = require('geolib'); const util = require('../util'); const osm = require('./sources/osm'); const tam = require('./sources/tam'); /** * Fetch stops and lines of the network. * * @param lineRefs List of lines to fetch. * @return Object with a set of stops, segments and lines. */ const fetch = async (lineRefs) => { // Retrieve routes, ways and stops from OpenStreetMap const rawData = await osm.runQuery(`[out:json]; // Find the public transport line bearing the requested reference relation[network="TaM"][type="route_master"][ref~"^(${lineRefs.join('|')})$"]; // Recursively fetch routes, ways and stops inside the line (._; >>;); out body qt; `); // List of retrieved objects const elementsList = rawData.elements; // List of retrieved lines const routeMasters = elementsList.filter(osm.isTransportLine); // Retrieved objects indexed by ID const elements = elementsList.reduce((prev, elt) => { prev[elt.id] = elt; return prev; }, {}); // All stops in the network const stops = {}; // All transport lines of the network const lines = {}; // All segments leading from one stop to another const segments = {}; for (let routeMaster of routeMasters) { const lineRef = routeMaster.tags.ref; const color = routeMaster.tags.colour || '#000000'; // Extract all routes for the given line const routes = []; for (let [routeRef, {ref: routeId}] of routeMaster.members.entries()) { const route = elements[routeId]; const {from, to, name} = route.tags; const state = route.tags.state || 'normal'; // Add missing stops to the global stops object for (let {ref, role} of route.members) { if (role === 'stop') { const stop = elements[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] = { lat: stop.lat, lon: stop.lon, name: stop.tags.name, routes: [[lineRef, routeRef]], }; } else { stops[stop.tags.ref].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 = elements[ways[wayIndex]]; const {nodes: wayNodes, tags: wayTags} = 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 = elements[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 = elements[lineStops[stopIdx]].tags.ref; const end = elements[lineStops[stopIdx + 1]].tags.ref; const id = `${begin}-${end}`; const nodes = path.slice( path.indexOf(lineStops[stopIdx]), path.indexOf(lineStops[stopIdx + 1]) + 1, ); if (id in segments) { if (!util.arraysEqual(nodes, segments[id].nodes)) { throw new Error(`Segment ${id} is defined as a different sequence of nodes in two or more lines.`); } segments[id].routes.push([lineRef, routeRef]); } else { const points = nodes.map(id => ({ lat: elements[id].lat, lon: elements[id].lon })); if (points.length) { // Augment each point with the distance to the start points[0].distance = 0; for (let i = 1; i < points.length; ++i) { points[i].distance = geolib.getPreciseDistance( points[i - 1], points[i], ) + points[i - 1].distance; } } segments[id] = { // Keep track of the original sequence of nodes to // compare with duplicates nodes, points, routes: [[lineRef, routeRef]], }; } } routes.push({ from, to, name, state, stops: lineStops.map(id => elements[id].tags.ref), }); } lines[lineRef] = { color, routes, }; } // Remove OSM nodes from segments that were only used for checking validity for (let segment of Object.values(segments)) { delete segment.nodes; } return {stops, lines, segments}; }; exports.fetch = fetch;