255 lines
8.7 KiB
JavaScript
255 lines
8.7 KiB
JavaScript
/**
|
||
* @fileoverview
|
||
*
|
||
* 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 turfHelpers = require("@turf/helpers");
|
||
const turfLength = require("@turf/length").default;
|
||
const util = require("../util");
|
||
const osm = require("./sources/osm");
|
||
|
||
/**
|
||
* Fetch stops and lines of the network.
|
||
* @param {string[]} lineRefs List of lines to fetch.
|
||
* @returns {{stops: Object, lines: Object, segments: Object}} 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 (const routeMaster of routeMasters) {
|
||
const lineRef = routeMaster.tags.ref;
|
||
const color = routeMaster.tags.colour || "#000000";
|
||
|
||
// Extract all routes for the given line
|
||
const routes = [];
|
||
|
||
for (const [routeRef, data] of routeMaster.members.entries()) {
|
||
const routeId = data.ref;
|
||
const route = elements[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 = 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] = turfHelpers.point([
|
||
stop.lon,
|
||
stop.lat
|
||
], {
|
||
name: stop.tags.name,
|
||
routes: [[lineRef, routeRef]]
|
||
});
|
||
} 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 = elements[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 = 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 beginIdx = path.indexOf(lineStops[stopIdx]);
|
||
const end = elements[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 => [
|
||
elements[nodeId].lon,
|
||
elements[nodeId].lat
|
||
]
|
||
), {
|
||
|
||
// Keep track of the original sequence of nodes to
|
||
// compare with duplicates
|
||
nodesIds,
|
||
routes: [[lineRef, routeRef]]
|
||
});
|
||
|
||
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 { stops, lines, segments };
|
||
};
|
||
|
||
exports.fetch = fetch;
|