@ducna01120/fleetops-engine
Version:
Fleet & Transport Management Extension for Fleetbase
1,574 lines (1,369 loc) • 63.1 kB
JavaScript
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { action, set } from '@ember/object';
import { isArray } from '@ember/array';
import { dasherize, camelize, classify } from '@ember/string';
import { singularize } from 'ember-inflector';
import { later } from '@ember/runloop';
import { debug } from '@ember/debug';
import { allSettled } from 'rsvp';
import { task } from 'ember-concurrency-decorators';
import { OSRMv1, Control as RoutingControl } from '@fleetbase/leaflet-routing-machine';
import getRoutingHost from '@fleetbase/ember-core/utils/get-routing-host';
import getWithDefault from '@fleetbase/ember-core/utils/get-with-default';
/**
* Component which displays live activity.
*
* @class
*/
const MAP_TARGET_FOCUS_PADDING_BOTTOM_RIGHT = [200, 0];
const MAP_TARGET_FOCUS_REFOCUS_PANBY = [150, 0];
export default class LiveMapComponent extends Component {
@service store;
@service intl;
@service fetch;
@service socket;
@service currentUser;
@service notifications;
@service serviceAreas;
@service location;
@service appCache;
@service universe;
@service abilities;
@service movementTracker;
@service crud;
@service contextPanel;
@service leafletMapManager;
@service leafletContextmenuManager;
@service theme;
/**
* An array of routes.
* @type {Array}
* @memberof LiveMapComponent
*/
@tracked routes = [];
/**
* An array of drivers.
* @type {Array}
* @memberof LiveMapComponent
*/
@tracked drivers = [];
/**
* An array of vehicles.
* @type {Array}
* @memberof LiveMapComponent
*/
@tracked vehicles = [];
/**
* An array of places.
* @type {Array}
* @memberof LiveMapComponent
*/
@tracked places = [];
/**
* An array of channels.
* @type {Array}
* @memberof LiveMapComponent
*/
@tracked channels = [];
/**
* Indicates if data is loading.
* @type {boolean}
* @memberof LiveMapComponent
*/
@tracked isLoading = true;
/**
* Indicates if the component is ready.
* @type {boolean}
* @memberof LiveMapComponent
*/
@tracked isReady = false;
/**
* Indicates if all the data requested has completed loading.
* @type {boolean}
* @memberof LiveMapComponent
*/
@tracked isDataLoaded = false;
/**
* Controls for visibility.
* @type {Object}
* @memberof LiveMapComponent
*/
@tracked visibilityControls = {
vehicles: true,
onlineVehicles: true,
offlineVehicles: true,
drivers: true,
onlineDrivers: true,
offlineDrivers: true,
places: true,
};
/**
* An array of active service areas.
* @type {Array}
* @memberof LiveMapComponent
*/
@tracked activeServiceAreas = [];
/**
* An array of editable map layers.
* @type {Array}
* @memberof LiveMapComponent
*/
@tracked editableLayers = [];
/**
* The Leaflet map instance.
* @type {Object}
* @memberof LiveMapComponent
*/
@tracked leafletMap;
/**
* The Drawer component context API.
* @type {Object}
* @memberof LiveMapComponent
*/
@tracked drawer;
/**
* The map's zoom level.
* @type {number}
* @memberof LiveMapComponent
*/
@tracked zoom = 12;
/**
* The feature group for drawing on the map.
* @type {Object}
* @memberof LiveMapComponent
*/
@tracked drawFeatureGroup;
/**
* The draw control for the map.
* @type {Object}
* @memberof LiveMapComponent
*/
@tracked drawControl;
/**
* The URL for the map's tile source.
* @type {string}
* @memberof LiveMapComponent
*/
@tracked tileSourceUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png';
@tracked mapTheme = 'light';
/**
* The latitude for the map view.
* @type {number}
* @memberof LiveMapComponent
*/
@tracked latitude = this.location.getLatitude();
/**
* The longitude for the map view.
* @type {number}
* @memberof LiveMapComponent
*/
@tracked longitude = this.location.getLongitude();
/**
* Indicates if coordinate setting should be skipped.
* @type {boolean}
* @memberof LiveMapComponent
*/
@tracked skipSetCoordinates = false;
/**
* ID of interval to check if leaflet plugins has loaded.
* @type {boolean}
* @memberof LiveMapComponent
*/
@tracked leafletPluginsLoadedCheckId = false;
/**
* Cache for storing original state of resource arrays.
* @type {Object.<string, Array>}
* @memberof LiveMapComponent
*/
originalResources = {};
/**
* Creates an instance of LiveMapComponent.
* @memberof LiveMapComponent
*/
constructor(owner, { zoom = 12, darkMode = false }) {
super(...arguments);
this.zoom = zoom;
this.changeTileSource(darkMode ? 'dark' : 'light');
this.movementTracker.registerTrackingMarker(owner);
this.setupComponent();
}
changeTileSource(sourceUrl = null) {
if (sourceUrl === 'dark') {
this.mapTheme = 'dark';
this.tileSourceUrl = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
} else if (sourceUrl === 'light') {
this.mapTheme = 'light';
this.tileSourceUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png';
} else if (typeof sourceUrl === 'string' && sourceUrl.startsWith('https://')) {
this.mapTheme = 'custom';
this.tileSourceUrl = sourceUrl;
} else {
this.mapTheme = 'light';
this.tileSourceUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png';
}
}
/**
* Initializes the LiveMapComponent by triggering events, setting initial coordinates,
* and loading required live data.
*
* @memberof LiveMapComponent
* @action
* @function
*/
async setupComponent() {
// trigger that initial coordinates have been set
this.universe.trigger('fleet-ops.live-map.loaded', this);
// set initial coordinates
await this.setInitialCoordinates();
// Check if leaflet plugins loaded
this.leafletPluginsLoadedCheckId = setInterval(() => {
if (window.fleetopsLeafletPluginsLoaded === true) {
clearInterval(this.leafletPluginsLoadedCheckId);
this.ready();
}
}, 100);
// load data and complete setup
await this.completeSetup([
this.loadLiveData.perform('routes'),
this.loadLiveData.perform('vehicles'),
this.loadLiveData.perform('drivers'),
this.loadLiveData.perform('places'),
this.loadServiceAreas.perform(),
]);
}
/**
* Completes the setup of the component by processing an array of live data promises.
* It waits for all the provided promises to settle and then sets a flag indicating
* that data fetching is complete. It ensures that any final listening and readiness
* processes are invoked at the end of the setup process.
*
* @param {Promise[]} liveDataPromises - An array of promises that fetch live data.
* @returns {Promise} A promise that resolves when all data-fetching promises have settled.
*/
async completeSetup(liveDataPromises) {
await allSettled(liveDataPromises);
this.isDataLoaded = true;
this.listen();
}
/**
* Reloads the live map data.
*
* @memberof LiveMapComponent
*/
async reload() {
this.isDataLoaded = false;
await this.completeSetup([
this.loadLiveData.perform('routes'),
this.loadLiveData.perform('vehicles'),
this.loadLiveData.perform('drivers'),
this.loadLiveData.perform('places'),
this.loadServiceAreas.perform(),
]);
}
/**
* Marks the LiveMapComponent as ready by setting the "isReady" property and triggering
* the "onReady" action and a "fleetops.live-map.ready" event.
*
* @memberof LiveMapComponent
* @function
*/
ready() {
this.isReady = true;
this.triggerAction('onReady');
this.universe.trigger('fleet-ops.live-map.ready', this);
}
/**
* Sets the initial coordinates for the LiveMapComponent.
*
* This function checks if initial coordinates are available in the appCache, and if not,
* it fetches the coordinates using the "getInitialCoordinates" function. It sets the
* latitude and longitude properties and triggers an event to notify that coordinates
* have been set.
*
* @memberof LiveMapComponent
* @async
* @function
* @returns {Promise<[number, number] | null>} An array containing the latitude and longitude
* if available, or null if the function is skipped.
*/
async setInitialCoordinates() {
try {
const { latitude, longitude } = await this.location.getUserLocation();
this.latitude = latitude || this.location.DEFAULT_LATITUDE;
this.longitude = longitude || this.location.DEFAULT_LONGITUDE;
} catch (error) {
this.latitude = this.location.DEFAULT_LATITUDE;
this.longitude = this.location.DEFAULT_LONGITUDE;
}
// Trigger that initial coordinates are set to live map component
this.universe.trigger('fleet-ops.live-map.has_coordinates', {
latitude: this.latitude,
longitude: this.longitude,
});
}
/**
* Sets up the LiveMap component and the Leaflet map instance.
*
* This function initializes the LiveMap component, associates it with the Leaflet map instance,
* triggers the "fleetops.live-map.leaflet_ready" event, and performs additional setup tasks like
* configuring context menus, hiding draw controls, and associating the map with the "serviceAreas"
* service. It also triggers the "onLoad" action with the provided event and target.
*
* @action
* @function
* @param {Event} event - The event object.
*/
@action setupMap(event) {
const { target } = event;
// set liveMapComponent component to instance
set(target, 'liveMap', this);
// set map instance
this.leafletMap = target;
// trigger liveMap ready through universe
this.universe.trigger('fleet-ops.live-map.leaflet_ready', event, target);
// make fleetops map globally available on the window
window.FleetOpsLeafletMap = target;
// store this component to universe
this.universe.set('component:fleet-ops:live-map', this);
// setup context menu
this.createMapContextMenu(target);
// hide draw controls by default
this.hideDrawControls();
// set instance to service areas service
this.serviceAreas.setMapInstance(target);
// Update event
event.target = target;
// trigger map loaded event
this.triggerAction('onLoad', event);
// handle theme change
this._checkThemeChanged();
}
_checkThemeChanged() {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'data-theme') {
const theme = document.body.getAttribute(mutation.attributeName);
if (theme === 'light' || theme === 'dark') {
this.changeTileSource(theme);
}
}
});
});
observer.observe(document.body, { attributes: true, attributeFilter: ['data-theme'] });
}
/**
* Invokes an action by name on the current component and its arguments (if defined).
*
* This function checks if an action with the specified name exists on the current component.
* If found, it invokes the action with the provided parameters. It also checks the component's
* arguments for the action and invokes it if defined.
*
* @action
* @function
* @param {string} actionName - The name of the action to trigger.
* @param {...any} params - Optional parameters to pass to the action.
*/
@action triggerAction(actionName, ...params) {
if (typeof this[actionName] === 'function') {
this[actionName](...params);
}
if (typeof this.args[actionName] === 'function') {
this.args[actionName](...params);
}
}
/**
* Asynchronously loads live data for a specified path and updates the component's state.
* This function uses Ember Concurrency to handle the asynchronous operation, ensuring
* better handling of concurrency and potential task cancellation.
*
* @memberof LiveMapComponent
* @task
* @generator
* @param {string} path - The path for fetching live data.
* @param {Object} [options={}] - Configuration options for the data loading process.
* @param {Object} [options.params={}] - Additional parameters to include in the request.
* @param {Function} [options.onLoaded] - Callback function executed when data is successfully loaded.
* @param {Function} [options.onFailure] - Callback function executed in case of a failure in data loading.
* @returns {Promise<Object|undefined>} A promise that resolves with the fetched data, or undefined if an error occurs.
*
* @example
* // To load data and execute specific actions on load and failure
* this.loadLiveData.perform('some/path', {
* params: { key: 'value' },
* onLoaded: (data) => { debug('Data loaded', data); },
* onFailure: (error) => { console.error('Failed to load data', error); }
* });
*/
@task *loadLiveData(path, options = {}) {
if (this.abilities.cannot(`fleet-ops list ${path}`)) {
return [];
}
const internalName = camelize(path);
const callbackFnName = `on${internalName}Loaded`;
const params = getWithDefault(options, 'params', {});
const url = `fleet-ops/live/${path}`;
try {
let data = yield this.fetch.get(url, params, { normalizeToEmberData: true, normalizeModelType: singularize(internalName) });
if (isArray(data)) {
data = [...data];
}
this.triggerAction(callbackFnName);
this.createVisibilityControl(internalName);
this[internalName] = data;
this.cacheOriginalResources(internalName);
if (typeof options.onLoaded === 'function') {
options.onLoaded(data);
}
return data;
} catch (error) {
if (typeof options.onFailure === 'function') {
options.onFailure(error);
}
}
}
/**
* Creates or updates a visibility control for a specific element by name.
*
* @function
* @param {string} name - The name or identifier for the visibility control.
* @param {boolean} [visible=true] - A boolean value indicating whether the element is initially visible (default is true).
*/
createVisibilityControl(name, visible = true) {
this.visibilityControls = {
...this.visibilityControls,
[name]: visible,
};
}
/**
* Hide all visibility controls associated with the current instance.
*/
hideAll(callback = null) {
const controls = Object.keys(this.visibilityControls);
for (let i = 0; i < controls.length; i++) {
const control = controls.objectAt(i);
this.hide(control);
}
if (typeof callback === 'function') {
callback();
}
}
/**
* Show all visibility controls associated with the current instance.
*/
showAll(callback = null) {
const controls = Object.keys(this.visibilityControls);
for (let i = 0; i < controls.length; i++) {
const control = controls.objectAt(i);
this.show(control);
}
if (typeof callback === 'function') {
callback();
}
}
/**
* Hides a specific element by name using a visibility control.
*
* @function
* @param {string} name - The name or identifier of the element to hide.
*/
hide(name, callback = null) {
if (isArray(name)) {
return name.forEach(this.hide.bind(this));
}
this.createVisibilityControl(name, false);
if (typeof callback === 'function') {
callback();
}
}
/**
* Shows a specific element by name using a visibility control.
*
* @function
* @param {string} name - The name or identifier of the element to show.
*/
show(name, callback = null) {
if (isArray(name)) {
return name.forEach(this.show.bind(this));
}
this.createVisibilityControl(name, true);
if (typeof callback === 'function') {
callback();
}
}
/**
* Toggles the visibility of a control by its name.
* Calls `hide()` if the control is currently visible, and `show()` otherwise.
*
* @param {string} name - The name of the control to toggle.
* @memberof LiveMapComponent
*/
toggleVisibility(name, callback = null) {
if (this.isVisible(name)) {
this.hide(name, callback);
} else {
this.show(name, callback);
}
}
/**
* Check if a specific element or feature is currently visible based on its name.
*
* @param {string} name - The name of the element or feature to check visibility for.
* @returns {boolean} Returns `true` if the element or feature is currently visible, `false` otherwise.
* @memberof LiveMapComponent
*/
isVisible(name) {
return this.visibilityControls[name] === true;
}
/**
* Caches the original state of a resource array.
* @param {string} name - Name of the resource array (e.g., 'vehicles', 'drivers').
* @memberof LiveMapComponent
*/
cacheOriginalResources(name) {
if (!this.originalResources[name]) {
this.originalResources[name] = [...this[name]];
}
}
/**
* Retrieves the original resources array for a given name.
* @param {string} name - Name of the resource array (e.g., 'vehicles', 'drivers').
* @returns {Array} - The original array of resources; an empty array if not set.
* @memberof LiveMapComponent
*/
getOriginalResources(name) {
if (isArray(this.originalResources[name])) {
return this.originalResources[name];
}
return [];
}
/**
* Shows all online and offline resources for a given name.
* @param {string} name - Name of the resource array (e.g., 'vehicles', 'drivers').
* @memberof LiveMapComponent
*/
showAllOnlineOffline(name) {
this.show(name);
this.showOnline(name);
this.showOffline(name);
}
/**
* Hides all online and offline resources for a given name.
* @param {string} name - Name of the resource array (e.g., 'vehicles', 'drivers').
* @memberof LiveMapComponent
*/
hideAllOnlineOffline(name) {
this.hide(name);
this.hideOnline(name);
this.hideOffline(name);
}
/**
* Toggles the visibility of all online and offline resources for a given name.
* @param {string} name - Name of the resource array (e.g., 'vehicles', 'drivers').
* @memberof LiveMapComponent
*/
toggleAllOnlineOffline(name) {
if (this.isVisible(name)) {
this.hideAllOnlineOffline(name);
} else {
this.showAllOnlineOffline(name);
}
}
/**
* Toggles the visibility of online resources for a given array.
* @method toggleOnline
* @param {string} name - The name of the resource array (e.g., 'vehicles', 'drivers').
* @memberof LiveMapComponent
*/
toggleOnline(name) {
const visibilityControlName = `online${classify(name)}`;
if (this.isVisible(visibilityControlName)) {
this.hideOnline(name);
} else {
this.showOnline(name);
}
}
/**
* Toggles the visibility of offline resources for a given array.
* @method toggleOffline
* @param {string} name - The name of the resource array (e.g., 'vehicles', 'drivers').
* @memberof LiveMapComponent
*/
toggleOffline(name) {
const visibilityControlName = `offline${classify(name)}`;
if (this.isVisible(visibilityControlName)) {
this.hideOffline(name);
} else {
this.showOffline(name);
}
}
/**
* Hides online resources from a given array and updates it.
* @method hideOnline
* @param {string} name - The name of the resource array (e.g., 'vehicles', 'drivers').
* @memberof LiveMapComponent
*/
hideOnline(name) {
this[name] = this.getOriginalResources(name).filter((resource) => !resource.online);
// track with visibility controls
this.createVisibilityControl(`online${classify(name)}`, false);
}
/**
* Shows online resources from a given array and updates it.
* @method showOnline
* @param {string} name - The name of the resource array (e.g., 'vehicles', 'drivers').
* @memberof LiveMapComponent
*/
showOnline(name) {
this[name] = this.getOriginalResources(name).filter((resource) => resource.online);
// track with visibility controls
this.createVisibilityControl(`online${classify(name)}`, true);
}
/**
* Hides offline resources from a specified array.
* @param {string} name - Name of the resource array (e.g., 'vehicles', 'drivers').
* @memberof LiveMapComponent
*/
hideOffline(name) {
this[name] = this.getOriginalResources(name).filter((resource) => resource.online);
// track with visibility controls
this.createVisibilityControl(`offline${classify(name)}`, false);
}
/**
* Shows offline resources from a specified array.
* @param {string} name - Name of the resource array (e.g., 'vehicles', 'drivers').
* @memberof LiveMapComponent
*/
showOffline(name) {
this[name] = this.getOriginalResources(name).filter((resource) => !resource.online);
// track with visibility controls
this.createVisibilityControl(`offline${classify(name)}`, true);
}
/**
* Toggles the context menu item for enabling/disabling draw controls.
*
* @param {Object} [options] - Optional settings for the context menu item.
* @param {string} [options.onText='Hide draw controls...'] - Text to display when enabling draw controls.
* @param {string} [options.offText='Enable draw controls...'] - Text to display when disabling draw controls.
* @param {string} [options.callback=function] - Callback function to trigger after toggle.
* @memberof LiveMapComponent
*/
toggleDrawControlContextMenuItem(options = {}) {
const toggle = !this.isVisible('drawControls');
this.leafletContextmenuManager.toggleContextMenuItem('map', 'draw controls', {
onText: this.intl.t('fleet-ops.component.live-map.onText'),
offText: this.intl.t('fleet-ops.component.live-map.offText'),
toggle,
callback: (isToggled) => {
if (isToggled) {
this.showDrawControls();
} else {
this.hideDrawControls();
}
},
...options,
});
}
/**
* Removes a specific service area from the context menu.
*
* @param {Object} serviceArea - The service area to be removed from the context menu.
* @memberof LiveMapComponent
*/
removeServiceAreaFromContextMenu(serviceArea) {
this.leafletContextmenuManager.removeItemFromContextMenu('map', `Focus Service Area: ${serviceArea.name}`);
}
/**
* Get a Leaflet layer from the map based on its ID.
*
* @param {string} id - The ID of the Leaflet layer to retrieve.
* @returns {Object|null} The found Leaflet layer or `null` if not found.
* @memberof LiveMapComponent
*/
getLeafletLayerById(id) {
return this.leafletMapManager.getLeafletLayerById(this.leafletMap, id);
}
/**
* Find a specific Leaflet layer on the map using a callback function.
*
* @param {Function} callback - A callback function that defines the condition for finding the layer.
* @returns {Object|null} The found Leaflet layer or `null` if not found.
* @memberof LiveMapComponent
*/
findLeafletLayer(callback) {
return this.leafletMapManager.findLeafletLayer(this.leafletMap, callback);
}
/**
* Find an editable layer in the collection by its record ID.
*
* @param {Object} record - The record with the ID used for lookup.
* @returns {Layer|null} The found editable layer, or null if not found.
* @memberof LiveMapComponent
*/
getLeafletLayerByRecordId(record) {
const id = getWithDefault(record, 'id', record);
let targetLayer = null;
this.leafletMap.eachLayer((layer) => {
// Check if the layer has an ID property
if (layer.record_id === id) {
targetLayer = layer;
}
});
return targetLayer;
}
/**
* Push an editable layer to the collection of editable layers.
*
* @param {Layer} layer - The layer to be added to the collection.
* @memberof LiveMapComponent
*/
pushEditableLayer(layer) {
if (!this.editableLayers.includes(layer)) {
this.editableLayers.pushObject(layer);
}
}
/**
* Remove an editable layer from the collection by its record ID.
*
* @param {Object} record - The record with the ID used for removal.
* @memberof LiveMapComponent
*/
removeEditableLayerByRecordId(record) {
const id = getWithDefault(record, 'id', record);
const index = this.editableLayers.findIndex((layer) => layer.record_id === id);
const layer = this.editableLayers.objectAt(index);
if (this.drawFeatureGroup) {
this.drawFeatureGroup.addLayer(layer);
this.editableLayers.removeAt(index);
}
}
/**
* Find an editable layer in the collection by its record ID.
*
* @param {Object} record - The record with the ID used for lookup.
* @returns {Layer|null} The found editable layer, or null if not found.
* @memberof LiveMapComponent
*/
findEditableLayerByRecordId(record) {
const id = getWithDefault(record, 'id', record);
return this.editableLayers.find((layer) => layer.record_id === id);
}
/**
* Peek a record for a given layer by its record ID and type.
*
* @param {Layer} layer - The layer associated with a record.
* @returns {Object|null} The peeked record, or null if not found.
* @memberof LiveMapComponent
*/
peekRecordForLayer(layer) {
if (layer.record_id && layer.record_type) {
return this.store.peekRecord(dasherize(layer.record_type), layer.record_id);
}
return null;
}
/**
* Sets the drawer component context api.
*
* @param {Object} drawerApi
* @memberof LiveMapComponent
*/
@action setDrawerContext(drawerApi) {
this.drawer = drawerApi;
if (typeof this.args.onDrawerReady === 'function') {
this.args.onDrawerReady(...arguments);
}
}
/**
* Handle the 'drawstop' event.
*
* @param {Event} event - The 'drawstop' event object.
* @param {Layer} layer - The layer associated with the event.
* @memberof LiveMapComponent
*/
@action onDrawDrawstop(event, layer) {
this.serviceAreas.createGenericLayer(event, layer);
}
/**
* Handle the 'deleted' event for drawn elements.
*
* @param {Event} event - The 'deleted' event object.
* @memberof LiveMapComponent
*/
@action onDrawDeleted(event) {
/** @var {L.LayerGroup} layers */
const { layers } = event;
const records = layers.getLayers().map(this.peekRecordForLayer).filter(Boolean);
const requests = records.map((record) => {
this.blurServiceArea(record);
this.removeServiceAreaFromContextMenu(record);
return record.destroyRecord();
});
allSettled(requests).then(() => {
records.forEach((record) => this.serviceAreas.removeFromCache(record));
});
}
/**
* Handle the 'edited' event for drawn elements.
*
* @param {Event} event - The 'edited' event object.
* @memberof LiveMapComponent
*/
@action onDrawEdited(event) {
/** @var {L.LayerGroup} layers */
const { layers } = event;
const requests = layers.getLayers().map((layer) => {
const record = this.peekRecordForLayer(layer);
let border;
if (layer.record_type === 'zone') {
border = this.serviceAreas.layerToTerraformerPrimitive(layer);
} else {
border = this.serviceAreas.layerToTerraformerMultiPolygon(layer);
}
record.set('border', border);
return record.save();
});
allSettled(requests);
}
/**
* Handle the addition of a service area layer.
*
* @param {ServiceAreaModel} serviceArea - The service area object.
* @param {Event} event - The event object associated with the addition.
* @memberof LiveMapComponent
*/
@action onServiceAreaLayerAdded(serviceArea, event) {
const { target } = event;
set(target, 'record_id', serviceArea.id);
set(target, 'record_type', 'service-area');
// set the layer instance to the serviceArea model
set(serviceArea, '_layer', target);
if (this.drawFeatureGroup) {
// add to draw feature group
this.drawFeatureGroup.addLayer(target);
}
// this.flyToBoundsOnly(target);
this.createServiceAreaContextMenu(serviceArea, target);
this.pushEditableLayer(target);
}
/**
* Handle the addition of a zone layer.
*
* @param {ZoneModel} zone - The zone object.
* @param {Event} event - The event object associated with the addition.
* @memberof LiveMapComponent
*/
@action onZoneLayerAdd(zone, event) {
const { target } = event;
set(target, 'record_id', zone.id);
set(target, 'record_type', 'zone');
// set the layer instance to the zone model
set(zone, '_layer', target);
// // if zone has a service area and service area is active then add zone to active service area
// const serviceAreaId = zone.get('service_area_uuid');
// if (serviceAreaId) {
// const serviceArea = this.activeServiceAreas.find((serviceArea) => serviceArea.get('id') === serviceAreaId);
// if (serviceArea) {
// const zones = serviceArea.get('zones') ? [...serviceArea.get('zones')] : [];
// const hasZoneAlready = zones.find((z) => z.get('id') === zone.get('id'));
// if (!hasZoneAlready) {
// serviceArea.set('zones', zones);
// }
// }
// }
if (this.drawFeatureGroup) {
// add to draw feature group
this.drawFeatureGroup.addLayer(target);
}
this.createZoneContextMenu(zone, target);
this.pushEditableLayer(target);
}
/**
* Handle the creation of the draw feature group.
*
* @param {DrawFeatureGroup} drawFeatureGroup - The draw feature group instance.
* @memberof LiveMapComponent
*/
@action onDrawFeatureGroupCreated(drawFeatureGroup) {
this.drawFeatureGroup = drawFeatureGroup;
}
/**
* Handle the addition of a driver marker.
*
* @param {DriverModel} driver - The driver object.
* @param {Event} event - The event object associated with the addition.
* @memberof LiveMapComponent
*/
@action onDriverAdded(driver, event) {
const { target } = event;
set(target, 'record_id', driver.id);
set(target, 'record_type', 'driver');
// set the marker instance to the driver model
set(driver, '_marker', target);
this.createDriverContextMenu(driver, target);
this.movementTracker.track(driver);
}
/**
* Handle the click event of a driver marker.
*
* @param {DriverModel} driver - The driver object.
* @param {Event} event - The event object associated with the addition.
* @memberof LiveMapComponent
*/
@action onDriverClicked(driver) {
this.contextPanel.clear();
this.contextPanel.focus(driver, 'viewing', {
args: {
width: '450px',
onOpen: () => {
this.leafletMap.once('moveend', () => {
this.leafletMap.panBy([200, 0]);
});
},
},
});
}
/**
* Handle the addition of a vehicle marker.
*
* @param {VehicleModel} vehicle - The vehicle object.
* @param {Event} event - The event object associated with the addition.
* @memberof LiveMapComponent
*/
@action onVehicleAdded(vehicle, event) {
const { target } = event;
set(target, 'record_id', vehicle.id);
set(target, 'record_type', 'vehicle');
// set the marker instance to the vehicle model
set(vehicle, '_marker', target);
this.createVehicleContextMenu(vehicle, target);
this.movementTracker.track(vehicle);
}
/**
* Handle the addition of a place marker.
*
* @param {PlaceModel} place - The place object.
* @param {Event} event - The event object associated with the addition.
* @memberof LiveMapComponent
*/
@action onPlaceAdded(place, event) {
const { target } = event;
set(target, 'record_id', place.id);
set(target, 'record_type', 'place');
// set the marker instance to the vehicle model
set(place, '_marker', target);
}
/**
* Handle the click event of a vehicle marker.
*
* @param {VehicleModel} vehicle - The vehicle object.
* @param {Event} event - The event object associated with the addition.
* @memberof LiveMapComponent
*/
@action onVehicleClicked(vehicle) {
this.contextPanel.clear();
this.contextPanel.focus(vehicle, 'viewing', {
args: {
width: '450px',
onOpen: () => {
this.leafletMap.once('moveend', () => {
this.leafletMap.panBy([200, 0]);
});
},
},
});
}
@action previewOrderRoute(order) {
// Hide all elements on map
this.hideAll();
// Show drivers
this.show('drivers');
// create order route preview
const waypoints = this.getRouteCoordinatesFromOrder(order);
const routingHost = getRoutingHost();
if (this.cannotRouteWaypoints(waypoints)) {
return;
}
// center on first coordinate
try {
this.leafletMap.stop();
this.leafletMap.flyTo(waypoints.firstObject);
} catch (error) {
// unable to stop map
debug(`Leaflet Map Error: ${error.message}`);
}
const router = new OSRMv1({
serviceUrl: `${routingHost}/route/v1`,
profile: 'driving',
});
this.routeControl = new RoutingControl({
fitSelectedRoutes: false,
router,
waypoints,
alternativeClassName: 'hidden',
addWaypoints: false,
markerOptions: {
draggable: false,
icon: L.icon({
iconUrl: '/assets/images/marker-icon.png',
iconRetinaUrl: '/assets/images/marker-icon-2x.png',
shadowUrl: '/assets/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
}),
},
}).addTo(this.leafletMap);
this.routeControl.on('routingerror', (error) => {
debug(`Routing Control Error: ${error.error.message}`);
});
this.routeControl.on('routesfound', () => {
this.leafletMap.flyToBounds(waypoints, {
paddingBottomRight: MAP_TARGET_FOCUS_PADDING_BOTTOM_RIGHT,
maxZoom: waypoints.length === 2 ? 13 : 12,
animate: true,
});
this.leafletMap.once('moveend', () => {
this.leafletMap.panBy(MAP_TARGET_FOCUS_REFOCUS_PANBY);
});
});
}
getRouteCoordinatesFromOrder(order) {
const payload = order.payload;
const waypoints = [];
const coordinates = [];
waypoints.pushObjects([payload.pickup, ...payload.waypoints.toArray(), payload.dropoff]);
waypoints.forEach((place) => {
if (place && place.get('longitude') && place.get('latitude')) {
if (place.hasInvalidCoordinates) {
return;
}
coordinates.pushObject([place.get('latitude'), place.get('longitude')]);
}
});
return coordinates;
}
cannotRouteWaypoints(waypoints = []) {
return !this.leafletMap || !isArray(waypoints) || waypoints.length < 2;
}
@action restoreDefaultLiveMap() {
this.removeRouteControl();
this.showAll();
this.leafletMap.flyTo([this.latitude, this.longitude], 13);
}
removeRouteControl() {
if (this.routeControl && this.routeControl instanceof RoutingControl) {
try {
this.routeControl.remove();
} catch (error) {
debug(`LiveMapComponent Error: ${error.message}`);
}
}
}
/**
* Handle the creation of the draw control.
*
* @param {DrawControl} drawControl - The draw control instance.
* @memberof LiveMapComponent
*/
@action onDrawControlCreated(drawControl) {
this.drawControl = drawControl;
}
/**
* Hide the draw controls on the map.
*
* @param {Object} [options={}] - Additional options.
* @param {string|boolean} [options.text] - Text to set for the menu item or `true` to set the default text.
* @param {function} [options.callback] - A callback function to execute.
* @memberof LiveMapComponent
*/
@action hideDrawControls(options = {}) {
this.hide('drawControls');
const text = getWithDefault(options, 'text', true);
const callback = getWithDefault(options, 'callback');
if (typeof callback === 'function') {
callback();
}
if (typeof text === 'string') {
this.leafletContextmenuManager.changeMenuItemText('map', 'draw controls', text);
}
if (text === true) {
this.leafletContextmenuManager.changeMenuItemText('map', 'draw controls', 'Enable draw controls...');
}
if (this.drawControl) {
this.leafletMap.removeControl(this.drawControl);
}
}
/**
* Show the draw controls on the map.
*
* @param {Object} [options={}] - Additional options.
* @param {string|boolean} [options.text] - Text to set for the menu item or `true` to set the default text.
* @param {function} [options.callback] - A callback function to execute.
* @memberof LiveMapComponent
*/
@action showDrawControls(options = {}) {
this.show('drawControls');
const text = getWithDefault(options, 'text');
const callback = getWithDefault(options, 'callback');
if (typeof callback === 'function') {
callback();
}
if (typeof text === 'string') {
this.leafletContextmenuManager.changeMenuItemText('map', 'draw controls', text);
}
if (text === true) {
this.leafletContextmenuManager.changeMenuItemText('map', 'draw controls', 'Hide draw controls...');
}
if (this.drawControl) {
this.leafletMap.addControl(this.drawControl);
}
}
/**
* Focus on a layer associated with a record.
*
* @param {Object} record - The record to focus on.
* @memberof LiveMapComponent
*/
@action focusLayerBoundsByRecord(record) {
const layer = this.getLeafletLayerByRecordId(record);
if (layer) {
this.flyToBoundsOnly(layer);
}
}
/**
* Fly to a service area layer on the map.
*
* @param {ServiceAreaModel} serviceArea - The service area object to fly to.
* @memberof LiveMapComponent
*/
@action flyToServiceArea(serviceArea) {
const layer = this.findEditableLayerByRecordId(serviceArea);
if (layer) {
this.flyToBoundsOnly(layer);
}
}
/**
* Focus on a service area by activating it and then flying to it on the map.
*
* @param {ServiceArea} serviceArea - The service area to focus on.
* @memberof LiveMapComponent
*/
@action focusServiceArea(serviceArea) {
this.activateServiceArea(serviceArea);
later(
this,
() => {
this.flyToServiceArea(serviceArea);
},
100
);
}
/**
* Blur all service areas except for those specified in the 'except' array.
*
* @param {Array} except - An array of records to exclude from blurring.
* @memberof LiveMapComponent
*/
blurAllServiceAreas(except = []) {
if (!isArray(except)) {
except = [];
}
// map except into ids only
except = except
.filter(Boolean)
.filter((record) => typeof record !== 'string' && !record?.id)
.map((record) => record.id);
for (let i = 0; i < this.activeServiceAreas.length; i++) {
const serviceArea = this.activeServiceAreas.objectAt(i);
if (isArray(except) && except.includes(serviceArea.id)) {
continue;
}
this.blurServiceArea(serviceArea);
}
for (let i = 0; i < this.editableLayers.length; i++) {
const layer = this.editableLayers.objectAt(i);
if (isArray(except) && except.includes(layer.record_id)) {
continue;
}
this.editableLayers.removeObject(layer);
}
}
/**
* Focus on all service areas except for those specified in the 'except' array by activating them.
*
* @param {Array} except - An array of records to exclude from activation.
* @memberof LiveMapComponent
*/
focusAllServiceAreas(except = []) {
if (!isArray(except)) {
except = [];
}
// map except into ids only
except = except
.filter(Boolean)
.filter((record) => typeof record !== 'string' && !record?.id)
.map((record) => record.id);
for (let i = 0; i < this.serviceAreaRecords.length; i++) {
const serviceArea = this.serviceAreaRecords.objectAt(i);
if (isArray(except) && except.includes(serviceArea.id)) {
continue;
}
this.activateServiceArea(serviceArea);
}
}
/**
* Blur a specific service area by removing it from the active service areas.
*
* @param {ServiceAreaModel} serviceArea - The service area to blur.
* @memberof LiveMapComponent
*/
blurServiceArea(serviceArea) {
if (this.activeServiceAreas.includes(serviceArea)) {
this.activeServiceAreas.removeObject(serviceArea);
}
}
/**
* Activate a service area by adding it to the active service areas.
*
* @param {ServiceAreaModel} serviceArea - The service area to activate.
* @memberof LiveMapComponent
*/
activateServiceArea(serviceArea) {
if (!this.activeServiceAreas.includes(serviceArea)) {
this.activeServiceAreas.pushObject(serviceArea);
}
}
/**
* Show coordinates information by displaying them as an info notification.
*
* @param {Event} event - The event containing latitude and longitude information.
* @memberof LiveMapComponent
*/
@action showCoordinates(event) {
const wrappedLatLng = event.latlng.wrap();
this.notifications.info(wrappedLatLng);
}
/**
* Center the map on a specific location provided in the event.
*
* @param {Event} event - The event containing the target location (latlng).
* @memberof LiveMapComponent
*/
@action centerMap(event) {
this.leafletMap.panTo(event.latlng);
}
/**
* Zoom in on the map.
*
* @memberof LiveMapComponent
*/
@action zoomIn() {
this.leafletMap.zoomIn();
}
/**
* Zoom out on the map.
*
* @memberof LiveMapComponent
*/
@action zoomOut() {
this.leafletMap.zoomOut();
}
/**
* Set the maximum bounds of the map based on the provided layer's bounds.
*
* @param {Layer} layer - The layer used to determine the map's maximum bounds.
* @memberof LiveMapComponent
*/
setMaxBoundsFromLayer(layer) {
if (layer && typeof layer.getBounds === 'function') {
const bounds = layer.getBounds();
this.leafletMap.flyToBounds(bounds);
this.leafletMap.setMaxBounds(bounds);
}
}
/**
* Fly to and focus on a specific layer's bounds on the map.
*
* @param {Layer} layer - The layer to focus on.
* @memberof LiveMapComponent
*/
flyToBoundsOnly(layer) {
if (layer && typeof layer.getBounds === 'function') {
const bounds = layer.getBounds();
this.leafletMap.flyToBounds(bounds);
}
}
/**
* Focus on a specific layer and optionally zoom in/out on it.
*
* @param {Layer} layer - The layer to focus on.
* @param {number} zoom - The zoom level for the focus operation.
* @param {Object} options - Additional options for the focus operation.
* @memberof LiveMapComponent
*/
@action focusLayer(layer, zoom, options = {}) {
this.leafletMapManager.flyToLayer(this.leafletMap, layer, zoom, options);
if (typeof options.onAfterFocus === 'function') {
options.onAfterFocus(layer);
}
}
/**
* Focuses the Leaflet map on a specific layer associated with a record.
*
* @param {Object} record - The record associated with the target layer.
* @param {number} zoom - The desired zoom level for the map.
* @param {Object} [options={}] - Additional options for the map focus.
* @returns {void}
*
* @example
* focusLayerByRecord(recordData, 12, { animate: true });
*/
@action focusLayerByRecord(record, zoom, options = {}) {
const layer = this.getLeafletLayerByRecordId(record);
if (layer) {
this.focusLayer(layer, zoom, options);
}
if (typeof options.onAfterFocusWithRecord === 'function') {
options.onAfterFocusWithRecord(record, layer);
}
}
/**
* Create a context menu for the map with various options.
*
* @param {L.Map} map - The map to which the context menu is attached.
* @memberof LiveMapComponent
*/
@action createMapContextMenu(map) {
const contextmenuItems = [
{
text: this.intl.t('fleet-ops.component.live-map.show-coordinates'),
callback: this.showCoordinates,
index: 0,
},
{
text: this.intl.t('fleet-ops.component.live-map.center-map'),
callback: this.centerMap,
index: 1,
},
{
text: this.intl.t('fleet-ops.component.live-map.zoom-in'),
callback: this.zoomIn,
index: 2,
},
{
text: this.intl.t('fleet-ops.component.live-map.zoom-out'),
callback: this.zoomOut,
index: 3,
},
{
text: this.isVisible('drawControls') ? this.intl.t('fleet-ops.component.live-map.hide-draw') : this.intl.t('fleet-ops.component.live-map.enable-draw'),
callback: this.toggleDrawControlContextMenuItem.bind(this),
index: 4,
},
{
separator: true,
},
{
text: this.intl.t('fleet-ops.component.live-map.create-new-service'),
callback: this.serviceAreas.createServiceArea,
inde