Skip to content

Commit

Permalink
feat(map): optimize route with large number of points
Browse files Browse the repository at this point in the history
  • Loading branch information
High10Hunter committed Oct 28, 2024
1 parent da368ef commit acf3ec0
Show file tree
Hide file tree
Showing 5 changed files with 232 additions and 44 deletions.
2 changes: 1 addition & 1 deletion src/modules/maps/services/constant.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const WAYPOINT_LIMIT = 12;
export const MAPBOX_WAYPOINT_LIMIT = 12;
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface IDirectionsRequest {}
export interface IDirectionsRequest {
params: any;
}
107 changes: 74 additions & 33 deletions src/modules/maps/services/providers/mapbox.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,15 @@ import { MapBoxGeocodeResponse } from '../dtos/providers/mapbox/mapbox-geocode-r
import axios from 'axios';
import { MapBoxDirectionsRequest } from '../dtos/providers/mapbox/mapbox-directions-request';
import { MapBoxDirectionsResponse } from '../dtos/providers/mapbox/mapbox-directions-response';
import { MAPBOX_WAYPOINT_LIMIT } from '../constant';
import { bulkPreprocessCoordinations } from '../utils';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';

@Injectable()
export class MapBoxService implements IMapService {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}

private readonly MAPBOX_GEOCODE_API_URL =
'https://api.mapbox.com/search/geocode/v6';
private readonly MAPBOX_OPTIMIZED_ROUTE_API_URL =
Expand Down Expand Up @@ -44,42 +50,77 @@ export class MapBoxService implements IMapService {
async directions(
request: MapBoxDirectionsRequest,
): Promise<MapBoxDirectionsResponse> {
const { origin, destination, waypoints, key } = request.params;
const coordinates = [
`${origin.lng},${origin.lat}`,
...waypoints.map((waypoint) => `${waypoint.lng},${waypoint.lat}`),
`${destination.lng},${destination.lat}`,
].join(';');

const url = `${
this.MAPBOX_OPTIMIZED_ROUTE_API_URL
}/mapbox/driving/${coordinates}?source=first&destination=last&roundtrip=false&access_token=${encodeURIComponent(
key,
)}&overview=full`;
const { key } = request.params;

try {
const response = await axios.get(url);
const preprocessWaypointsWithTooManyCoordinations =
await bulkPreprocessCoordinations(request, this.dataSource);

if (response.data.code !== 'Ok') {
return null;
}
const batchSize = MAPBOX_WAYPOINT_LIMIT - 2;
const batches = [];

return {
data: {
status: response.data.code,
routes: [
{
waypoints: response.data.waypoints,
legs: response.data.trips[0].legs,
overview_polyline: response.data.trips[0].geometry,
},
],
},
};
} catch (error) {
// eslint-disable-next-line no-console
console.error(error.message);
return null;
for (
let i = 0;
i < preprocessWaypointsWithTooManyCoordinations.length;
i += batchSize
) {
batches.push(
preprocessWaypointsWithTooManyCoordinations
.slice(i, i + batchSize)
.map((waypoint) => `${waypoint.lng},${waypoint.lat}`),
);
}

const routes = [];
for (let index = 0; index < batches.length; index++) {
const batch = batches[index];
const url = `${
this.MAPBOX_OPTIMIZED_ROUTE_API_URL
}/mapbox/driving/${batch.join(
';',
)}?source=first&destination=last&roundtrip=false&access_token=${encodeURIComponent(
key,
)}&overview=full`;

try {
const response = await axios.get(url);
if (response.data.code !== 'Ok') {
continue;
}

const currentLastWaypointCoordinate = response.data.waypoints.filter(
(waypoint) => waypoint.waypoint_index === batch.length - 1,
)[0].location;

// append the currentLastWaypointCoordinate to the next batch
if (index < batches.length - 1) {
batches[index + 1].unshift(
`${currentLastWaypointCoordinate[0]},${currentLastWaypointCoordinate[1]}`,
);
}

routes.push({
waypoints: response.data.waypoints.map((waypoint) => ({
distance: waypoint.distance,
location: [waypoint.location[0], waypoint.location[1]],
name: waypoint.name,
waypoint_index: waypoint.waypoint_index,
})),
legs: response.data.trips[0].legs,
overview_polyline: response.data.trips[0].geometry,
});
} catch (error) {
// eslint-disable-next-line no-console
console.error(error.message);
}
}

const filteredRoutes = routes.filter((route) => route !== null);

return {
data: {
status: 'Ok',
routes: filteredRoutes,
},
};
}
}
153 changes: 153 additions & 0 deletions src/modules/maps/services/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { GoogleMapDirectionsRequest } from './dtos/providers/google-map/gg-map-directions-request';
import { MapBoxDirectionsRequest } from './dtos/providers/mapbox/mapbox-directions-request';
import { IDirectionsRequest } from './dtos/directions-request.interface';
import { DataSource } from 'typeorm';

export type BulkCoordinationsPreprocessResponse = {
path_order: number;
point_type: string;
lng: number;
lat: number;
waypoint_index: number | null;
};

export async function bulkPreprocessCoordinations(
request:
| MapBoxDirectionsRequest
| GoogleMapDirectionsRequest
| IDirectionsRequest,
dataSource: DataSource,
): Promise<BulkCoordinationsPreprocessResponse[]> {
const { origin, destination, waypoints } = request.params;
const get_optimal_path_sql = `
-- Recursive CTE to find optimal path
WITH RECURSIVE sorted_path AS (
-- Base case: start with origin
SELECT
id,
point_type,
geom,
1 as path_order,
ARRAY[id] as visited_points
FROM temp_waypoints
WHERE point_type = 'origin'
UNION ALL
-- Recursive case: find next nearest point
SELECT
w.id,
w.point_type,
w.geom,
sp.path_order + 1,
sp.visited_points || w.id
FROM sorted_path sp
CROSS JOIN LATERAL (
SELECT
tw.id,
tw.point_type,
tw.geom
FROM temp_waypoints tw
WHERE tw.id != ALL(sp.visited_points)
AND (
tw.point_type != 'destination'
OR NOT EXISTS (
SELECT 1
FROM temp_waypoints tw2
WHERE tw2.id != ALL(sp.visited_points)
AND tw2.point_type = 'waypoint'
)
)
ORDER BY
CASE
WHEN tw.point_type = 'destination' AND EXISTS (
SELECT 1
FROM temp_waypoints tw2
WHERE tw2.id != ALL(sp.visited_points)
AND tw2.point_type = 'waypoint'
) THEN 2
ELSE 1
END,
sp.geom <-> tw.geom
LIMIT 1
) w
)
-- Final result with coordinates
SELECT
sp.path_order,
sp.point_type,
ST_X(sp.geom) as lng,
ST_Y(sp.geom) as lat,
CASE
WHEN sp.point_type = 'waypoint'
THEN sp.path_order - 2 -- Subtract 2 to account for origin being 1
ELSE NULL
END as waypoint_index
FROM sorted_path sp
ORDER BY sp.path_order;
`;
const queryRunner = dataSource.createQueryRunner();

try {
await queryRunner.connect();
await queryRunner.startTransaction();

// create a temporary table to store waypoints for better performance
await queryRunner.query(`
CREATE TEMPORARY TABLE IF NOT EXISTS temp_waypoints (
id SERIAL PRIMARY KEY,
point_type VARCHAR(20),
geom GEOMETRY(Point, 4326)
)
`);

// insert origin point
await queryRunner.query(`
INSERT INTO temp_waypoints (point_type, geom)
VALUES (
'origin',
ST_SetSRID(ST_MakePoint(${origin.lng}, ${origin.lat}), 4326)
)
`);

// insert waypoints from JSON
await queryRunner.query(`
INSERT INTO temp_waypoints (point_type, geom)
SELECT
'waypoint' as point_type,
ST_SetSRID(ST_MakePoint(lng, lat), 4326) as geom
FROM jsonb_to_recordset('${JSON.stringify(
waypoints,
)}') as waypoints(lat float, lng float)
`);

// insert destination point
await queryRunner.query(`
INSERT INTO temp_waypoints (point_type, geom)
VALUES (
'destination',
ST_SetSRID(ST_MakePoint(${destination.lng}, ${destination.lat}), 4326)
)
`);

// create index for better spatial query performance
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_temp_waypoints_geom ON temp_waypoints USING GIST(geom)
`);

// recursive CTE to find optimal path
const result = await queryRunner.query(get_optimal_path_sql);

// clean up
await queryRunner.query('DROP TABLE IF EXISTS temp_waypoints');
await queryRunner.query('DROP INDEX IF EXISTS idx_temp_waypoints_geom');
await queryRunner.commitTransaction();

return result;
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error executing SQL script:', error);
return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,8 @@ import { GetOptimizedRouteResponse } from './get-optimized-route.response';
import { Inject } from '@nestjs/common';
import { IMapService } from '@modules/maps/services/map.service.interface';
import { Result, failure, success } from '@core/logic/errors-handler';
import {
NotFoundOptimizedRoute,
WaypointsLimitExceeded,
} from './get-optimized-route.errors';
import { NotFoundOptimizedRoute } from './get-optimized-route.errors';
import { GetOptimizedRouteDtoResponse } from './get-optimized-route.dto';
import { WAYPOINT_LIMIT } from '@modules/maps/services/constant';

@QueryHandler(GetOptimizedRouteQuery)
export class GetOptimizedRouteUseCase
Expand All @@ -22,10 +18,6 @@ export class GetOptimizedRouteUseCase
async execute({
getOptimizedRouteRequestDto,
}: GetOptimizedRouteQuery): Promise<GetOptimizedRouteResponse> {
// check the waypoints is exceeding the limit or not
if (getOptimizedRouteRequestDto.params.waypoints.length > WAYPOINT_LIMIT) {
return failure(new WaypointsLimitExceeded());
}
const result = await this.mapService.directions(
getOptimizedRouteRequestDto,
);
Expand Down

0 comments on commit acf3ec0

Please sign in to comment.