@fleetbase/fleetops-engine
Version:
Fleet & Transport Management Extension for Fleetbase
301 lines (242 loc) • 11.1 kB
JavaScript
/* global google */
import RouteOptimizationInterfaceService from './route-optimization-interface';
import { isArray } from '@ember/array';
import { inject as service } from '@ember/service';
export default class GoogleRoutesService extends RouteOptimizationInterfaceService {
mapSettings;
name = 'Google';
_apiLoadPromise = null;
async computeRoute(waypoints = [], options = {}) {
const normalizedWaypoints = this.#normalizeWaypoints(waypoints);
if (normalizedWaypoints.length < 2) {
throw new Error('At least 2 waypoints are required to compute a route.');
}
await this.#ensureApiLoaded(options);
const { Route, TravelMode, RoutingPreference } = await google.maps.importLibrary('routes');
const optimizeWaypointOrder = options.optimizeWaypointOrder === true;
const request = {
origin: normalizedWaypoints[0],
destination: normalizedWaypoints[normalizedWaypoints.length - 1],
travelMode: TravelMode.DRIVING,
routingPreference: optimizeWaypointOrder ? RoutingPreference.TRAFFIC_AWARE : RoutingPreference.TRAFFIC_AWARE_OPTIMAL,
fields: ['path', 'viewport', 'distanceMeters', 'durationMillis', 'legs', 'optimizedIntermediateWaypointIndices'],
};
if (normalizedWaypoints.length > 2) {
request.intermediates = normalizedWaypoints.slice(1, -1).map((waypoint) => ({ location: waypoint, via: false }));
}
if (optimizeWaypointOrder) {
request.optimizeWaypointOrder = true;
}
const response = await Route.computeRoutes(request);
const route = response?.routes?.[0];
if (!route?.path?.length) {
throw new Error('Google route computation returned no path.');
}
const coordinates = route.path.map((point) => [typeof point.lat === 'function' ? point.lat() : point.lat, typeof point.lng === 'function' ? point.lng() : point.lng]);
const optimizedIndices = isArray(route.optimizedIntermediateWaypointIndices) ? route.optimizedIntermediateWaypointIndices : [];
const bounds = this.#extractBounds(route.viewport) ?? this.#boundsFromCoordinates(coordinates);
return {
engine: 'google',
waypoints: this.#reorderWaypoints(waypoints, optimizedIndices),
coordinates,
bounds,
summary: {
totalDistance: route.distanceMeters ?? 0,
totalTime: route.durationMillis ? route.durationMillis / 1000 : 0,
},
legs: isArray(route.legs)
? route.legs.map((leg) => ({
distanceMeters: leg.distanceMeters ?? 0,
durationMillis: leg.durationMillis ?? 0,
startLocation: leg.startLocation,
endLocation: leg.endLocation,
}))
: [],
raw: route,
optimizedIntermediateWaypointIndices: optimizedIndices,
};
}
async optimize({ order, waypoints, coordinates, preserveEndpoints = false }, options = {}) {
const route = await this.computeRoute(this.#normalizeOptimizationCoordinates(order, coordinates), {
...options,
optimizeWaypointOrder: true,
});
let sortedWaypoints = waypoints;
if (isArray(route.waypoints) && route.waypoints.length > 0) {
sortedWaypoints = this.#reorderModelsByRouteWaypoints(waypoints, route.waypoints, preserveEndpoints);
} else if (isArray(route.optimizedIntermediateWaypointIndices) && route.optimizedIntermediateWaypointIndices.length > 0) {
sortedWaypoints = this.#reorderModelWaypoints(waypoints, route.optimizedIntermediateWaypointIndices, preserveEndpoints);
}
return {
sortedWaypoints,
route: route.coordinates,
trip: {
distance: route.summary.totalDistance,
duration: route.summary.totalTime,
legs: route.legs,
},
result: route.raw,
engine: 'google',
};
}
#normalizeOptimizationCoordinates(order, coordinates = []) {
const driverPosition = order?.driver_assigned?.location?.coordinates;
const normalized = (isArray(coordinates) ? coordinates : []).map(([lng, lat]) => [lat, lng]);
if (this.#isValidDriverPosition(driverPosition)) {
return [[driverPosition[1], driverPosition[0]], ...normalized];
}
return normalized;
}
#isValidDriverPosition(driverPosition) {
if (!isArray(driverPosition) || driverPosition.length < 2) {
return false;
}
const [lng, lat] = driverPosition.map(Number);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
return false;
}
return lat !== 0 && lng !== 0;
}
#normalizeWaypoints(waypoints = []) {
return (isArray(waypoints) ? waypoints : [])
.map((waypoint) => {
if (isArray(waypoint) && waypoint.length >= 2) {
return { lat: Number(waypoint[0]), lng: Number(waypoint[1]) };
}
if (Number.isFinite(waypoint?.lat) && Number.isFinite(waypoint?.lng)) {
return { lat: Number(waypoint.lat), lng: Number(waypoint.lng) };
}
return null;
})
.filter((waypoint) => Number.isFinite(waypoint?.lat) && Number.isFinite(waypoint?.lng));
}
#reorderWaypoints(waypoints = [], optimizedIndices = []) {
if (!isArray(optimizedIndices) || optimizedIndices.length === 0 || waypoints.length < 3) {
return waypoints;
}
const start = waypoints[0];
const end = waypoints[waypoints.length - 1];
const intermediates = waypoints.slice(1, -1);
const orderedIntermediates = optimizedIndices.map((index) => intermediates[index]).filter(Boolean);
return [start, ...orderedIntermediates, end];
}
#reorderModelWaypoints(waypoints = [], optimizedIndices = [], preserveEndpoints = false) {
if (!isArray(optimizedIndices) || optimizedIndices.length === 0 || !isArray(waypoints) || waypoints.length < 3) {
return waypoints;
}
if (!preserveEndpoints) {
const start = waypoints[0];
const end = waypoints[waypoints.length - 1];
const intermediates = waypoints.slice(1, -1);
const orderedIntermediates = optimizedIndices.map((index) => intermediates[index]).filter(Boolean);
return [start, ...orderedIntermediates, end];
}
const start = waypoints[0];
const end = waypoints[waypoints.length - 1];
const intermediates = waypoints.slice(1, -1);
const orderedIntermediates = optimizedIndices.map((index) => intermediates[index]).filter(Boolean);
return [start, ...orderedIntermediates, end];
}
#reorderModelsByRouteWaypoints(waypoints = [], routeWaypoints = [], preserveEndpoints = false) {
if (!isArray(waypoints) || !waypoints.length || !isArray(routeWaypoints) || !routeWaypoints.length) {
return waypoints;
}
const waypointOrder = preserveEndpoints ? routeWaypoints.slice(1, -1) : routeWaypoints;
const buckets = new Map();
for (const waypoint of waypoints) {
const key = this.#modelCoordinateKey(waypoint);
if (!key) {
continue;
}
if (!buckets.has(key)) {
buckets.set(key, []);
}
buckets.get(key).push(waypoint);
}
const orderedWaypoints = [];
for (const routeWaypoint of waypointOrder) {
const key = this.#coordinateKey(routeWaypoint?.lat, routeWaypoint?.lng);
const bucket = buckets.get(key);
if (bucket?.length) {
orderedWaypoints.push(bucket.shift());
}
}
if (orderedWaypoints.length === waypoints.length) {
return orderedWaypoints;
}
return waypoints;
}
#modelCoordinateKey(waypoint) {
const place = waypoint?.place ?? waypoint;
return this.#coordinateKey(place?.latitude ?? waypoint?.latitude, place?.longitude ?? waypoint?.longitude);
}
#coordinateKey(lat, lng) {
const normalizedLat = Number(lat);
const normalizedLng = Number(lng);
if (!Number.isFinite(normalizedLat) || !Number.isFinite(normalizedLng)) {
return null;
}
return `${normalizedLat.toFixed(6)}:${normalizedLng.toFixed(6)}`;
}
#boundsFromCoordinates(coordinates = []) {
if (!coordinates.length) {
return [
[0, 0],
[0, 0],
];
}
const lats = coordinates.map(([lat]) => lat);
const lngs = coordinates.map(([, lng]) => lng);
return [
[Math.min(...lats), Math.min(...lngs)],
[Math.max(...lats), Math.max(...lngs)],
];
}
#extractBounds(viewport) {
if (!viewport) return null;
if (typeof viewport.getSouthWest === 'function' && typeof viewport.getNorthEast === 'function') {
const sw = viewport.getSouthWest();
const ne = viewport.getNorthEast();
return [
[sw.lat(), sw.lng()],
[ne.lat(), ne.lng()],
];
}
if (Number.isFinite(viewport.south) && Number.isFinite(viewport.west) && Number.isFinite(viewport.north) && Number.isFinite(viewport.east)) {
return [
[viewport.south, viewport.west],
[viewport.north, viewport.east],
];
}
return null;
}
async #ensureApiLoaded(options = {}) {
if (window.google?.maps?.importLibrary) {
return true;
}
if (this._apiLoadPromise) {
return this._apiLoadPromise;
}
const apiKey = options.apiKey ?? this.mapSettings.googleMapsApiKey;
if (!apiKey) {
throw new Error('Google Maps API key is required for Google route computation.');
}
this._apiLoadPromise = new Promise((resolve, reject) => {
const callbackName = `__fleetopsGoogleRoutesInit_${Date.now().toString(36)}`;
window[callbackName] = () => {
delete window[callbackName];
resolve(true);
};
const script = document.createElement('script');
script.async = true;
script.defer = true;
script.onerror = (error) => {
delete window[callbackName];
reject(error);
};
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=routes,geometry,marker&callback=${callbackName}&loading=async`;
document.head.appendChild(script);
});
return this._apiLoadPromise;
}
}