UNPKG

minotor

Version:

A lightweight client-side transit routing library.

191 lines (173 loc) 5.97 kB
import { BinaryReader, BinaryWriter } from '@bufbuild/protobuf/wire'; import { around } from 'geokdbush'; import KDTree from 'kdbush'; import { addAll, createIndex, search, SearchResult } from 'slimsearch'; import { generateAccentVariants } from './i18n.js'; import { deserializeStopsMap, serializeStopsMap } from './io.js'; import { StopsMap as ProtoStopsMap } from './proto/stops.js'; import { SourceStopId, SourceStopsMap, Stop, StopId } from './stops.js'; type StopPoint = { id: StopId; lat: number; lon: number }; /** * The StopMap class provides functionality to search for public transport stops * by name or geographic location. It leverages text search and geospatial indexing * to efficiently find stops based on user queries. */ export class StopsIndex { private readonly stops: Stop[]; private readonly sourceStopsMap: SourceStopsMap; private readonly textIndex; private readonly geoIndex: KDTree; private readonly stopPoints: StopPoint[]; constructor(stops: Stop[]) { this.stops = stops; this.sourceStopsMap = new Map<SourceStopId, StopId>(); const stopsSet = new Map<StopId, { id: StopId; name: string }>(); this.stopPoints = []; for (let id = 0; id < stops.length; id++) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const stop = stops[id]!; this.sourceStopsMap.set(stop.sourceStopId, id); const effectiveStopId = stop.parent ?? id; if (!stopsSet.has(effectiveStopId)) { stopsSet.set(effectiveStopId, { id: effectiveStopId, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion name: stop.parent ? this.stops[stop.parent]!.name : stop.name, }); } if (stop.lat && stop.lon) { this.stopPoints.push({ id: id, lat: stop.lat, lon: stop.lon, }); } } this.textIndex = createIndex({ fields: ['name'], storeFields: ['id'], searchOptions: { prefix: true, fuzzy: 0.2 }, processTerm: generateAccentVariants, }); const stopsArray = Array.from(stopsSet.values()); addAll(this.textIndex, stopsArray); this.geoIndex = new KDTree(this.stopPoints.length); for (let i = 0; i < this.stopPoints.length; i++) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const { lat, lon } = this.stopPoints[i]!; this.geoIndex.add(lon, lat); } this.geoIndex.finish(); } /** * Deserializes a binary representation of the stops. * * @param data - The binary data to deserialize. * @returns The deserialized StopFinder. */ static fromData(data: Uint8Array): StopsIndex { const reader = new BinaryReader(data); const protoStopsMap = ProtoStopsMap.decode(reader); return new StopsIndex(deserializeStopsMap(protoStopsMap)); } /** * Serializes the stops into a binary protobuf. * * @returns The serialized binary data. */ serialize(): Uint8Array { const protoStopsMap: ProtoStopsMap = serializeStopsMap(this.stops); const writer = new BinaryWriter(); ProtoStopsMap.encode(protoStopsMap, writer); return writer.finish(); } /** * Returns the number of stops in the index. * * @returns The total number of stops. */ size(): number { return this.stops.length; } /** * Finds stops by their name using a text search. * * @param query - The name or partial name of the stop to search for. * @param maxResults - The maximum number of results to return (default is 5). * @returns An array of Stop objects that match the search query. */ findStopsByName(query: string, maxResults = 5): Stop[] { const results = search(this.textIndex, query).map( (result: SearchResult) => this.stops[result.id as number] as Stop, ); return results.slice(0, maxResults); } /** * Finds stops by their geographic location using latitude and longitude. * * @param lat - The latitude of the location to search near. * @param lon - The longitude of the location to search near. * @param maxResults - The maximum number of results to return (default is 10). * @param radius - The search radius in kilometers (default is 0.5). * @returns An array of Stop objects that are closest to the specified location. */ findStopsByLocation( lat: number, lon: number, maxResults = 5, radius = 0.5, ): Stop[] { const nearestStops = around( this.geoIndex, lon, lat, maxResults, radius, ).map((id) => { const stopPoint = this.stopPoints[id as number] as StopPoint; return this.stops[stopPoint.id] as Stop; }); return nearestStops; } /** * Finds a stop by its internal ID. * * @param id - The internal ID of the stop to search for. * @returns The Stop object that matches the specified ID, or undefined if not found. */ findStopById(id: StopId): Stop | undefined { return this.stops[id]; } /** * Finds a stop by its ID in the transit data source (e.g. GTFS). * * @param id - The source ID of the stop to search for. * @returns The Stop object that matches the specified ID, or undefined if not found. */ findStopBySourceStopId(sourceStopId: SourceStopId): Stop | undefined { const stopId = this.sourceStopsMap.get(sourceStopId); if (stopId === undefined) { return; } return this.findStopById(stopId); } /** * Find ids of all sibling stops. */ equivalentStops(sourceId: SourceStopId): Stop[] { const id = this.sourceStopsMap.get(sourceId); if (id === undefined) { return []; } const stop = this.stops[id]; if (!stop) { return []; } const equivalentStops = stop.parent ? (this.stops[stop.parent]?.children ?? []) : stop.children; return Array.from(new Set([id, ...equivalentStops])).map( (stopId) => this.stops[stopId] as Stop, ); } }