diff --git a/.stylelintrc.js b/.stylelintrc.js
index f1804fe2d..21ff58943 100644
--- a/.stylelintrc.js
+++ b/.stylelintrc.js
@@ -5,6 +5,7 @@ module.exports = {
rules: {
'selector-class-pattern': null,
'no-descending-specificity': null,
+ 'alpha-value-notation': null,
'color-function-notation': null,
},
};
diff --git a/addon/components/ai/create-order-preview.hbs b/addon/components/ai/create-order-preview.hbs
new file mode 100644
index 000000000..edcba9b17
--- /dev/null
+++ b/addon/components/ai/create-order-preview.hbs
@@ -0,0 +1,297 @@
+
+
+
+ {{#if @isApplying}}
+
+
+
+
+
+
+ Creating Fleet-Ops order...
+
+ {{else if this.isCompleted}}
+
+
+
+
+
+ {{this.completedMessage}}
+ {{this.orderReference}}
+
+ {{#if this.canOpenOrder}}
+
+ {{/if}}
+
+ {{else if this.isCancelled}}
+
+
+ {{this.cancelledMessage}}
+
+ {{else if this.preview.error}}
+
{{this.preview.error.message}}
+ {{else}}
+
+
+
+
+
+ Order type
+ {{#if this.isEditingOrderType}}
+
+ {{or model.name model.key}}
+
+ {{else}}
+ {{this.orderTypeLabel}}
+ {{/if}}
+
+
+
+
+
+
+ Schedule
+ {{#if this.isEditingSchedule}}
+
+ {{else}}
+
+ {{#if this.draft.scheduled_at}}
+ {{format-date-fns this.draft.scheduled_at "yyyy-MM-dd HH:mm" fallback=this.scheduledAtLabel}}
+ {{else}}
+ {{this.scheduledAtLabel}}
+ {{/if}}
+
+ {{/if}}
+
+
+
+
+
+
+
Pickup
+ {{#if this.isEditingPickup}}
+
+ {{this.addressLabel place}}
+
+ {{else}}
+
{{this.pickupLabel}}
+ {{/if}}
+
+
+
+
+
+
+
Dropoff
+ {{#if this.isEditingDropoff}}
+
+ {{this.addressLabel place}}
+
+ {{else}}
+
{{this.dropoffLabel}}
+ {{/if}}
+
+
+
+
+
+
+ Driver
+ {{#if this.isEditingDriver}}
+
+ {{driver.name}}
+
+ {{else}}
+ {{this.driverLabel}}
+ {{/if}}
+
+
+
+
+
+
+ Vehicle
+ {{#if this.isEditingVehicle}}
+
+ {{or vehicle.display_name vehicle.name vehicle.plate_number}}
+
+ {{else}}
+ {{this.vehicleLabel}}
+ {{/if}}
+
+
+
+
+
+
+ Notes
+ {{#if this.isEditingNotes}}
+
+ {{else}}
+ {{this.notesLabel}}
+ {{/if}}
+
+
+
+
+
+
+
+
+ {{#if this.draft.pod_required}}
+ {{#if this.isEditingPodMethod}}
+
+ {{else}}
+
+ {{/if}}
+ {{/if}}
+
+
+
+
+
+
+ {{#if this.hasMissingFields}}
+
+
Required before apply
+
+ {{#each this.missingFields as |field|}}
+ - {{field}}
+ {{/each}}
+
+
+ {{/if}}
+
+
+
+
+ {{#if this.refreshPreview.isRunning}}
+ Updating preview...
+ {{/if}}
+
+ {{/if}}
+
diff --git a/addon/components/ai/create-order-preview.js b/addon/components/ai/create-order-preview.js
new file mode 100644
index 000000000..ce04f8ff8
--- /dev/null
+++ b/addon/components/ai/create-order-preview.js
@@ -0,0 +1,362 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import { action, get } from '@ember/object';
+import { task, timeout } from 'ember-concurrency';
+
+export default class AiCreateOrderPreviewComponent extends Component {
+ @service router;
+
+ @tracked draft = this.clone(this.args.preview?.draft ?? {});
+ @tracked preview = this.args.preview;
+ @tracked editingField = null;
+
+ get payload() {
+ return this.draft.payload ?? {};
+ }
+
+ get routeStops() {
+ return this.preview?.route_preview?.stops ?? [];
+ }
+
+ get podMethods() {
+ return this.preview?.options?.pod_methods ?? ['scan', 'signature', 'photo'];
+ }
+
+ get isReady() {
+ return this.preview?.ready === true && !this.isTerminal;
+ }
+
+ get isCompleted() {
+ return Boolean(this.preview?.result);
+ }
+
+ get isTerminal() {
+ return this.isCompleted || this.isCancelled || Boolean(this.preview?.error);
+ }
+
+ get isCancelled() {
+ return this.args.task?.status === 'cancelled' || this.preview?.error?.type === 'cancelled';
+ }
+
+ get cancelledMessage() {
+ return this.preview?.error?.message ?? 'This order creation preview was cancelled.';
+ }
+
+ get completedMessage() {
+ return this.preview?.result?.message ?? 'Fleet-Ops order was created.';
+ }
+
+ get resultResource() {
+ return this.preview?.result?.resource;
+ }
+
+ get orderReference() {
+ return this.resultResource?.id ?? this.resultResource?.uuid ?? 'Created order';
+ }
+
+ get canOpenOrder() {
+ return Boolean(this.resultResource?.route && (this.resultResource?.models?.length || this.resultResource?.uuid || this.resultResource?.id));
+ }
+
+ get missingFields() {
+ return this.preview?.missing_fields ?? [];
+ }
+
+ get hasMissingFields() {
+ return this.missingFields.length > 0;
+ }
+
+ get orderPreviewId() {
+ return this.args.task?.uuid ?? this.args.task?.id ?? this.preview?.key ?? 'ai-order-preview';
+ }
+
+ get orderTypeLabel() {
+ return this.titleize(this.draft.type ?? this.draft.order_config_name ?? this.draft.order_config_uuid) ?? 'Select order type';
+ }
+
+ get scheduledAtLabel() {
+ const scheduledAt = this.draft.scheduled_at;
+
+ if (!scheduledAt) {
+ return 'Add schedule';
+ }
+
+ return String(scheduledAt);
+ }
+
+ get pickupLabel() {
+ return this.addressLabel(this.payload.pickup) ?? this.payload.pickup_query ?? 'Add pickup';
+ }
+
+ get dropoffLabel() {
+ return this.addressLabel(this.payload.dropoff) ?? this.payload.dropoff_query ?? 'Add dropoff';
+ }
+
+ get driverLabel() {
+ return this.draft.driver_query ?? this.draft.driver_name ?? 'Assign driver';
+ }
+
+ get vehicleLabel() {
+ return this.draft.vehicle_query ?? this.draft.vehicle_name ?? 'Assign vehicle';
+ }
+
+ get notesLabel() {
+ return this.draft.notes?.trim?.() || 'Add order notes';
+ }
+
+ get podMethodLabel() {
+ return this.titleize(this.draft.pod_method ?? 'scan');
+ }
+
+ get isEditingOrderType() {
+ return this.editingField === 'orderType';
+ }
+
+ get isEditingSchedule() {
+ return this.editingField === 'schedule';
+ }
+
+ get isEditingPickup() {
+ return this.editingField === 'pickup';
+ }
+
+ get isEditingDropoff() {
+ return this.editingField === 'dropoff';
+ }
+
+ get isEditingDriver() {
+ return this.editingField === 'driver';
+ }
+
+ get isEditingVehicle() {
+ return this.editingField === 'vehicle';
+ }
+
+ get isEditingPodMethod() {
+ return this.editingField === 'podMethod';
+ }
+
+ get isEditingNotes() {
+ return this.editingField === 'notes';
+ }
+
+ get isApplyDisabled() {
+ return !this.isReady || this.args.isApplying || this.refreshPreview.isRunning;
+ }
+
+ addressLabel(place) {
+ if (!place) {
+ return null;
+ }
+
+ const values = [place.address, place.street1, place.name, place.query, 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()));
+
+ return [...new Set(values)].slice(0, 3).join(' - ');
+ }
+
+ clone(value) {
+ return JSON.parse(JSON.stringify(value ?? {}));
+ }
+
+ modelValue(model, key) {
+ if (!model) {
+ return null;
+ }
+
+ if (typeof model.get === 'function') {
+ return model.get(key);
+ }
+
+ return get(model, key) ?? model[key];
+ }
+
+ serializeModel(model, fallback = {}) {
+ if (!model) {
+ return null;
+ }
+
+ if (typeof model.toJSON === 'function') {
+ return {
+ ...model.toJSON(),
+ id: this.modelValue(model, 'id'),
+ uuid: this.modelValue(model, 'uuid') ?? this.modelValue(model, 'id'),
+ public_id: this.modelValue(model, 'public_id'),
+ name: this.modelValue(model, 'name'),
+ address: this.addressLabel(model) ?? this.modelValue(model, 'address'),
+ street1: this.modelValue(model, 'street1'),
+ city: this.modelValue(model, 'city'),
+ postal_code: this.modelValue(model, 'postal_code'),
+ country: this.modelValue(model, 'country'),
+ latitude: this.modelValue(model, 'latitude'),
+ longitude: this.modelValue(model, 'longitude'),
+ ...fallback,
+ };
+ }
+
+ return { ...model, ...fallback };
+ }
+
+ titleize(value) {
+ if (!value) {
+ return null;
+ }
+
+ return String(value)
+ .replace(/[_-]+/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .replace(/\b\w/g, (char) => char.toUpperCase());
+ }
+
+ updateDraft(updates = {}) {
+ this.draft = {
+ ...this.draft,
+ ...updates,
+ payload: {
+ ...(this.draft.payload ?? {}),
+ ...(updates.payload ?? {}),
+ },
+ };
+ }
+
+ updateRouteDraft(updates = {}) {
+ this.updateDraft(updates);
+ this.refreshPreview.perform();
+ }
+
+ @action editField(field) {
+ if (this.isTerminal) {
+ return;
+ }
+
+ this.editingField = field;
+ }
+
+ @action closeEditor() {
+ this.editingField = null;
+ }
+
+ @task *refreshPreview() {
+ yield timeout(180);
+ const refreshed = yield this.args.onRefresh?.(this.args.task, this.preview, { draft: this.draft });
+ if (refreshed) {
+ this.preview = {
+ ...this.preview,
+ ...refreshed,
+ draft: this.draft,
+ };
+ }
+ }
+
+ @action setPayloadPlace(role, place) {
+ const serialized = this.serializeModel(place);
+ const payload = { ...(this.draft.payload ?? {}) };
+
+ payload[role] = serialized;
+ payload[`${role}_query`] = this.addressLabel(serialized) ?? serialized?.address ?? serialized?.name ?? null;
+
+ if (serialized?.uuid) {
+ payload[`${role}_uuid`] = serialized.uuid;
+ } else {
+ delete payload[`${role}_uuid`];
+ }
+
+ this.updateRouteDraft({ payload });
+ this.closeEditor();
+ }
+
+ @action setOrderConfig(orderConfig) {
+ this.updateDraft({
+ order_config_uuid: this.modelValue(orderConfig, 'uuid') ?? this.modelValue(orderConfig, 'id'),
+ order_config_name: this.modelValue(orderConfig, 'name'),
+ type: this.modelValue(orderConfig, 'key'),
+ });
+ this.closeEditor();
+ }
+
+ @action setScheduledAt(value) {
+ this.updateDraft({ scheduled_at: value });
+ }
+
+ @action setDriver(driver) {
+ this.updateDraft({
+ driver: this.modelValue(driver, 'uuid') ?? this.modelValue(driver, 'id'),
+ driver_query: this.modelValue(driver, 'name') ?? this.modelValue(driver, 'public_id'),
+ });
+ this.closeEditor();
+ }
+
+ @action setVehicle(vehicle) {
+ this.updateDraft({
+ vehicle_assigned_uuid: this.modelValue(vehicle, 'uuid') ?? this.modelValue(vehicle, 'id'),
+ vehicle_query: this.modelValue(vehicle, 'display_name') ?? this.modelValue(vehicle, 'name') ?? this.modelValue(vehicle, 'plate_number'),
+ });
+ this.closeEditor();
+ }
+
+ @action setPodRequired(value) {
+ this.updateDraft({
+ pod_required: value,
+ pod_method: value ? (this.draft.pod_method ?? 'scan') : null,
+ });
+ }
+
+ @action setPodMethod(value) {
+ this.updateDraft({ pod_method: value });
+ this.closeEditor();
+ }
+
+ @action setPodMethodFromEvent(event) {
+ this.setPodMethod(event.target.value);
+ }
+
+ @action setDispatched(value) {
+ this.updateDraft({ dispatched: value });
+ }
+
+ @action setNotes(valueOrEvent) {
+ const value = valueOrEvent?.target ? valueOrEvent.target.value : valueOrEvent;
+
+ this.updateDraft({ notes: value });
+ }
+
+ @action apply() {
+ if (this.isTerminal) {
+ return;
+ }
+
+ return this.args.onApply?.(this.args.task, this.preview, { draft: this.draft });
+ }
+
+ @action cancel() {
+ if (this.isTerminal) {
+ return;
+ }
+
+ return this.args.onCancel?.(this.args.task);
+ }
+
+ @action openOrder() {
+ if (!this.canOpenOrder) {
+ return;
+ }
+
+ const models = this.resultResource.models?.length ? this.resultResource.models : [this.resultResource.uuid ?? this.resultResource.id].filter(Boolean);
+
+ try {
+ this.router.transitionTo(this.resultResource.route, ...models);
+ } catch {
+ this.router.transitionTo('operations.orders.index.details', ...models);
+ }
+ }
+
+ @action updatePreview(preview) {
+ this.preview = preview;
+ if (preview?.draft && !this.editingField) {
+ this.draft = this.clone(preview.draft);
+ }
+ }
+}
diff --git a/addon/components/ai/route-preview-map.hbs b/addon/components/ai/route-preview-map.hbs
new file mode 100644
index 000000000..3988ce5ab
--- /dev/null
+++ b/addon/components/ai/route-preview-map.hbs
@@ -0,0 +1,79 @@
+
+ {{#if this.canShowMap}}
+ {{#if this.shouldUseGoogleMaps}}
+
+ {{else if this.leafletPluginsReady}}
+
+
+ {{#if this.hasRouteLine}}
+ {{#each this.routeStyles as |style|}}
+
+ {{/each}}
+ {{/if}}
+ {{#each this.markerStops as |stop|}}
+
+
+ {{stop.title}}{{#if stop.address}} - {{stop.address}}{{/if}}
+
+
+ {{/each}}
+
+ {{else}}
+
+
+ Preparing map preview...
+
+ {{/if}}
+
+ {{#if this.isLoading}}
+
+
+ Rendering route...
+
+ {{else if this.routeErrorMessage}}
+
+
+ {{this.routeErrorMessage}}
+
+ {{/if}}
+ {{else}}
+
+
+ {{this.emptyText}}
+
+ {{/if}}
+
diff --git a/addon/components/ai/route-preview-map.js b/addon/components/ai/route-preview-map.js
new file mode 100644
index 000000000..be7e145f0
--- /dev/null
+++ b/addon/components/ai/route-preview-map.js
@@ -0,0 +1,527 @@
+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 {
+ @service mapSettings;
+ @service routeEngine;
+
+ @tracked route = null;
+ @tracked error = null;
+ @tracked isLoading = false;
+ @tracked leafletPluginsReady = hasLeafletPluginsReady();
+ @tracked 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);
+ }
+
+ @action async setupPreview() {
+ await this.mapSettings.load();
+
+ if (!this.shouldUseGoogleMaps && !this.leafletPluginsReady) {
+ await this.prepareLeafletPlugins();
+ }
+
+ this.syncPreview();
+ }
+
+ @action async prepareLeafletPlugins() {
+ if (this.leafletPluginsReady) {
+ return;
+ }
+
+ await ensureLeafletPluginsReady();
+ this.leafletPluginsReady = true;
+ }
+
+ @action didLoadLeafletMap({ target: map }) {
+ this.leafletMap = map;
+ this.syncPreview();
+ }
+
+ @action 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();
+ }
+
+ @action 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 = `
+
{{t "common.name"}}
-
{{n-a @resource.name}}
+
{{n-a this.resource.name}}
{{t "resource.service-area"}}
-
{{n-a @resource.service_area.name}}
+
{{n-a this.resource.service_area.name}}
{{t "resource.zone"}}
-
{{n-a @resource.zone.name}}
+
{{n-a this.resource.zone.name}}
{{t "fleet.fields.task"}}
-
{{n-a @resource.task}}
+
{{n-a this.resource.task}}
{{t "fleet.fields.active-manpower"}}
-
{{@resource.drivers_online_count}} of {{@resource.drivers_count}} Online
+
{{this.driversOnlineCount}} of {{this.driversCount}} Online
{{t "common.date-created"}}
-
{{@resource.createdAtShort}}
+
{{this.resource.createdAtShort}}
-