import { HttpService } from '@nestjs/axios'; import { IGeorouter } from '../../domain/interfaces/georouter.interface'; import { Injectable } from '@nestjs/common'; import { catchError, lastValueFrom, map } from 'rxjs'; import { AxiosError, AxiosResponse } from 'axios'; import { IGeodesic } from '../../domain/interfaces/geodesic.interface'; import { GeorouterSettings } from '../../domain/types/georouter-settings.type'; import { Path } from '../../domain/types/path.type'; import { NamedRoute } from '../../domain/types/named-route'; import { GeographyException } from '../../exceptions/geography.exception'; import { ExceptionCode } from '../../../utils/exception-code.enum'; import { Route } from '../../domain/entities/route'; import { SpacetimePoint } from '../../domain/entities/spacetime-point'; @Injectable() export class GraphhopperGeorouter implements IGeorouter { private url: string; private urlArgs: string[]; private withTime: boolean; private withPoints: boolean; private withDistance: boolean; private paths: Path[]; private httpService: HttpService; private geodesic: IGeodesic; constructor(url: string, httpService: HttpService, geodesic: IGeodesic) { this.url = url + '/route?'; this.httpService = httpService; this.geodesic = geodesic; } route = async ( paths: Path[], settings: GeorouterSettings, ): Promise => { this.setDefaultUrlArgs(); this.setWithTime(settings.withTime); this.setWithPoints(settings.withPoints); this.setWithDistance(settings.withDistance); this.paths = paths; return await this.getRoutes(); }; private setDefaultUrlArgs = (): void => { this.urlArgs = ['vehicle=car', 'weighting=fastest', 'points_encoded=false']; }; private setWithTime = (withTime: boolean): void => { this.withTime = withTime; if (withTime) { this.urlArgs.push('details=time'); } }; private setWithPoints = (withPoints: boolean): void => { this.withPoints = withPoints; if (!withPoints) { this.urlArgs.push('calc_points=false'); } }; private setWithDistance = (withDistance: boolean): void => { this.withDistance = withDistance; if (withDistance) { this.urlArgs.push('instructions=true'); } else { this.urlArgs.push('instructions=false'); } }; private getRoutes = async (): Promise => { const routes = Promise.all( this.paths.map(async (path) => { const url: string = [ this.getUrl(), '&point=', path.points .map((point) => [point.lat, point.lon].join('%2C')) .join('&point='), ].join(''); const route = await lastValueFrom( this.httpService.get(url).pipe( map((res) => (res.data ? this.createRoute(res) : undefined)), catchError((error: AxiosError) => { if (error.code == AxiosError.ERR_BAD_REQUEST) { throw new GeographyException( ExceptionCode.OUT_OF_RANGE, 'No route found for given coordinates', ); } throw new GeographyException( ExceptionCode.UNAVAILABLE, 'Georouter unavailable : ' + error.message, ); }), ), ); return { key: path.key, route, }; }), ); return routes; }; private getUrl = (): string => { return [this.url, this.urlArgs.join('&')].join(''); }; private createRoute = ( response: AxiosResponse, ): Route => { const route = new Route(this.geodesic); if (response.data.paths && response.data.paths[0]) { const shortestPath = response.data.paths[0]; route.distance = shortestPath.distance ?? 0; route.duration = shortestPath.time ? shortestPath.time / 1000 : 0; if (shortestPath.points && shortestPath.points.coordinates) { route.setPoints( shortestPath.points.coordinates.map((coordinate) => ({ lon: coordinate[0], lat: coordinate[1], })), ); if ( shortestPath.details && shortestPath.details.time && shortestPath.snapped_waypoints && shortestPath.snapped_waypoints.coordinates ) { let instructions: GraphhopperInstruction[] = []; if (shortestPath.instructions) instructions = shortestPath.instructions; route.setSpacetimePoints( this.generateSpacetimePoints( shortestPath.points.coordinates, shortestPath.snapped_waypoints.coordinates, shortestPath.details.time, instructions, ), ); } } } return route; }; private generateSpacetimePoints = ( points: Array, snappedWaypoints: Array, durations: Array, instructions: GraphhopperInstruction[], ): SpacetimePoint[] => { const indices = this.getIndices(points, snappedWaypoints); const times = this.getTimes(durations, indices); const distances = this.getDistances(instructions, indices); return indices.map( (index) => new SpacetimePoint( { lon: points[index][1], lat: points[index][0] }, times.find((time) => time.index == index)?.duration, distances.find((distance) => distance.index == index)?.distance, ), ); }; private getIndices = ( points: Array, snappedWaypoints: Array, ): number[] => { const indices = snappedWaypoints.map((waypoint) => points.findIndex( (point) => point[0] == waypoint[0] && point[1] == waypoint[1], ), ); if (indices.find((index) => index == -1) === undefined) return indices; const missedWaypoints = indices .map( (value, index) => < { index: number; originIndex: number; waypoint: number[]; nearest: number; distance: number; } >{ index: value, originIndex: index, waypoint: snappedWaypoints[index], nearest: undefined, distance: 999999999, }, ) .filter((element) => element.index == -1); for (const index in points) { for (const missedWaypoint of missedWaypoints) { const inverse = this.geodesic.inverse( missedWaypoint.waypoint[0], missedWaypoint.waypoint[1], points[index][0], points[index][1], ); if (inverse.distance < missedWaypoint.distance) { missedWaypoint.distance = inverse.distance; missedWaypoint.nearest = parseInt(index); } } } for (const missedWaypoint of missedWaypoints) { indices[missedWaypoint.originIndex] = missedWaypoint.nearest; } return indices; }; private getTimes = ( durations: Array, indices: number[], ): Array<{ index: number; duration: number }> => { const times: Array<{ index: number; duration: number }> = []; let duration = 0; for (const [origin, destination, stepDuration] of durations) { let indexFound = false; const indexAsOrigin = indices.find((index) => index == origin); if ( indexAsOrigin !== undefined && times.find((time) => origin == time.index) == undefined ) { times.push({ index: indexAsOrigin, duration: Math.round(stepDuration / 1000), }); indexFound = true; } if (!indexFound) { const indexAsDestination = indices.find( (index) => index == destination, ); if ( indexAsDestination !== undefined && times.find((time) => destination == time.index) == undefined ) { times.push({ index: indexAsDestination, duration: Math.round((duration + stepDuration) / 1000), }); indexFound = true; } } if (!indexFound) { const indexInBetween = indices.find( (index) => origin < index && index < destination, ); if (indexInBetween !== undefined) { times.push({ index: indexInBetween, duration: Math.round((duration + stepDuration / 2) / 1000), }); } } duration += stepDuration; } return times; }; private getDistances = ( instructions: GraphhopperInstruction[], indices: number[], ): Array<{ index: number; distance: number }> => { let distance = 0; const distances: Array<{ index: number; distance: number }> = [ { index: 0, distance, }, ]; for (const instruction of instructions) { distance += instruction.distance; if ( (instruction.sign == GraphhopperSign.SIGN_WAYPOINT || instruction.sign == GraphhopperSign.SIGN_FINISH) && indices.find((index) => index == instruction.interval[0]) !== undefined ) { distances.push({ index: instruction.interval[0], distance: Math.round(distance), }); } } return distances; }; } type GraphhopperResponse = { paths: [ { distance: number; weight: number; time: number; points_encoded: boolean; bbox: number[]; points: GraphhopperCoordinates; snapped_waypoints: GraphhopperCoordinates; details: { time: Array; }; instructions: GraphhopperInstruction[]; }, ]; }; type GraphhopperCoordinates = { coordinates: Array; }; type GraphhopperInstruction = { distance: number; heading: number; sign: GraphhopperSign; interval: number[]; text: string; }; enum GraphhopperSign { SIGN_START = 0, SIGN_FINISH = 4, SIGN_WAYPOINT = 5, }