UNPKG

terra-route

Version:

A library for routing along GeoJSON LineString networks

196 lines (171 loc) 7.47 kB
import { FeatureCollection, LineString, Point, Feature, Position } from "geojson"; import { haversineDistance } from "./distance/haversine"; import { createCheapRuler } from "./distance/cheap-ruler"; import { MinHeap } from "./heap/min-heap"; import { HeapConstructor } from "./heap/heap"; import { LineStringGraph } from "./graph/graph"; interface Router { buildRouteGraph(network: FeatureCollection<LineString>): void; getRoute(start: Feature<Point>, end: Feature<Point>): Feature<LineString> | null; } class TerraRoute implements Router { private network: FeatureCollection<LineString> | null = null; private distanceMeasurement: (a: Position, b: Position) => number; private heapConstructor: HeapConstructor; // Map from longitude → (map from latitude → index) private coordinateIndexMap: Map<number, Map<number, number>> = new Map(); private coordinates: Position[] = []; private adjacencyList: Array<Array<{ node: number; distance: number }>> = []; constructor(options?: { distanceMeasurement?: (a: Position, b: Position) => number; heap?: HeapConstructor; }) { this.distanceMeasurement = options?.distanceMeasurement ?? haversineDistance; this.heapConstructor = options?.heap ?? MinHeap; } /** * Converts a coordinate into a unique index. If the coordinate already exists, returns its index. * Otherwise, assigns a new index and stores the coordinate. * * @param coord - A GeoJSON Position array representing [longitude, latitude]. * @returns A unique numeric index for the coordinate. */ public buildRouteGraph(network: FeatureCollection<LineString>): void { this.network = network; // Reset everything this.coordinateIndexMap = new Map(); this.coordinates = []; this.adjacencyList = []; // Hoist to locals for speed const coordIndexMapLocal = this.coordinateIndexMap; const coordsLocal = this.coordinates; const adjListLocal = this.adjacencyList; const measureDistance = this.distanceMeasurement; for (const feature of network.features) { const lineCoords = feature.geometry.coordinates; for (let i = 0; i < lineCoords.length - 1; i++) { const [lngA, latA] = lineCoords[i]; const [lngB, latB] = lineCoords[i + 1]; // get or assign index for A let latMapA = coordIndexMapLocal.get(lngA); if (!latMapA) { latMapA = new Map<number, number>(); coordIndexMapLocal.set(lngA, latMapA); } let indexA = latMapA.get(latA); if (indexA === undefined) { indexA = coordsLocal.length; coordsLocal.push(lineCoords[i]); latMapA.set(latA, indexA); adjListLocal[indexA] = []; } // get or assign index for B let latMapB = coordIndexMapLocal.get(lngB); if (!latMapB) { latMapB = new Map<number, number>(); coordIndexMapLocal.set(lngB, latMapB); } let indexB = latMapB.get(latB); if (indexB === undefined) { indexB = coordsLocal.length; coordsLocal.push(lineCoords[i + 1]); latMapB.set(latB, indexB); adjListLocal[indexB] = []; } // record the bidirectional edge const segmentDistance = measureDistance(lineCoords[i], lineCoords[i + 1]); adjListLocal[indexA].push({ node: indexB, distance: segmentDistance }); adjListLocal[indexB].push({ node: indexA, distance: segmentDistance }); } } } /** * Computes the shortest route between two points in the network using the A* algorithm. * * @param start - A GeoJSON Point Feature representing the start location. * @param end - A GeoJSON Point Feature representing the end location. * @returns A GeoJSON LineString Feature representing the shortest path, or null if no path is found. * * @throws Error if the network has not been built yet with buildRouteGraph(network). */ public getRoute( start: Feature<Point>, end: Feature<Point> ): Feature<LineString> | null { if (!this.network) { throw new Error("Network not built. Please call buildRouteGraph(network) first."); } // ensure start/end are in the index maps const startIndex = this.getOrCreateIndex(start.geometry.coordinates); const endIndex = this.getOrCreateIndex(end.geometry.coordinates); if (startIndex === endIndex) { return null; } const openSet = new this.heapConstructor(); openSet.insert(0, startIndex); const nodeCount = this.coordinates.length; const gScore = new Array<number>(nodeCount).fill(Infinity); const cameFrom = new Array<number>(nodeCount).fill(-1); const visited = new Array<boolean>(nodeCount).fill(false); gScore[startIndex] = 0; while (openSet.size() > 0) { const current = openSet.extractMin()!; if (visited[current]) { continue; } if (current === endIndex) { break; } visited[current] = true; for (const neighbor of this.adjacencyList[current] || []) { const tentativeG = gScore[current] + neighbor.distance; if (tentativeG < gScore[neighbor.node]) { gScore[neighbor.node] = tentativeG; cameFrom[neighbor.node] = current; const heuristic = this.distanceMeasurement( this.coordinates[neighbor.node], this.coordinates[endIndex] ); openSet.insert(tentativeG + heuristic, neighbor.node); } } } if (cameFrom[endIndex] < 0) { return null; } // Reconstruct path const path: Position[] = []; let current = endIndex; while (current !== startIndex) { path.unshift(this.coordinates[current]); current = cameFrom[current]; } path.unshift(this.coordinates[startIndex]); return { type: "Feature", geometry: { type: "LineString", coordinates: path }, properties: {}, }; } /** * Helper to index start/end in getRoute. */ private getOrCreateIndex(coord: Position): number { const [lng, lat] = coord; let latMap = this.coordinateIndexMap.get(lng); if (!latMap) { latMap = new Map<number, number>(); this.coordinateIndexMap.set(lng, latMap); } let index = latMap.get(lat); if (index === undefined) { index = this.coordinates.length; this.coordinates.push(coord); latMap.set(lat, index); // ensure adjacencyList covers this new node this.adjacencyList[index] = []; } return index; } } export { TerraRoute, createCheapRuler, haversineDistance, LineStringGraph }