tracktracker/src/tam/network.js

307 lines
10 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, use
* the `script/update-network` 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";
/**
* 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.
*/
export const fetch = async lineRefs => {
// Retrieve routes, ways and stops from OpenStreetMap
const rawData = 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;
`);
// 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;
}, {});
// Graph for out-of-route navigation
const navigation = {};
// Stops in the network indexed by reference
const stops = {};
// Stops indexed by OSM identifier
const stopsReverse = {};
// Transport lines of the network indexed by name
const lines = {};
// Segments leading from stop to stop in the planned route for each line
const segments = {};
// 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 = 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]],
});
stopsReverse[ref] = 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 routes 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;
}
// Create out-of-route navigation graph
const navigationId = objId => (
objId in stopsReverse
? stopsReverse[objId]
: objId.toString()
);
for (const obj of elementsList) {
if (obj.type === "node") {
const id = navigationId(obj.id);
navigation[id] = {
// Position of this node
lon: obj.lon,
lat: obj.lat,
// List of other nodes that can be accessed from this node
successors: [],
};
}
}
for (const obj of elementsList) {
if (obj.type === "way") {
const oneWay = osm.isOneWay(obj);
const pairs = obj.nodes.slice(0, -1).map(
(node, i) => [
navigationId(node),
navigationId(obj.nodes[i + 1]),
]
);
for (const [from, to] of pairs) {
navigation[from].successors.push(to);
if (!oneWay) {
navigation[to].successors.push(from);
}
}
}
}
return { navigation, stops, lines, segments };
};