UNPKG

terra-route

Version:

A library for routing along GeoJSON LineString networks

371 lines (312 loc) 18.1 kB
import { FeatureCollection, LineString, Point, Feature, Position } from "geojson"; // Import GeoJSON types import { haversineDistance } from "./distance/haversine"; // Great-circle distance function (default heuristic/edge weight) import { createCheapRuler } from "./distance/cheap-ruler"; // Factory for faster planar distance (exported for consumers) import { HeapConstructor } from "./heap/heap"; // Heap interface so users can plug custom heaps import { LineStringGraph } from "./graph/graph"; // Exported type (not used internally here) import { FourAryHeap } from "./heap/four-ary-heap"; 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; // The last network used to build the graph private distanceMeasurement: (a: Position, b: Position) => number; // Distance function used for edges and heuristic private heapConstructor: HeapConstructor; // Heap class used by A* // Map from longitude → (map from latitude → index) to deduplicate coordinates and get node indices quickly private coordinateIndexMap: Map<number, Map<number, number>> = new Map(); // Nested map for exact coord lookup private coordinates: Position[] = []; // Array of all unique coordinates by index // Sparse adjacency list used during build and for any nodes added dynamically later private adjacencyList: Array<Array<{ node: number, distance: number }>> = []; // Per-node neighbor arrays used only pre-CSR or for dynamic nodes // Compressed Sparse Row adjacency representation for fast neighbor iteration in getRoute private csrOffsets: Int32Array | null = null; // Row pointer: length = nodeCount + 1, offsets into indices/distances private csrIndices: Int32Array | null = null; // Column indices: neighbor node IDs, length = totalEdges private csrDistances: Float64Array | null = null; // Edge weights aligned to csrIndices, length = totalEdges private csrNodeCount = 0; // Number of nodes captured in the CSR arrays // Reusable typed scratch buffers for A* private gScoreScratch: Float64Array | null = null; // gScore per node (cost from start) private cameFromScratch: Int32Array | null = null; // Predecessor per node for path reconstruction private visitedScratch: Uint8Array | null = null; // Visited set to avoid reprocessing private hScratch: Float64Array | null = null; // Per-node heuristic cache for the current query (lazy compute) private scratchCapacity = 0; // Current capacity of scratch arrays constructor(options?: { distanceMeasurement?: (a: Position, b: Position) => number; // Optional distance function override heap?: HeapConstructor; // Optional heap implementation override }) { this.distanceMeasurement = options?.distanceMeasurement ?? haversineDistance; // Default to haversine this.heapConstructor = options?.heap ?? FourAryHeap; // Default to MinHeap } /** * Builds a graph (CSR) from a LineString FeatureCollection. * Two-pass build: pass 1 assigns node indices and counts degrees; pass 2 fills CSR arrays. */ public buildRouteGraph(network: FeatureCollection<LineString>): void { this.network = network; // Keep a reference to the network // Reset internal structures for a fresh build this.coordinateIndexMap = new Map(); // Clear coordinate index map this.coordinates = []; // Clear coordinates array this.adjacencyList = []; // Will not be populated during build; reserved for dynamic nodes post-build // Reset CSR structures (will rebuild below) this.csrOffsets = null; this.csrIndices = null; this.csrDistances = null; this.csrNodeCount = 0; // Hoist to locals for speed (avoid repeated property lookups in hot loops) const coordIndexMapLocal = this.coordinateIndexMap; // Local alias for coord map const coordsLocal = this.coordinates; // Local alias for coordinates array const measureDistance = this.distanceMeasurement; // Local alias for distance function const features = network.features; // All LineString features // Pass 1: assign indices and count degrees per node const degree: number[] = []; // Dynamic degree array; grows as nodes are discovered for (let f = 0, fLen = features.length; f < fLen; f++) { // Iterate features const feature = features[f]; // Current feature const lineCoords = feature.geometry.coordinates; // Coordinates for this LineString for (let i = 0, len = lineCoords.length - 1; i < len; i++) { // Iterate segment pairs const a = lineCoords[i]; // Segment start coord const b = lineCoords[i + 1]; // Segment end coord const lngA = a[0], latA = a[1]; // Keys for A const lngB = b[0], latB = b[1]; // Keys for B // Index A let latMapA = coordIndexMapLocal.get(lngA); if (latMapA === undefined) { latMapA = new Map<number, number>(); coordIndexMapLocal.set(lngA, latMapA); } let indexA = latMapA.get(latA); if (indexA === undefined) { indexA = coordsLocal.length; coordsLocal.push(a); latMapA.set(latA, indexA); } // Index B let latMapB = coordIndexMapLocal.get(lngB); if (latMapB === undefined) { latMapB = new Map<number, number>(); coordIndexMapLocal.set(lngB, latMapB); } let indexB = latMapB.get(latB); if (indexB === undefined) { indexB = coordsLocal.length; coordsLocal.push(b); latMapB.set(latB, indexB); } // Count degree for both directions degree[indexA] = (degree[indexA] ?? 0) + 1; degree[indexB] = (degree[indexB] ?? 0) + 1; } } // Build CSR arrays from degree counts const nodeCount = this.coordinates.length; // Total nodes discovered this.csrNodeCount = nodeCount; // CSR covers all built nodes const offsets = new Int32Array(nodeCount + 1); // Row pointer array for (let i = 0; i < nodeCount; i++) { const deg = degree[i] ?? 0; // Degree of node i offsets[i + 1] = offsets[i] + deg; // Prefix sum } const totalEdges = offsets[nodeCount]; // Total adjacency entries const indices = new Int32Array(totalEdges); // Neighbor indices array const distances = new Float64Array(totalEdges); // Distances array aligned to indices // Pass 2: fill CSR arrays using a write cursor per node const cursor = offsets.slice(); // Current write positions per node for (let f = 0, fLen = features.length; f < fLen; f++) { const feature = features[f]; const lineCoords = feature.geometry.coordinates; for (let i = 0, len = lineCoords.length - 1; i < len; i++) { const a = lineCoords[i]; const b = lineCoords[i + 1]; const lngA = a[0], latA = a[1]; const lngB = b[0], latB = b[1]; // Read back indices (guaranteed to exist from pass 1) const indexA = this.coordinateIndexMap.get(lngA)!.get(latA)!; const indexB = this.coordinateIndexMap.get(lngB)!.get(latB)!; const segmentDistance = measureDistance(a, b); // Edge weight once // Write A → B let pos = cursor[indexA]++; indices[pos] = indexB; distances[pos] = segmentDistance; // Write B → A pos = cursor[indexB]++; indices[pos] = indexA; distances[pos] = segmentDistance; } } // Commit CSR to instance this.csrOffsets = offsets; this.csrIndices = indices; this.csrDistances = distances; // Prepare sparse shell only for dynamically added nodes later (no prefilled neighbor arrays) this.adjacencyList = new Array(nodeCount); } /** * 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>, // Start point feature end: Feature<Point> // End point feature ): Feature<LineString> | null { if (this.network === null) { // Guard: graph must be built first throw new Error("Network not built. Please call buildRouteGraph(network) first."); } // Ensure start/end exist in index maps const startIndex = this.getOrCreateIndex(start.geometry.coordinates); // Get or insert start node index const endIndex = this.getOrCreateIndex(end.geometry.coordinates); // Get or insert end node index // Trivial case: same node if (startIndex === endIndex) { return null; // No path needed } // Local aliases const coords = this.coordinates; // Alias to coordinates array const adj = this.adjacencyList; // Alias to sparse adjacency list (for dynamic nodes) const measureDistance = this.distanceMeasurement; // Alias to distance function // Ensure and init scratch buffers const nodeCount = coords.length; // Current number of nodes (may be >= csrNodeCount if new nodes added) this.ensureScratch(nodeCount); // Allocate scratch arrays if needed // Non-null after ensure const gScore = this.gScoreScratch!; // gScore pointer const cameFrom = this.cameFromScratch!; // cameFrom pointer const visited = this.visitedScratch!; // visited pointer const hCache = this.hScratch!; // heuristic cache pointer // Reset only the used range for speed gScore.fill(Number.POSITIVE_INFINITY, 0, nodeCount); // Init gScore to +∞ cameFrom.fill(-1, 0, nodeCount); // Init predecessors to -1 (unknown) visited.fill(0, 0, nodeCount); // Init visited flags to 0 hCache.fill(-1, 0, nodeCount); // Init heuristic cache with sentinel (-1 means unknown) // Create min-heap (priority queue) const openSet = new this.heapConstructor(); // Precompute heuristic for start to prime the queue cost const endCoord = coords[endIndex]; // Cache end coordinate for heuristic let hStart = hCache[startIndex]; if (hStart < 0) { hStart = measureDistance(coords[startIndex], endCoord); hCache[startIndex] = hStart; } openSet.insert(hStart, startIndex); // Insert start with f = g(0) + h(start) gScore[startIndex] = 0; // g(start) = 0 while (openSet.size() > 0) { // Main A* loop until queue empty const current = openSet.extractMin()!; // Pop node with smallest f if (visited[current] !== 0) { // Skip if already finalized continue; } if (current === endIndex) { // Early exit if reached goal break; } visited[current] = 1; // Mark as visited // Prefer CSR neighbors if available for this node, fall back to sparse list for dynamically added nodes if (this.csrOffsets && current < this.csrNodeCount) { // Use CSR fast path const csrOffsets = this.csrOffsets!; // Local CSR offsets (non-null here) const csrIndices = this.csrIndices!; // Local CSR neighbors const csrDistances = this.csrDistances!; // Local CSR weights const startOff = csrOffsets[current]; // Row start for current const endOff = csrOffsets[current + 1]; // Row end for current for (let i = startOff; i < endOff; i++) { // Iterate neighbors in CSR slice const nbNode = csrIndices[i]; // Neighbor node id const tentativeG = gScore[current] + csrDistances[i]; // g' = g(current) + w(current, nb) if (tentativeG < gScore[nbNode]) { // Relaxation check gScore[nbNode] = tentativeG; // Update best g cameFrom[nbNode] = current; // Track predecessor // A* priority = g + h (cache h per node for this query) let hVal = hCache[nbNode]; if (hVal < 0) { hVal = measureDistance(coords[nbNode], endCoord); hCache[nbNode] = hVal; } openSet.insert(tentativeG + hVal, nbNode); // Push/update neighbor into open set } } } // Fallback: use sparse adjacency (only for nodes added after CSR build) else { const neighbors = adj[current]; // Neighbor list for current if (!neighbors || neighbors.length === 0) continue; // No neighbors for (let i = 0, n = neighbors.length; i < n; i++) { // Iterate neighbors const nb = neighbors[i]; // Neighbor entry const nbNode = nb.node; // Neighbor id const tentativeG = gScore[current] + nb.distance; // g' via current if (tentativeG < gScore[nbNode]) { // Relax if better gScore[nbNode] = tentativeG; // Update best g cameFrom[nbNode] = current; // Track predecessor // A* priority = g + h (cached) let hVal = hCache[nbNode]; if (hVal < 0) { hVal = measureDistance(coords[nbNode], endCoord); hCache[nbNode] = hVal; } openSet.insert(tentativeG + hVal, nbNode); // Enqueue neighbor } } } } // If goal was never reached/relaxed, return null if (cameFrom[endIndex] < 0) { return null; } // Reconstruct path (push + reverse to avoid O(n^2) unshift) const path: Position[] = []; let cur = endIndex; while (cur !== startIndex) { path.push(coords[cur]); cur = cameFrom[cur]; } // Include start coordinate path.push(coords[startIndex]); // Reverse to get start→end order path.reverse(); return { type: "Feature", geometry: { type: "LineString", coordinates: path }, properties: {}, }; } /** * Helper to index start/end in getRoute. */ private getOrCreateIndex(coord: Position): number { // Ensure a coordinate has a node index, creating if absent const lng = coord[0]; // Extract longitude const lat = coord[1]; // Extract latitude let latMap = this.coordinateIndexMap.get(lng); // Get lat→index map for this longitude if (latMap === undefined) { // Create if missing latMap = new Map<number, number>(); this.coordinateIndexMap.set(lng, latMap); } let index = latMap.get(lat); // Lookup index by latitude if (index === undefined) { // If not found, append new node index = this.coordinates.length; // New index at end this.coordinates.push(coord); // Store coordinate latMap.set(lat, index); // Record mapping // Ensure sparse adjacency slot for dynamically added nodes this.adjacencyList[index] = []; // Init empty neighbor array // Extend CSR offsets to keep indices consistent (no neighbors for new node) if (this.csrOffsets) { // Only adjust if CSR already built // Only need to expand offsets by one; indices/distances remain unchanged const oldCount = this.csrNodeCount; // Nodes currently covered by CSR // Appending exactly one new node at the end if (index === oldCount) { const newOffsets = new Int32Array(oldCount + 2); // Allocate offsets for +1 node newOffsets.set(this.csrOffsets, 0); // Copy previous offsets // Last offset repeats to indicate zero neighbors newOffsets[oldCount + 1] = newOffsets[oldCount]; // Replicate last pointer this.csrOffsets = newOffsets; // Swap in new offsets this.csrNodeCount = oldCount + 1; // Increment CSR node count } } } return index; } // Ensure scratch arrays are allocated with at least `size` capacity. private ensureScratch(size: number): void { const ifAlreadyBigEnough = this.scratchCapacity >= size && this.gScoreScratch && this.cameFromScratch && this.visitedScratch && this.hScratch; if (ifAlreadyBigEnough) { return; // Nothing to do } const capacity = size | 0; // Ensure integer this.gScoreScratch = new Float64Array(capacity); this.cameFromScratch = new Int32Array(capacity); this.visitedScratch = new Uint8Array(capacity); this.hScratch = new Float64Array(capacity); this.scratchCapacity = capacity; } } export { TerraRoute, createCheapRuler, haversineDistance, LineStringGraph }