@fleetbase/fleetops-engine
Version:
Fleet & Transport Management Extension for Fleetbase
784 lines (644 loc) • 25.4 kB
JavaScript
/**
* MapManagerService
*
* Provider-agnostic central map service for Fleet-Ops. Replaces the
* Leaflet-specific `leaflet-map-manager` as the single point of contact
* for all map operations across the application.
*
* The service resolves the correct adapter at initialization time based on
* the configured `mapProvider` setting (default: 'leaflet'). All method
* calls are delegated to the active adapter, which translates them into
* the underlying library's API.
*
* Usage:
* @service mapManager;
* this.mapManager.flyTo(lat, lng, 15);
*
* Switching providers:
* Set `mapProvider` in config/environment.js or via the settings API.
* The adapter is resolved once at `initializeMap` time.
*
* @module services/map-manager
*/
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { isArray } from '@ember/array';
import { inject as service } from '@ember/service';
import { getOwner } from '@ember/application';
import { action } from '@ember/object';
import { debug } from '@ember/debug';
import { next } from '@ember/runloop';
export default class MapManagerService extends Service {
universe;
notifications;
mapSettings;
routeEngine;
leafletContextmenuManager;
/**
* The active map provider adapter instance.
* @type {MapAdapterInterface}
*/
adapter = null;
/**
* The name of the active provider (e.g. 'leaflet', 'google').
* @type {string}
*/
providerName = 'leaflet';
/**
* Reference to the live map component instance.
* @type {*}
*/
_livemap = null;
/**
* Whether the map has been fully initialized and is ready for interaction.
* @type {boolean}
*/
isReady = false;
routeControls = new Map();
/**
* Internal deferred promise that resolves when the map is ready.
*/
#mapReadyPromise = null;
#resolveMapReady = null;
constructor() {
super(...arguments);
this.#resetReadyDeferred();
this.providerName = this.getConfiguredProvider();
}
// ─── Provider Resolution ───────────────────────────────────────────────────
/**
* Resolve and activate the adapter for the given provider name.
* Falls back to 'leaflet' if the requested provider is not registered.
*
* @param {string} [provider='leaflet']
* @returns {MapAdapterInterface}
*/
resolveAdapter(provider = 'leaflet') {
const owner = getOwner(this);
const lookupName = `service:map-adapter/${provider}`;
const previousAdapter = this.adapter;
let adapter = owner.lookup(lookupName);
let resolvedProvider = provider;
if (!adapter) {
debug(`[MapManager] No adapter found for provider "${provider}", falling back to "leaflet".`);
adapter = owner.lookup('service:map-adapter/leaflet');
resolvedProvider = 'leaflet';
}
if (!adapter) {
throw new Error(`[MapManager] Could not resolve any map adapter. Ensure "service:map-adapter/leaflet" is registered.`);
}
if (previousAdapter && previousAdapter !== adapter) {
previousAdapter.destroyMap?.();
}
this.adapter = adapter;
this.providerName = resolvedProvider;
debug(`[MapManager] Resolved adapter: ${resolvedProvider}`);
return adapter;
}
/**
* Determine the configured provider from runtime settings.
*
* @returns {string}
*/
getConfiguredProvider() {
return this.mapSettings.mapProvider ?? 'leaflet';
}
// ─── Lifecycle ─────────────────────────────────────────────────────────────
/**
* Initialize the map in the given DOM element.
* Resolves the adapter from config if not already resolved.
*
* @param {HTMLElement} element
* @param {Object} [options={}]
* @returns {*} The native map instance
*/
initializeMap(element, options = {}) {
const provider = options.provider ?? this.getConfiguredProvider();
if (!this.adapter || this.providerName !== provider) {
this.resolveAdapter(provider);
}
const map = this.adapter.initializeMap(element, {
...options,
apiKey: options.apiKey ?? this.mapSettings.googleMapsApiKey,
mapId: options.mapId ?? this.mapSettings.googleMapsMapId,
mapTypeId: options.mapTypeId ?? this.mapSettings.googleMapsMapType,
showTrafficLayer: options.showTrafficLayer ?? this.mapSettings.showGoogleMapsTrafficLayer,
showTransitLayer: options.showTransitLayer ?? this.mapSettings.showGoogleMapsTransitLayer,
});
return Promise.resolve(map).then((nativeMap) => {
this.isReady = true;
this.#resolveMapReady?.(nativeMap);
debug('[MapManager] Map initialized');
return nativeMap;
});
}
/**
* Destroy the map and reset state.
*/
destroyMap() {
this.adapter?.destroyMap();
this.isReady = false;
this._livemap = null;
this.#resetReadyDeferred();
debug('[MapManager] Map destroyed');
}
/**
* Notify the adapter that the container has been resized.
*/
invalidateSize() {
this.adapter?.invalidateSize();
}
applyViewSettingsFromSettings() {
if (this.providerName !== 'google' || typeof this.adapter?.applyViewSettings !== 'function') {
return;
}
return this.adapter.applyViewSettings({
mapTypeId: this.mapSettings.googleMapsMapType,
showTrafficLayer: this.mapSettings.showGoogleMapsTrafficLayer,
showTransitLayer: this.mapSettings.showGoogleMapsTransitLayer,
});
}
/**
* Set the live map component reference.
*
* @param {*} livemap
*/
setLivemap(livemap) {
this._livemap = livemap;
}
/**
* Wait until the map is fully initialized.
*
* @param {Object} [options={}]
* @param {number} [options.timeoutMs=8000]
* @returns {Promise<*>}
*/
waitForMap({ timeoutMs = 8000 } = {}) {
if (this.isReady && this.adapter?._map) {
return Promise.resolve(this.adapter._map);
}
let to;
const p = Promise.race([
this.#mapReadyPromise,
new Promise((_, rej) => {
if (timeoutMs != null) {
to = setTimeout(() => rej(new Error('[MapManager] waitForMap timed out')), timeoutMs);
}
}),
]);
return p.finally(() => {
if (to) clearTimeout(to);
});
}
async ensureInteractive({ timeoutMs = 8000 } = {}) {
const map = await this.waitForMap({ timeoutMs });
if (typeof this.adapter?.ensureInteractive === 'function') {
return this.adapter.ensureInteractive({ timeoutMs, map });
}
return map;
}
// ─── Viewport ──────────────────────────────────────────────────────────────
setCenter(lat, lng, zoom) {
return this.adapter?.setCenter(lat, lng, zoom);
}
flyTo(lat, lng, zoom, options = {}) {
return this.adapter?.flyTo(lat, lng, zoom, options);
}
fitBounds(bounds, options = {}) {
return this.adapter?.fitBounds(bounds, options);
}
panTo(lat, lng, options = {}) {
return this.adapter?.panTo(lat, lng, options);
}
zoomIn() {
return this.adapter?.zoomIn();
}
zoomOut() {
return this.adapter?.zoomOut();
}
getZoom() {
return this.adapter?.getZoom();
}
getCenter() {
return this.adapter?.getCenter();
}
getBounds() {
return this.adapter?.getBounds();
}
get livemap() {
return this._livemap;
}
// ─── Markers ───────────────────────────────────────────────────────────────
addMarker(id, lat, lng, options = {}) {
return this.adapter?.addMarker(id, lat, lng, options);
}
updateMarkerPosition(id, lat, lng, animated = true, duration = 500) {
return this.adapter?.updateMarkerPosition(id, lat, lng, animated, duration);
}
setMarkerRotation(id, degrees) {
return this.adapter?.setMarkerRotation(id, degrees);
}
removeMarker(id) {
return this.adapter?.removeMarker(id);
}
getMarker(id) {
return this.adapter?.getMarker(id) ?? null;
}
hasMarker(id) {
return this.adapter?.hasMarker(id) ?? false;
}
// ─── Overlays ──────────────────────────────────────────────────────────────
addPolygon(id, coordinates, options = {}) {
return this.adapter?.addPolygon(id, coordinates, options);
}
addPolyline(id, coordinates, options = {}) {
return this.adapter?.addPolyline(id, coordinates, options);
}
addCircle(id, lat, lng, radiusMeters, options = {}) {
return this.adapter?.addCircle(id, lat, lng, radiusMeters, options);
}
removeOverlay(id) {
return this.adapter?.removeOverlay(id);
}
clearOverlays() {
return this.adapter?.clearOverlays();
}
getOverlay(id) {
return this.adapter?.getOverlay(id) ?? null;
}
// ─── Routing Controls ─────────────────────────────────────────────────────
async addRoutingControl(waypoints, options = {}) {
if (!isArray(waypoints) || waypoints.length === 0) return null;
try {
await this.ensureInteractive({ timeoutMs: options.timeoutMs ?? 8000 });
const engine = options.engine ?? this.routeEngine.getDisplayEngine('osrm');
const route =
waypoints.length === 1
? {
engine: typeof engine === 'string' ? engine : this.routeEngine.getDisplayEngine('osrm'),
waypoints,
coordinates: [],
bounds: null,
summary: { totalDistance: 0, totalTime: 0 },
legs: [],
raw: null,
}
: await this.routeEngine.compute(engine, waypoints, options);
const routeControl = await this.adapter?.addRoutingControl?.(route, {
...options,
engine,
});
if (routeControl) {
this.routeControls.set(routeControl.id, routeControl);
}
if (options.position !== false) {
this.positionWaypoints(route.bounds ?? route.waypoints, {
...(options.fitOptions ?? {}),
isBounds: Boolean(route.bounds),
});
}
options.onRouteFound?.(route);
return routeControl;
} catch (error) {
options.onRoutingError?.(error);
debug(`[MapManager] Routing control failed: ${error.message}`);
return null;
}
}
async replaceRoutingControl(waypoints, existingHandle, options = {}) {
const removeOptions = options.removeOptions ?? {};
if (existingHandle) {
await this.removeRoutingControl(existingHandle, removeOptions);
}
return this.addRoutingControl(waypoints, options);
}
removeRoutingControl(handle, options = {}) {
const routeControl = typeof handle === 'string' ? this.routeControls.get(handle) : handle;
if (!routeControl) return false;
const removed = this.adapter?.removeRoutingControl?.(routeControl, options) ?? false;
this.routeControls.delete(routeControl.id);
return removed;
}
clearRoutingControls(filter = null) {
for (const handle of this.routeControls.values()) {
if (typeof filter === 'function' && filter(handle) === false) {
continue;
}
this.removeRoutingControl(handle);
}
}
clearRoutingControlsByTag(tag) {
if (!tag) {
return;
}
this.clearRoutingControls((handle) => handle?.tag === tag);
}
positionWaypoints(waypointsOrBounds, options = {}) {
return this.adapter?.positionWaypoints?.(waypointsOrBounds, options);
}
focusResource(record, zoom = 16, options = {}) {
if (!record) return null;
const { paddingBottomRight = [0, 0], moveend } = options;
const layer = record?.leafletLayer ?? this.getMarker(record?.id) ?? this.getOverlay(record?.id);
const bounds = this.#extractLayerBounds(layer);
const point = this.#extractLayerPoint(layer) ?? this.#extractRecordPoint(record);
if (typeof moveend === 'function') {
this.once('moveend', () => next(this, moveend));
}
if (bounds) {
return this.fitBounds(bounds, { paddingBottomRight, maxZoom: zoom, animate: true });
}
if (point) {
return this.flyTo(point.lat, point.lng, zoom, { animate: true, duration: 0.8 });
}
return null;
}
// ─── Drawing Tools ─────────────────────────────────────────────────────────
enableDrawingMode(type, options = {}) {
return this.adapter?.enableDrawingMode(type, options);
}
disableDrawingMode() {
return this.adapter?.disableDrawingMode();
}
showDrawControl(config = {}) {
return this.adapter?.showDrawControl(config);
}
hideDrawControl() {
return this.adapter?.hideDrawControl();
}
// ─── Popups ────────────────────────────────────────────────────────────────
openPopup(id, lat, lng, htmlContent) {
return this.adapter?.openPopup(id, lat, lng, htmlContent);
}
closePopup(id) {
return this.adapter?.closePopup(id);
}
// ─── Context Menus ─────────────────────────────────────────────────────────
registerContextMenu(target, items) {
return this.adapter?.registerContextMenu(target, items);
}
removeContextMenu(target) {
return this.adapter?.removeContextMenu(target);
}
// ─── Events ────────────────────────────────────────────────────────────────
on(event, handler) {
return this.adapter?.on(event, handler);
}
off(event, handler) {
return this.adapter?.off(event, handler);
}
once(event, handler) {
return this.adapter?.once(event, handler);
}
// ─── Utilities ─────────────────────────────────────────────────────────────
distanceBetween(lat1, lng1, lat2, lng2) {
return this.adapter?.distanceBetween(lat1, lng1, lat2, lng2);
}
addGeoJson(id, geojson, options = {}) {
return this.adapter?.addGeoJson(id, geojson, options);
}
removeGeoJson(id) {
return this.adapter?.removeGeoJson(id);
}
setTileLayer(url, options = {}) {
return this.adapter?.setTileLayer(url, options);
}
// ─── Provider Switching ──────────────────────────────────────────────────────────
/**
* Activate a named provider adapter.
* Called by the live-map component when the map initialises.
*
* @param {string} provider e.g. 'leaflet' | 'google'
*/
setActiveProvider(provider) {
if (this.providerName === provider && this.adapter) return;
this.isReady = false;
this.#resetReadyDeferred();
this.resolveAdapter(provider);
}
/**
* Bind a host-created native map instance to the active adapter.
*
* @param {*} mapInstance
*/
setMapInstance(mapInstance) {
this.adapter?.setMapInstance?.(mapInstance);
if (mapInstance) {
this.isReady = true;
this.#resolveMapReady?.(mapInstance);
}
}
/**
* Whether the active provider is Google Maps.
* Used by templates to conditionally render the correct map branch.
*
* @type {boolean}
*/
get isGoogleMaps() {
return (this.adapter ? this.providerName : this.getConfiguredProvider()) === 'google';
}
#extractLayerBounds(layer) {
if (!layer) return null;
if (typeof layer.getBounds === 'function') {
const bounds = layer.getBounds();
if (typeof bounds?.getSouth === 'function') {
return [
[bounds.getSouth(), bounds.getWest()],
[bounds.getNorth(), bounds.getEast()],
];
}
if (typeof bounds?.getSouthWest === 'function') {
const sw = bounds.getSouthWest();
const ne = bounds.getNorthEast();
return [
[sw.lat(), sw.lng()],
[ne.lat(), ne.lng()],
];
}
}
return null;
}
#extractLayerPoint(layer) {
if (!layer) return null;
if (typeof layer.getLatLng === 'function') {
const latlng = layer.getLatLng();
if (Number.isFinite(latlng?.lat) && Number.isFinite(latlng?.lng)) {
return { lat: latlng.lat, lng: latlng.lng };
}
}
const position = layer.position ?? layer.getPosition?.();
if (position) {
const lat = typeof position.lat === 'function' ? position.lat() : position.lat;
const lng = typeof position.lng === 'function' ? position.lng() : position.lng;
if (Number.isFinite(lat) && Number.isFinite(lng)) {
return { lat, lng };
}
}
return null;
}
#extractRecordPoint(record) {
const coordinates = record?.location?.coordinates;
if (isArray(coordinates) && coordinates.length >= 2) {
const [lng, lat] = coordinates;
if (Number.isFinite(lat) && Number.isFinite(lng)) {
return { lat, lng };
}
}
if (Number.isFinite(record?.latitude) && Number.isFinite(record?.longitude)) {
return { lat: record.latitude, lng: record.longitude };
}
return null;
}
/**
* Pass a live `google.maps.Map` instance directly to the Google adapter.
* Called by `Map::GoogleLiveMap` after the map element is initialised.
*
* @param {google.maps.Map} mapInstance
*/
registerMarker(id, markerObject, meta = {}) {
return this.adapter?.registerMarker?.(id, markerObject, meta);
}
/**
* Register a native polygon object so that geofence and other services
* can retrieve it by model id.
*
* @param {string} id
* @param {*} polygonObject Native polygon (L.Polygon or google.maps.Polygon)
* @param {Object} [meta={}]
*/
registerPolygon(id, polygonObject, meta = {}) {
return this.adapter?.registerPolygon?.(id, polygonObject, meta);
}
/**
* Show a polygon by model id.
*
* @param {string} id
*/
showPolygon(id) {
return this.adapter?.showPolygon?.(id);
}
/**
* Hide a polygon by model id.
*
* @param {string} id
*/
hidePolygon(id) {
return this.adapter?.hidePolygon?.(id);
}
// ─── Context Menu helpers ─────────────────────────────────────────────────────────
/**
* Retrieve the registered context-menu items for a given target key.
* Used by the Google Maps branch to build floating InfoWindow menus.
*
* @param {string} target e.g. 'map', 'driver:DRV-XXXX'
* @returns {Array}
*/
getContextMenuItems(target) {
const registry = this.leafletContextmenuManager.getRegistry(target);
const items = registry?.contextmenuItems ?? this.adapter?.getContextMenuItems?.(target) ?? [];
return items.map((item) => ({
...item,
label: item.label ?? item.text,
action: item.action ?? item.callback,
}));
}
getComposedContextMenuItems(target) {
const mapItems = this.getContextMenuItems('map');
if (!target || target === 'map') {
return mapItems;
}
const targetItems = this.getContextMenuItems(target);
if (!targetItems.length) {
return mapItems;
}
if (!mapItems.length) {
return targetItems;
}
const combinedItems = [...mapItems];
const firstTargetItem = targetItems[0];
if (!firstTargetItem?.separator) {
combinedItems.push({ separator: true });
}
combinedItems.push(...targetItems);
return combinedItems;
}
showContextMenu(event, items = []) {
return this.adapter?.showContextMenu?.(event, items);
}
closeContextMenu() {
return this.adapter?.closeContextMenu?.();
}
// ─── Map control shortcuts ──────────────────────────────────────────────────────
/**
* Show the current cursor coordinates in a toast / overlay.
* Delegates to the active adapter.
*/
showCoordinates(event) {
const result = this.adapter?.showCoordinates?.(event);
if (result && typeof result === 'object' && Number.isFinite(result.lat) && Number.isFinite(result.lng)) {
this.notifications.info(`${result.lat}, ${result.lng}`);
}
return result;
}
/**
* Re-centre the map on the user's current location.
*/
centerMap(event) {
return this.adapter?.centerMap?.(event);
}
/**
* Toggle the drawing toolbar visibility.
*/
toggleDrawControl() {
return this.adapter?.toggleDrawControl?.();
}
/**
* Pan the map by a pixel offset.
*
* @param {number} x Horizontal pixel offset
* @param {number} y Vertical pixel offset (default 0)
*/
panBy(x, y = 0) {
return this.adapter?.panBy?.(x, y);
}
/**
* Edit an existing polygon overlay.
* Delegates to the adapter's `editPolygon` implementation.
*
* @param {*} layer Native polygon object
* @param {Object} [options={}]
* @returns {Promise<{ type: 'edited'|'cancel', layer: * }>}
*/
editPolygon(layer, options = {}) {
return this.adapter?.editPolygon?.(layer, options) ?? Promise.resolve({ type: 'unsupported' });
}
// ─── Layer Visibility (delegated to adapter) ───────────────────────────────
/**
* Show a layer by its native object reference.
* Adapters implement this to show/hide provider-specific overlay objects.
*
* @param {*} layer - Native layer object
* @param {Object} [options={}]
*/
showLayer(layer, options = {}) {
return this.adapter?.showLayer?.(layer, options);
}
/**
* Hide a layer by its native object reference.
*
* @param {*} layer - Native layer object
* @param {Object} [options={}]
*/
hideLayer(layer, options = {}) {
return this.adapter?.hideLayer?.(layer, options);
}
removeLayer(layer) {
return this.adapter?.removeLayer?.(layer);
}
// ─── Private ───────────────────────────────────────────────────────────────
#resetReadyDeferred() {
this.#mapReadyPromise = new Promise((resolve) => {
this.#resolveMapReady = resolve;
});
}
}