@fleetbase/fleetops-engine
Version:
Fleet & Transport Management Extension for Fleetbase
528 lines (433 loc) • 16.6 kB
JavaScript
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { isArray } from '@ember/array';
import { debug } from '@ember/debug';
import { colorForId, routeColorForStatus, routeStyleForStatus, waypointIconHtml } from '../../utils/route-colors';
import { buildRoutePointMarkerPresentation, buildRoutePointsFromPayload } from '../../utils/route-visualization';
import ensureLeafletPluginsReady, { hasLeafletPluginsReady } from '../../utils/leaflet-plugin-loader';
const PREVIEW_MAX_ZOOM_TWO_POINTS = 13;
const PREVIEW_MAX_ZOOM_MULTI_POINTS = 12;
const GOOGLE_MAP_STYLES = [
{
featureType: 'all',
elementType: 'labels.icon',
stylers: [{ visibility: 'off' }],
},
{
featureType: 'poi',
elementType: 'all',
stylers: [{ visibility: 'off' }],
},
{
featureType: 'transit',
elementType: 'all',
stylers: [{ visibility: 'off' }],
},
{
featureType: 'administrative.land_parcel',
elementType: 'all',
stylers: [{ visibility: 'off' }],
},
{
featureType: 'road',
elementType: 'labels.icon',
stylers: [{ visibility: 'off' }],
},
{
featureType: 'landscape.man_made',
elementType: 'labels',
stylers: [{ visibility: 'off' }],
},
];
export default class AiRoutePreviewMapComponent extends Component {
mapSettings;
routeEngine;
route = null;
error = null;
isLoading = false;
leafletPluginsReady = hasLeafletPluginsReady();
googleMap = null;
leafletMap = null;
googleMarkers = [];
googlePolylines = [];
googleTrafficLayer = null;
googleTransitLayer = null;
googleListeners = [];
computeToken = 0;
googleApiLoadPromise = null;
willDestroy() {
super.willDestroy(...arguments);
this.clearGoogleListeners();
this.clearGoogleRoute();
this.googleTrafficLayer?.setMap(null);
this.googleTransitLayer?.setMap(null);
this.googleMap = null;
this.leafletMap = null;
}
get payload() {
return this.args.payload ?? {};
}
get routePoints() {
return buildRoutePointsFromPayload(this.payload);
}
get coordinates() {
return this.routePoints.map(({ place }) => [Number(place.latitude), Number(place.longitude)]).filter(([lat, lng]) => Number.isFinite(lat) && Number.isFinite(lng));
}
get canShowMap() {
return this.coordinates.length >= 2;
}
get routeSignature() {
return `${this.provider}:${this.displayRouteEngine}:${this.coordinates.map((coordinate) => coordinate.join(',')).join('|')}`;
}
get provider() {
return this.mapSettings.mapProvider ?? 'leaflet';
}
get shouldUseGoogleMaps() {
return this.provider === 'google';
}
get displayRouteEngine() {
const configuredEngine = this.routeEngine?.getDisplayEngine?.('osrm') ?? 'osrm';
return this.routeEngine?.get?.(configuredEngine) ? configuredEngine : 'osrm';
}
get routeErrorMessage() {
if (!this.error || this.isLoading || this.hasRouteLine) {
return null;
}
return 'Unable to calculate route';
}
get center() {
const [lat, lng] = this.coordinates[0] ?? [1.3521, 103.8198];
return { lat, lng };
}
get routeColor() {
return colorForId(this.args.orderId ?? 'ai-order-preview');
}
get routeStatus() {
return this.args.status ?? 'created';
}
get statusColor() {
return routeColorForStatus(this.routeStatus);
}
get routeStyles() {
return routeStyleForStatus(this.routeStatus, this.statusColor);
}
get routeCoordinates() {
return this.route?.coordinates?.length ? this.route.coordinates : this.coordinates;
}
get routeLineCoordinates() {
return this.route?.coordinates?.length ? this.route.coordinates : [];
}
get hasRouteLine() {
return this.routeLineCoordinates.length > 1;
}
get markerStops() {
return this.routePoints
.map((routePoint, index) => {
const location = [Number(routePoint.place.latitude), Number(routePoint.place.longitude)];
const presentation = buildRoutePointMarkerPresentation(routePoint, this.routeColor);
return {
...presentation,
index,
location,
title: presentation.title,
address: this.addressLabel(routePoint.place),
};
})
.filter((stop) => stop.location.every((coordinate) => Number.isFinite(coordinate)));
}
get tileUrl() {
const theme = document.body?.dataset?.theme;
if (theme === 'dark') {
return 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
}
return 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png';
}
get emptyText() {
if (this.error) {
return 'Unable to preview the route right now.';
}
return 'Select pickup and dropoff with coordinates to preview the route.';
}
addressLabel(place) {
return [place?.address, place?.street1, place?.name, place?.city, place?.postal_code, place?.country]
.map((value) => (value === null || value === undefined ? null : String(value).trim()))
.filter((value) => value && !['undefined', 'null'].includes(value.toLowerCase()))
.slice(0, 3)
.join(' - ');
}
waypointIconHtml(label, color) {
return waypointIconHtml(label, color);
}
async setupPreview() {
await this.mapSettings.load();
if (!this.shouldUseGoogleMaps && !this.leafletPluginsReady) {
await this.prepareLeafletPlugins();
}
this.syncPreview();
}
async prepareLeafletPlugins() {
if (this.leafletPluginsReady) {
return;
}
await ensureLeafletPluginsReady();
this.leafletPluginsReady = true;
}
didLoadLeafletMap({ target: map }) {
this.leafletMap = map;
this.syncPreview();
}
async setupGoogleMap(element) {
await this.mapSettings.load();
await this.loadGoogleMapsApi();
const googleMaps = window.google;
const { Map } = await googleMaps.maps.importLibrary('maps');
const mapOptions = {
center: { lat: this.center.lat, lng: this.center.lng },
zoom: 12,
mapTypeId: this.mapSettings.googleMapsMapType ?? googleMaps.maps.MapTypeId.ROADMAP,
disableDefaultUI: true,
gestureHandling: 'greedy',
clickableIcons: false,
styles: this.googleMapStyles,
};
if (this.mapSettings.googleMapsMapId) {
mapOptions.mapId = this.mapSettings.googleMapsMapId;
}
this.googleMap = new Map(element, mapOptions);
this.applyGoogleViewSettings();
this.syncPreview();
}
syncPreview() {
if (this.shouldUseGoogleMaps) {
this.applyGoogleViewSettings();
}
this.computeAndDrawRoute();
}
async computeAndDrawRoute() {
const token = ++this.computeToken;
this.error = null;
if (!this.canShowMap) {
this.route = null;
this.clearGoogleRoute();
return;
}
this.isLoading = true;
try {
const route = await this.computeRoute();
if (token !== this.computeToken) {
return;
}
this.route = route;
this.error = null;
this.fitLeafletMap();
this.drawGoogleRoute().catch((error) => this.logRenderError(error));
} catch (error) {
if (token !== this.computeToken) {
return;
}
this.error = error;
this.route = null;
this.logRouteError(error);
this.drawGoogleRoute().catch((renderError) => this.logRenderError(renderError));
} finally {
if (token === this.computeToken) {
this.isLoading = false;
}
}
}
async computeRoute() {
const engine = this.displayRouteEngine;
const options = {
payload: this.payload,
fitOptions: this.fitOptions,
};
if (!this.routeEngine?.compute) {
throw new Error('No route engine is available for the AI order preview.');
}
try {
return await this.routeEngine.compute(engine, this.coordinates, options);
} catch (error) {
if (engine !== 'osrm') {
return this.routeEngine.compute('osrm', this.coordinates, options);
}
throw error;
}
}
get fitOptions() {
return {
padding: [18, 18],
maxZoom: this.coordinates.length === 2 ? PREVIEW_MAX_ZOOM_TWO_POINTS : PREVIEW_MAX_ZOOM_MULTI_POINTS,
};
}
get googleMapStyles() {
if (this.mapSettings.showGoogleMapsTransitLayer) {
return GOOGLE_MAP_STYLES.filter((style) => style.featureType !== 'transit');
}
return GOOGLE_MAP_STYLES;
}
fitLeafletMap() {
if (!this.leafletMap || !this.routeCoordinates.length) {
return;
}
const bounds = this.route?.bounds ?? this.routeCoordinates;
if (isArray(bounds) && bounds.length > 1) {
this.leafletMap.fitBounds(bounds, this.fitOptions);
}
}
async drawGoogleRoute() {
if (!this.googleMap || !this.shouldUseGoogleMaps) {
return;
}
await this.loadGoogleMapsApi();
this.clearGoogleRoute();
const googleMaps = window.google;
const path = this.routeLineCoordinates.map(([lat, lng]) => ({ lat, lng }));
if (path.length > 1) {
this.routeStyles.forEach((style) => {
const polyline = new googleMaps.maps.Polyline({
path,
map: this.googleMap,
strokeColor: style.color,
strokeWeight: style.weight,
strokeOpacity: style.opacity,
});
this.googlePolylines.push(polyline);
});
}
const { AdvancedMarkerElement } = await googleMaps.maps.importLibrary('marker');
this.markerStops.forEach((stop) => {
const position = { lat: stop.location[0], lng: stop.location[1] };
const content = document.createElement('div');
content.className = 'fleetops-map-marker';
content.innerHTML = waypointIconHtml(stop.waypointLabel, stop.waypointColor);
let marker;
if (this.mapSettings.googleMapsMapId) {
marker = new AdvancedMarkerElement({
map: this.googleMap,
position,
title: stop.title,
content,
zIndex: stop.zIndexOffset,
});
} else {
marker = new googleMaps.maps.Marker({
map: this.googleMap,
position,
title: stop.title,
zIndex: stop.zIndexOffset,
icon: this.googleWaypointIcon(stop.waypointLabel, stop.waypointColor),
});
}
this.googleMarkers.push(marker);
});
this.fitGoogleMap();
}
fitGoogleMap() {
const googleMaps = window.google;
const map = this.googleMap;
if (!map || !googleMaps?.maps || !this.routeCoordinates.length) {
return;
}
const maxZoom = this.coordinates.length === 2 ? PREVIEW_MAX_ZOOM_TWO_POINTS : PREVIEW_MAX_ZOOM_MULTI_POINTS;
const bounds = new googleMaps.maps.LatLngBounds();
this.routeCoordinates.forEach(([lat, lng]) => bounds.extend({ lat, lng }));
map.fitBounds(bounds, 18);
this.clearGoogleListeners();
const idleListener = googleMaps.maps.event.addListenerOnce(map, 'idle', () => {
const currentZoom = map.getZoom?.();
if (Number.isFinite(currentZoom) && currentZoom > maxZoom) {
map.setZoom?.(maxZoom);
}
});
this.googleListeners.push(idleListener);
}
clearGoogleRoute() {
this.googleMarkers.forEach((marker) => {
if ('map' in marker) {
marker.map = null;
} else {
marker.setMap?.(null);
}
});
this.googlePolylines.forEach((polyline) => polyline.setMap(null));
this.googleMarkers = [];
this.googlePolylines = [];
}
clearGoogleListeners() {
const googleMaps = window.google;
this.googleListeners.forEach((listener) => {
googleMaps?.maps?.event?.removeListener?.(listener);
});
this.googleListeners = [];
}
applyGoogleViewSettings() {
if (!this.googleMap || !window.google?.maps) {
return;
}
const googleMaps = window.google;
this.googleTrafficLayer?.setMap(null);
this.googleTransitLayer?.setMap(null);
this.googleTrafficLayer = null;
this.googleTransitLayer = null;
this.googleMap.setOptions({
mapTypeId: this.mapSettings.googleMapsMapType ?? googleMaps.maps.MapTypeId.ROADMAP,
clickableIcons: false,
styles: this.googleMapStyles,
});
if (this.mapSettings.showGoogleMapsTrafficLayer) {
this.googleTrafficLayer = new googleMaps.maps.TrafficLayer();
this.googleTrafficLayer.setMap(this.googleMap);
}
if (this.mapSettings.showGoogleMapsTransitLayer) {
this.googleTransitLayer = new googleMaps.maps.TransitLayer();
this.googleTransitLayer.setMap(this.googleMap);
}
}
logRouteError(error) {
debug(`[Fleet-Ops AI Route Preview] Unable to calculate route with ${this.displayRouteEngine}. coordinates=${this.coordinates.length}. error=${error?.message ?? error}`);
}
logRenderError(error) {
debug(`[Fleet-Ops AI Route Preview] Route calculated but map rendering failed. error=${error?.message ?? error}`);
}
googleWaypointIcon(label, color) {
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="34" height="34" viewBox="0 0 34 34">
<circle cx="17" cy="17" r="15" fill="${color}" stroke="white" stroke-width="2.5" />
<text x="17" y="21" text-anchor="middle" font-family="Inter, Arial, sans-serif" font-size="13" font-weight="700" fill="white">${label}</text>
</svg>
`;
return {
url: `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`,
scaledSize: new window.google.maps.Size(34, 34),
anchor: new window.google.maps.Point(17, 17),
};
}
async loadGoogleMapsApi() {
if (window.google?.maps?.importLibrary) {
return true;
}
if (this.googleApiLoadPromise) {
return this.googleApiLoadPromise;
}
const apiKey = this.mapSettings.googleMapsApiKey;
this.googleApiLoadPromise = new Promise((resolve, reject) => {
const callbackName = `__fleetops_ai_preview_gmaps_${Date.now().toString(36)}`;
window[callbackName] = () => {
delete window[callbackName];
resolve(true);
};
const script = document.createElement('script');
script.async = true;
script.defer = true;
script.onerror = () => {
delete window[callbackName];
reject(new Error('Failed to load Google Maps API for AI route preview.'));
};
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=geometry,marker,routes&callback=${callbackName}&loading=async`;
document.head.appendChild(script);
});
return this.googleApiLoadPromise;
}
}