UNPKG

@fleetbase/fleetops-engine

Version:

Fleet & Transport Management Extension for Fleetbase

1,457 lines (1,238 loc) 78.4 kB
/* global google */ /** * GoogleMapsAdapter * * Implements the MapAdapterInterface using the Google Maps JavaScript API. * Provides 1-to-1 feature parity with the LeafletAdapter, including: * * - Live tracking markers with smooth animation (custom rAF interpolation) * - Marker rotation via CSS transforms on AdvancedMarkerElement * - Geofence drawing via DrawingManager (with fallback for deprecation) * - Editable polygons and circles * - Custom right-click context menus * - InfoWindow popups with custom HTML * - GeoJSON rendering via the Data layer * - Custom tile overlays via ImageMapType * - Normalized event system matching the Leaflet adapter's event names * * Prerequisites: * - `GOOGLE_MAPS_API_KEY` must be set in config/environment.js * - `GOOGLE_MAPS_MAP_ID` must be set for AdvancedMarkerElement support * - The Google Maps JS API is loaded lazily on first initializeMap() call * * @module services/map-adapter/google */ import MapAdapterInterface from '../map-adapter-interface'; import { getOwner } from '@ember/application'; import { isArray } from '@ember/array'; import { debug } from '@ember/debug'; import { waypointIconHtml } from '../../utils/route-colors'; import { Circle, Feature, Polygon } from '@fleetbase/fleetops-data/utils/geojson'; const DEFAULT_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' }], }, ]; function buildGoogleMapStyles({ showTransitLayer = false, googleOptions = {} } = {}) { const baseStyles = showTransitLayer ? DEFAULT_GOOGLE_MAP_STYLES.filter((style) => style.featureType !== 'transit') : DEFAULT_GOOGLE_MAP_STYLES; if (googleOptions.styles === undefined) { return baseStyles; } return [...baseStyles, ...googleOptions.styles]; } function buildWaypointMarkerContent(label, color) { const wrapper = document.createElement('div'); wrapper.className = 'fleetops-map-marker'; wrapper.style.cssText = 'transform-origin: center center; display: block;'; wrapper.innerHTML = waypointIconHtml(label, color); return wrapper; } function buildWaypointMarkerSvg(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" /> <text x="17" y="21" text-anchor="middle" font-family="Inter, Arial, sans-serif" font-size="14" font-weight="700" fill="white">${label}</text> </svg> `; return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`; } export default class GoogleMapsAdapter extends MapAdapterInterface { // ─── Internal State ──────────────────────────────────────────────────────── /** @type {google.maps.Map|null} */ _map = null; /** @type {Map<string, google.maps.marker.AdvancedMarkerElement>} */ _markers = new Map(); /** @type {Map<string, google.maps.Polygon|google.maps.Polyline|google.maps.Circle>} */ _overlays = new Map(); /** @type {Map<string, google.maps.InfoWindow>} */ _popups = new Map(); /** @type {Map<string, google.maps.Data>} */ _geojsonLayers = new Map(); /** @type {google.maps.drawing.DrawingManager|null} */ _drawingManager = null; /** @type {HTMLElement|null} DOM element for the custom draw toolbar */ _drawControlEl = null; /** @type {google.maps.ImageMapType|null} */ _customTileLayer = null; /** @type {google.maps.TrafficLayer|null} */ _trafficLayer = null; /** @type {google.maps.TransitLayer|null} */ _transitLayer = null; /** @type {Map<string, Function[]>} Normalized event name → array of [handler, gmListener] pairs */ _eventListeners = new Map(); /** @type {Map<string, HTMLElement>} Context menu DOM elements */ _contextMenuEls = new Map(); /** @type {Map<string, Array>} */ _contextMenus = new Map(); /** @type {Map<string, Function>} Active animation frame cancel functions */ _animations = new Map(); /** @type {boolean} Whether the Google Maps API has been loaded */ _apiLoaded = false; /** @type {*|null} */ _advancedMarkerElementClass = null; /** @type {boolean} */ _supportsAdvancedMarkers = false; /** @type {Object|null} */ _drawControlConfig = null; /** @type {HTMLElement|null} */ _drawActionEl = null; /** @type {*|null} */ _selectedOverlay = null; /** @type {Set<*>} */ _draftOverlays = new Set(); /** @type {Set<*>} */ _pendingDeletedDrafts = new Set(); /** @type {Function|null} */ _contextMenuCleanup = null; /** @type {google.maps.OverlayView|null} */ _tooltipOverlayView = null; // ─── Lifecycle ───────────────────────────────────────────────────────────── async initializeMap(element, options = {}) { this.destroyMap(); await this.#loadGoogleMapsApi(options); const googleMaps = window.google; const { Map } = await googleMaps.maps.importLibrary('maps'); const { AdvancedMarkerElement } = await googleMaps.maps.importLibrary('marker'); this._advancedMarkerElementClass = AdvancedMarkerElement; const mergedGoogleOptions = options.googleOptions ?? {}; const mergedMapStyles = buildGoogleMapStyles({ showTransitLayer: options.showTransitLayer, googleOptions: mergedGoogleOptions, }); const mapId = options.mapId ?? mergedGoogleOptions.mapId ?? this.#getConfig()?.googleMaps?.mapId ?? this.#getConfig()?.GOOGLE_MAPS_MAP_ID ?? null; this._supportsAdvancedMarkers = Boolean(mapId && mapId !== 'FLEETOPS_MAP'); this._map = new Map(element, { center: { lat: options.lat ?? 1.3521, lng: options.lng ?? 103.8198 }, zoom: options.zoom ?? 12, mapTypeId: options.mapTypeId ?? googleMaps.maps.MapTypeId.ROADMAP, disableDefaultUI: options.disableDefaultUI ?? true, gestureHandling: options.gestureHandling ?? 'greedy', clickableIcons: mergedGoogleOptions.clickableIcons ?? false, ...mergedGoogleOptions, ...(mapId ? { mapId } : {}), styles: mergedMapStyles, }); await this.applyViewSettings(options); await this.#initDrawingManager(); debug('[GoogleMapsAdapter] Map initialized'); return this._map; } destroyMap() { // Cancel all running animations for (const cancel of this._animations.values()) { cancel(); } this._animations.clear(); // Remove all markers for (const marker of this._markers.values()) { marker.map = null; } this._markers.clear(); // Remove all overlays for (const overlay of this._overlays.values()) { overlay.setMap(null); } this._overlays.clear(); this._routingControls.clear(); // Close all popups for (const popup of this._popups.values()) { popup.close(); } this._popups.clear(); // Remove context menus for (const el of this._contextMenuEls.values()) { el.remove(); } this._contextMenuEls.clear(); this._contextMenus.clear(); this._eventListeners.clear(); // Remove draw control this._drawControlEl?.remove(); this._drawControlEl = null; this._drawActionEl?.remove(); this._drawActionEl = null; this._drawingManager?.setMap(null); this._drawingManager = null; this._selectedOverlay = null; this._draftOverlays.clear(); this._pendingDeletedDrafts.clear(); this._contextMenuCleanup?.(); this._contextMenuCleanup = null; this._tooltipOverlayView?.setMap?.(null); this._tooltipOverlayView = null; this._supportsAdvancedMarkers = false; this._trafficLayer?.setMap(null); this._trafficLayer = null; this._transitLayer?.setMap(null); this._transitLayer = null; this._map = null; debug('[GoogleMapsAdapter] Map destroyed'); } invalidateSize() { if (!this._map) return; google.maps.event.trigger(this._map, 'resize'); } async applyViewSettings(options = {}) { if (!this._map) return; const googleMaps = window.google; const { TrafficLayer, TransitLayer } = await googleMaps.maps.importLibrary('maps'); const mapTypeId = options.mapTypeId ?? googleMaps.maps.MapTypeId.ROADMAP; const showTrafficLayer = Boolean(options.showTrafficLayer); const showTransitLayer = Boolean(options.showTransitLayer); const googleOptions = options.googleOptions ?? {}; this._map.setMapTypeId?.(mapTypeId); this._map.setOptions?.({ styles: buildGoogleMapStyles({ showTransitLayer, googleOptions, }), }); if (showTrafficLayer) { if (!this._trafficLayer) { this._trafficLayer = new TrafficLayer(); } this._trafficLayer.setMap(this._map); } else if (this._trafficLayer) { this._trafficLayer.setMap(null); this._trafficLayer = null; } if (showTransitLayer) { if (!this._transitLayer) { this._transitLayer = new TransitLayer(); } this._transitLayer.setMap(this._map); } else if (this._transitLayer) { this._transitLayer.setMap(null); this._transitLayer = null; } debug(`[GoogleMapsAdapter] Applied view settings: mapTypeId=${mapTypeId}, traffic=${showTrafficLayer}, transit=${showTransitLayer}`); } // ─── Viewport ────────────────────────────────────────────────────────────── setCenter(lat, lng, zoom) { if (!this._map) return; this._map.setCenter({ lat, lng }); if (zoom !== undefined) this._map.setZoom(zoom); } flyTo(lat, lng, zoom, options = {}) { if (!this._map) return; // Google Maps has no built-in flyTo; use panTo + optional zoom change this._map.panTo({ lat, lng }); if (zoom !== undefined) { // Delay zoom slightly to allow pan animation to start setTimeout(() => this._map?.setZoom(zoom), options.delay ?? 200); } } fitBounds(bounds, options = {}) { if (!this._map) return; const gmBounds = this.#normalizeBounds(bounds); if (!gmBounds) return; const maxZoom = Number.isFinite(options.maxZoom) ? options.maxZoom : null; const padding = options.paddingBottomRight ?? options.padding ?? null; if (maxZoom !== null) { google.maps.event.addListenerOnce(this._map, 'idle', () => { const currentZoom = this._map?.getZoom?.(); if (Number.isFinite(currentZoom) && currentZoom > maxZoom) { this._map?.setZoom?.(maxZoom); } }); } if (padding) { this._map.fitBounds(gmBounds, { right: padding[0] ?? 0, bottom: padding[1] ?? 0 }); } else { this._map.fitBounds(gmBounds); } } panTo(lat, lng) { if (!this._map) return; this._map.panTo({ lat, lng }); } zoomIn() { if (!this._map) return; this._map.setZoom(this._map.getZoom() + 1); } zoomOut() { if (!this._map) return; this._map.setZoom(this._map.getZoom() - 1); } getZoom() { return this._map?.getZoom() ?? 0; } getCenter() { const c = this._map?.getCenter(); return c ? { lat: c.lat(), lng: c.lng() } : { lat: 0, lng: 0 }; } getBounds() { const b = this._map?.getBounds(); if (!b) { return [ [0, 0], [0, 0], ]; } return [ [b.getSouthWest().lat(), b.getSouthWest().lng()], [b.getNorthEast().lat(), b.getNorthEast().lng()], ]; } // ─── Markers ─────────────────────────────────────────────────────────────── async addMarker(id, lat, lng, options = {}) { if (!this._map) return null; if (!this._supportsAdvancedMarkers) { const classicMarker = this.#createClassicMarker(lat, lng, options); if (!classicMarker) return null; this._markers.set(id, classicMarker); return classicMarker; } const { AdvancedMarkerElement } = await google.maps.importLibrary('marker'); // Build the content element — either a custom HTML element or an img icon let content = options.content ?? null; if (!content && options.iconUrl) { const img = document.createElement('img'); img.src = options.iconUrl; const size = options.iconSize ?? [24, 24]; img.width = size[0]; img.height = size[1]; img.style.width = `${size[0]}px`; img.style.height = `${size[1]}px`; img.style.maxWidth = 'none'; img.style.maxHeight = 'none'; img.style.objectFit = 'contain'; img.style.display = 'block'; img.alt = options.alt ?? options.title ?? ''; // Wrapper div allows rotation transforms without affecting the anchor const wrapper = document.createElement('div'); wrapper.className = 'fleetops-map-marker'; wrapper.style.cssText = `width: ${size[0]}px; height: ${size[1]}px; max-width: ${size[0]}px; max-height: ${size[1]}px; line-height: 0; transform-origin: center center; display: block; overflow: visible;`; wrapper.appendChild(img); content = wrapper; } if (content && options.title) { content.setAttribute?.('title', options.title); } const marker = new AdvancedMarkerElement({ map: this._map, position: { lat, lng }, content, title: options.title ?? '', zIndex: options.zIndexOffset ?? 0, }); // Apply initial rotation if (options.rotationAngle && content) { content.style.transform = `rotate(${options.rotationAngle}deg)`; } // Click handler if (typeof options.onClick === 'function') { marker.addListener('click', options.onClick); } if (typeof options.onRightClick === 'function') { marker.addListener('rightclick', (event) => options.onRightClick(this.#normalizeMapEvent('rightclick', event))); } if (options.tooltip && content) { marker.__tooltipCleanup = this.#attachMarkerTooltip(content, options.tooltip, options.tooltipOptions ?? {}); } this._markers.set(id, marker); return marker; } updateMarkerPosition(id, lat, lng, animated = true, duration = 500) { const marker = this._markers.get(id); if (!marker) return; const nextPosition = this.#normalizeMarkerPosition(lat, lng); if (!nextPosition) return; // Cancel any running animation for this marker const cancelPrev = this._animations.get(id); if (cancelPrev) { cancelPrev(); this._animations.delete(id); } if (!animated || duration <= 0) { this.#setMarkerPosition(marker, nextPosition); return; } // Smooth animation via requestAnimationFrame interpolation const startPos = this.#extractMarkerPosition(marker); if (!startPos) { this.#setMarkerPosition(marker, nextPosition); return; } const startLat = startPos.lat; const startLng = startPos.lng; const startTime = performance.now(); let rafId = null; let cancelled = false; const animate = (currentTime) => { if (cancelled) return; const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); // Ease-out cubic for natural deceleration const eased = 1 - Math.pow(1 - progress, 3); const currentLat = startLat + (lat - startLat) * eased; const currentLng = startLng + (lng - startLng) * eased; this.#setMarkerPosition(marker, { lat: currentLat, lng: currentLng }); if (progress < 1) { rafId = requestAnimationFrame(animate); } else { this._animations.delete(id); } }; rafId = requestAnimationFrame(animate); this._animations.set(id, () => { cancelled = true; if (rafId) cancelAnimationFrame(rafId); }); } setMarkerRotation(id, degrees) { const marker = this._markers.get(id); if (!marker?.content) return; // Apply rotation to the content element marker.content.style.transform = `rotate(${degrees}deg)`; marker.content.style.transformOrigin = 'center center'; } removeMarker(id) { const marker = this._markers.get(id); if (marker) { // Cancel animation const cancel = this._animations.get(id); if (cancel) { cancel(); this._animations.delete(id); } marker.__tooltipCleanup?.(); if (typeof marker.setMap === 'function') { marker.setMap(null); } else { marker.map = null; } this._markers.delete(id); } } // ─── Overlays ────────────────────────────────────────────────────────────── addPolygon(id, coordinates, options = {}) { if (!this._map) return null; const paths = this.#normalizePolygonPaths(coordinates); if (!paths) return null; const polygon = new google.maps.Polygon({ paths, map: this._map, strokeColor: options.color ?? '#3388ff', strokeWeight: options.weight ?? 3, strokeOpacity: 1, fillColor: options.fillColor ?? options.color ?? '#3388ff', fillOpacity: options.fillOpacity ?? 0.2, editable: options.editable ?? false, clickable: options.clickable ?? true, zIndex: options.zIndex ?? 1, }); if (typeof options.onRightClick === 'function') { polygon.addListener('rightclick', (event) => { this._selectedOverlay = polygon; options.onRightClick(this.#normalizeMapEvent('rightclick', event)); }); } polygon.addListener('click', () => { this._selectedOverlay = polygon; }); if (options.tooltip) { polygon.__labelText = options.tooltip; polygon.__labelPaths = paths; polygon.__labelMarker = this.#createOverlayLabel(paths, options.tooltip); polygon.__tooltipCleanup = this.#attachOverlayLabelHover(polygon, polygon.__labelMarker); } this._overlays.set(id, polygon); return polygon; } addPolyline(id, coordinates, options = {}) { if (!this._map) return null; const path = this.#normalizeLinePath(coordinates); if (!path) return null; const polyline = new google.maps.Polyline({ path, map: this._map, strokeColor: options.color ?? '#3388ff', strokeWeight: options.weight ?? 3, strokeOpacity: options.opacity ?? 1, }); this._overlays.set(id, polyline); return polyline; } addCircle(id, lat, lng, radiusMeters, options = {}) { if (!this._map) return null; const circle = new google.maps.Circle({ center: { lat, lng }, radius: radiusMeters, map: this._map, strokeColor: options.color ?? '#3388ff', strokeWeight: options.weight ?? 3, strokeOpacity: 1, fillColor: options.fillColor ?? options.color ?? '#3388ff', fillOpacity: options.fillOpacity ?? 0.2, editable: options.editable ?? false, clickable: options.clickable ?? true, }); this._overlays.set(id, circle); return circle; } removeOverlay(id) { const overlay = this._overlays.get(id); if (overlay) { overlay.__tooltipCleanup?.(); overlay.setMap(null); if (overlay.__labelMarker) { this.#setOverlayLabelMap(overlay.__labelMarker, null); } this._overlays.delete(id); } } async addRoutingControl(route, options = {}) { if (!this._map || !route?.waypoints?.length) return null; const handleId = options.id ?? `route:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 8)}`; const routeStyles = options.polylineOptions?.styles ?? null; const polylineIds = []; if (route.coordinates?.length) { const polylineLayers = routeStyles?.length ? routeStyles.map((style, index) => ({ id: `${handleId}:polyline:${index}`, options: { color: style.color, weight: style.weight, opacity: style.opacity, }, })) : [ { id: `${handleId}:polyline:0`, options: { color: options.polylineOptions?.color ?? options.color ?? '#2563eb', weight: options.polylineOptions?.weight ?? 4, opacity: options.polylineOptions?.opacity ?? 0.85, }, }, ]; polylineLayers.forEach(({ id, options: polylineOptions }) => { const polyline = this.addPolyline(id, route.coordinates, polylineOptions); if (polyline) { polylineIds.push(id); } }); } const markerIds = []; const markerWaypoints = options.markerWaypoints ?? route.waypoints ?? []; if (!options.suppressMarkers) { for (const [index, waypoint] of markerWaypoints.entries()) { const markerId = `${handleId}:marker:${index}`; const markerOptions = this.#buildRouteMarkerOptions(waypoint, index, route, options); if (markerOptions === null) continue; await this.addMarker(markerId, waypoint[0], waypoint[1], markerOptions); markerIds.push(markerId); } } const handle = { id: handleId, engine: route.engine, route, markerIds, polylineIds, tag: options.tag ?? null, raw: route.raw, bounds: route.bounds, }; this._routingControls.set(handleId, handle); return handle; } removeRoutingControl(handle) { const resolvedHandle = typeof handle === 'string' ? this._routingControls.get(handle) : handle; if (!resolvedHandle) return false; resolvedHandle.markerIds?.forEach((id) => this.removeMarker(id)); resolvedHandle.polylineIds?.forEach((id) => this.removeOverlay(id)); this._routingControls.delete(resolvedHandle.id); return true; } positionWaypoints(waypointsOrBounds, options = {}) { if (!this._map) return null; const isBounds = options.isBounds === true; const bounds = isBounds ? waypointsOrBounds : null; const waypoints = isBounds ? null : waypointsOrBounds; const paddingBottomRight = options.paddingBottomRight ?? [300, 0]; const singlePointZoom = options.singlePointZoom ?? 18; const maxZoom = options.maxZoom ?? (isArray(waypoints) && waypoints.length === 2 ? 15 : 14); const panBy = options.panBy ?? [0, 0]; if (isArray(waypoints) && waypoints.length === 1) { this.flyTo(waypoints[0][0], waypoints[0][1], singlePointZoom, { animate: true }); google.maps.event.addListenerOnce(this._map, 'idle', () => this.panBy(panBy[0], panBy[1])); return true; } if (bounds || (isArray(waypoints) && waypoints.length > 1)) { this.fitBounds(bounds ?? waypoints, { paddingBottomRight, maxZoom, animate: true, }); google.maps.event.addListenerOnce(this._map, 'idle', () => this.panBy(panBy[0], panBy[1])); return true; } return null; } #getConfig() { try { const owner = getOwner(this); const config = owner?.resolveRegistration('config:environment'); return config?.['@fleetbase/fleetops-engine'] ?? config ?? null; } catch { return null; } } removeLayer(layer) { if (!layer) return; layer.__tooltipCleanup?.(); if (typeof layer.setMap === 'function') { layer.setMap(null); } else if ('map' in layer) { layer.map = null; } this._draftOverlays.delete(layer); this._pendingDeletedDrafts.delete(layer); if (this._selectedOverlay === layer) { this._selectedOverlay = null; } } // ─── Layer Visibility ────────────────────────────────────────────────────── showLayer(layer) { if (!layer) return; if (typeof layer.setMap === 'function') { layer.setMap(this._map); } else if (layer.map === null) { layer.map = this._map; } this.#syncOverlayPresentation(layer); if (layer.__labelMarker) { this.#setOverlayLabelMap(layer.__labelMarker, this._map); } layer.__hidden = false; } hideLayer(layer) { if (!layer) return; if (typeof layer.setMap === 'function') { layer.setMap(null); } else { layer.map = null; } if (layer.__labelMarker) { this.#setOverlayLabelMap(layer.__labelMarker, null); } layer.__tooltipCleanup?.hide?.(); layer.__hidden = true; } isLayerHidden(layer) { if (!layer) return true; return layer.__hidden === true; } isLayerVisible(layer) { return !this.isLayerHidden(layer); } // ─── Drawing Tools ───────────────────────────────────────────────────────── enableDrawingMode(type, options = {}) { if (!this._drawingManager) { debug('[GoogleMapsAdapter] DrawingManager not initialized'); return; } const modeMap = { polygon: google.maps.drawing.OverlayType.POLYGON, circle: google.maps.drawing.OverlayType.CIRCLE, rectangle: google.maps.drawing.OverlayType.RECTANGLE, polyline: google.maps.drawing.OverlayType.POLYLINE, marker: google.maps.drawing.OverlayType.MARKER, }; this._drawingManager.setDrawingMode(modeMap[type] ?? null); this._drawingManager.setMap(this._map); this._drawingOnCreate = typeof options.onCreate === 'function' ? options.onCreate : null; } disableDrawingMode() { this._drawingManager?.setDrawingMode(null); this._drawingOnCreate = null; // Fire normalized event for listeners this.#fireNormalizedEvent('draw:drawstop', {}); } showDrawControl(config = {}) { this._drawControlConfig = config; this.#ensureDrawToolbar(); if (this._drawControlEl) { this._drawControlEl.style.display = ''; } this.#applyDrawToolbarConfig(config); if (config?.defaultMode) { this.enableDrawingMode(config.defaultMode); } } hideDrawControl() { if (this._drawControlEl) { this._drawControlEl.style.display = 'none'; } this._drawActionEl?.remove(); this._drawActionEl = null; this.#restorePendingDeletedDrafts(); this.disableDrawingMode(); } // ─── Popups / Info Windows ───────────────────────────────────────────────── openPopup(id, lat, lng, htmlContent) { if (!this._map) return null; const infoWindow = new google.maps.InfoWindow({ content: typeof htmlContent === 'string' ? htmlContent : htmlContent, position: { lat, lng }, }); infoWindow.open({ map: this._map }); this._popups.set(id, infoWindow); return infoWindow; } closePopup(id) { const popup = this._popups.get(id); if (popup) { popup.close(); this._popups.delete(id); } } // ─── Context Menus ───────────────────────────────────────────────────────── registerContextMenu(target, items) { this._contextMenus.set(target, items); } removeContextMenu(target) { this._contextMenus.delete(target); const el = this._contextMenuEls.get(target); if (el) { el.remove(); this._contextMenuEls.delete(target); } } getContextMenuItems(target) { return this._contextMenus.get(target) ?? []; } showContextMenu(event, items = []) { const latlng = event?.latlng; if (!latlng || !items.length) return; this.closeContextMenu(); this.#renderContextMenu(event?.originalEvent, latlng, items); } closeContextMenu() { this._contextMenuCleanup?.(); this._contextMenuCleanup = null; } // ─── Events ──────────────────────────────────────────────────────────────── on(event, handler) { if (!this._map) return; const gmEvent = this.#normalizeEvent(event); if (gmEvent.startsWith('draw:')) { if (!this._eventListeners.has(event)) { this._eventListeners.set(event, []); } this._eventListeners.get(event).push({ handler }); } else { const listener = this._map.addListener(gmEvent, (e) => { handler(this.#normalizeMapEvent(event, e)); }); if (!this._eventListeners.has(event)) { this._eventListeners.set(event, []); } this._eventListeners.get(event).push({ handler, listener }); } } off(event, handler) { const listeners = this._eventListeners.get(event) ?? []; const entry = listeners.find((e) => e.handler === handler); if (entry) { if (entry.listener) { google.maps.event.removeListener(entry.listener); } this._eventListeners.set( event, listeners.filter((e) => e !== entry) ); } } once(event, handler) { if (!this._map) return; if (event.startsWith('draw:')) { const wrappedHandler = (payload) => { this.off(event, wrappedHandler); handler(payload); }; this.on(event, wrappedHandler); return wrappedHandler; } const gmEvent = this.#normalizeEvent(event); const listener = google.maps.event.addListenerOnce(this._map, gmEvent, (e) => { handler(this.#normalizeMapEvent(event, e)); }); return listener; } // ─── Utilities ───────────────────────────────────────────────────────────── distanceBetween(lat1, lng1, lat2, lng2) { return google.maps.geometry.spherical.computeDistanceBetween(new google.maps.LatLng(lat1, lng1), new google.maps.LatLng(lat2, lng2)); } addGeoJson(id, geojson, options = {}) { if (!this._map) return null; const dataLayer = new google.maps.Data({ map: this._map }); dataLayer.addGeoJson(geojson); if (options.style) { dataLayer.setStyle(options.style); } this._geojsonLayers.set(id, dataLayer); return dataLayer; } removeGeoJson(id) { const layer = this._geojsonLayers.get(id); if (layer) { layer.setMap(null); this._geojsonLayers.delete(id); } } setTileLayer(url, options = {}) { if (!this._map) return; if (this._customTileLayer) { this._map.overlayMapTypes.clear(); } // Create a custom ImageMapType for the tile URL this._customTileLayer = new google.maps.ImageMapType({ getTileUrl: (coord, zoom) => { return url .replace('{z}', zoom) .replace('{x}', coord.x) .replace('{y}', coord.y) .replace('{s}', ['a', 'b', 'c'][Math.floor(Math.random() * 3)]); }, tileSize: new google.maps.Size(256, 256), name: options.name ?? 'Custom Tiles', opacity: options.opacity ?? 1, }); this._map.overlayMapTypes.insertAt(0, this._customTileLayer); } registerMarker(id, markerObject) { return super.registerMarker(id, markerObject); } registerPolygon(id, polygonObject) { return super.registerPolygon(id, polygonObject); } showCoordinates(event) { const lat = event?.latlng?.lat ?? event?.latlng?.lat?.(); const lng = event?.latlng?.lng ?? event?.latlng?.lng?.(); return { lat, lng }; } centerMap(event) { const lat = event?.latlng?.lat ?? event?.latlng?.lat?.(); const lng = event?.latlng?.lng ?? event?.latlng?.lng?.(); if (Number.isFinite(lat) && Number.isFinite(lng)) { this._map?.panTo({ lat, lng }); } } toggleDrawControl() { this.#ensureDrawToolbar(); if (!this._drawControlEl) return; const isHidden = this._drawControlEl.style.display === 'none' || this._drawControlEl.style.display === ''; if (!isHidden) { this.hideDrawControl(); return; } this.showDrawControl( this._drawControlConfig ?? { tools: ['polygon', 'circle', 'rectangle'], allowEdit: true, allowDelete: true, defaultMode: null, } ); } editPolygon(layer, { focusBounds = null } = {}) { if (!layer || typeof layer.setEditable !== 'function') { return Promise.resolve({ type: 'unsupported' }); } const originalState = this.#captureOverlayState(layer); if (!originalState) { return Promise.resolve({ type: 'unsupported' }); } if (focusBounds) { try { this.fitBounds(focusBounds, { paddingBottomRight: [0, 0], maxZoom: 16, animate: true }); } catch { // noop } } layer.setEditable(true); const cleanupActionBar = this.#showEditActionBar({ onSave: () => { const geoJson = this.#geoJsonFromOverlay(layer); finalize({ type: 'edited', layer, geoJson, toGeoJSON: () => geoJson, }); }, onCancel: () => { this.#restoreOverlayState(layer, originalState); finalize({ type: 'cancel' }); }, }); let settled = false; const finalize = (result) => { if (settled) return; settled = true; layer.setEditable(false); cleanupActionBar?.(); resolve(result); }; let resolve; return new Promise((promiseResolve) => { resolve = promiseResolve; }); } panBy(x, y = 0) { this._map?.panBy?.(x, y); } #buildRouteMarkerOptions(waypoint, index, route, options) { if (typeof options.createMarker === 'function') { const customOptions = options.createMarker(waypoint, index, route); if (customOptions === null || customOptions === false) { return null; } if (customOptions && typeof customOptions === 'object') { if (customOptions.waypointLabel) { return { ...customOptions, content: buildWaypointMarkerContent(customOptions.waypointLabel, customOptions.waypointColor ?? '#2563eb'), iconSize: customOptions.iconSize ?? [32, 32], iconAnchor: customOptions.iconAnchor ?? [16, 16], zIndexOffset: customOptions.zIndexOffset ?? 100000, }; } return customOptions; } } return { iconUrl: '/assets/images/marker-icon.png', iconSize: [25, 41], iconAnchor: [12, 41], draggable: false, ...options.markerOptions, }; } // ─── Private ─────────────────────────────────────────────────────────────── /** * Load the Google Maps JavaScript API dynamically. * Uses the configured API key from environment config. * * @param {Object} options * @returns {Promise<void>} */ async #loadGoogleMapsApi(options = {}) { if (this._apiLoaded || (typeof google !== 'undefined' && google.maps)) { this._apiLoaded = true; return; } const apiKey = options.apiKey ?? ''; if (!apiKey) { debug('[GoogleMapsAdapter] Warning: No Google Maps API key configured.'); } return new Promise((resolve, reject) => { if (typeof google !== 'undefined' && google.maps) { this._apiLoaded = true; resolve(); return; } const callbackName = `__fleetops_gmaps_cb_${Date.now()}`; window[callbackName] = () => { this._apiLoaded = true; delete window[callbackName]; resolve(); }; const script = document.createElement('script'); script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=drawing,geometry,marker,routes&callback=${callbackName}&loading=async`; script.async = true; script.onerror = () => reject(new Error('[GoogleMapsAdapter] Failed to load Google Maps API')); document.head.appendChild(script); }); } /** * Initialize the DrawingManager (hidden by default). */ async #initDrawingManager() { try { const googleMaps = window.google; const { DrawingManager } = await googleMaps.maps.importLibrary('drawing'); this._drawingManager = new DrawingManager({ drawingControl: false, drawingMode: null, polygonOptions: { strokeColor: '#3388ff', fillColor: '#3388ff', fillOpacity: 0.2, strokeWeight: 3, editable: true, clickable: true, }, circleOptions: { strokeColor: '#3388ff', fillColor: '#3388ff', fillOpacity: 0.2, strokeWeight: 3, editable: true, clickable: true, }, rectangleOptions: { strokeColor: '#3388ff', fillColor: '#3388ff', fillOpacity: 0.2, strokeWeight: 3, editable: true, clickable: true, }, }); // Bridge DrawingManager events to normalized 'draw:created' event this._drawingManager.addListener('overlaycomplete', (e) => { const typeMap = { [googleMaps.maps.drawing.OverlayType.POLYGON]: 'polygon', [googleMaps.maps.drawing.OverlayType.CIRCLE]: 'circle', [googleMaps.maps.drawing.OverlayType.RECTANGLE]: 'rectangle', [googleMaps.maps.drawing.OverlayType.POLYLINE]: 'polyline', [googleMaps.maps.drawing.OverlayType.MARKER]: 'marker', }; const geoJson = this.#overlayToGeoJson(e.overlay, e.type); this._selectedOverlay = e.overlay; this._draftOverlays.add(e.overlay); e.overlay.__overlayType = typeMap[e.type] ?? e.type; this.#bindOverlaySelection(e.overlay); const normalizedEvent = { layerType: typeMap[e.type] ?? e.type, layer: e.overlay, geoJson, toGeoJSON: () => geoJson, }; this._drawingOnCreate?.(normalizedEvent); this.#fireNormalizedEvent('draw:created', normalizedEvent); this._drawingManager.setDrawingMode(null); }); } catch (e) { debug('[GoogleMapsAdapter] DrawingManager not available: ' + e.message); } } #ensureDrawToolbar() { if (this._drawControlEl || !this._map) return; const topOffset = this.#getToolbarTopOffset(); const container = document.createElement('div'); container.className = 'fleetops-google-draw'; container.style.cssText = ` position:absolute; top:${topOffset}px; right:16px; z-index:5; display:none; pointer-events:none; `; const toolSection = document.createElement('div'); toolSection.className = 'fleetops-google-draw-section'; const toolGroup = document.createElement('div'); toolGroup.className = 'fleetops-google-draw-toolbar fleetops-google-draw-toolbar-top fleetops-google-draw-bar'; const actionSection = document.createElement('div'); actionSection.className = 'fleetops-google-draw-section'; const actionGroup = document.createElement('div'); actionGroup.className = 'fleetops-google-draw-toolbar fleetops-google-draw-bar'; toolSection.appendChild(toolGroup); actionSection.appendChild(actionGroup); container.appendChild(toolSection); container.appendChild(actionSection); this._drawControlEl = container; this._drawControlEl.__toolGroup = toolGroup; this._drawControlEl.__actionGroup = actionGroup; this._drawControlEl.__toolSection = toolSection; this._drawControlEl.__actionSection = actionSection; this._map.getDiv()?.appendChild(container); } #applyDrawToolbarConfig(config = {}) { if (!this._drawControlEl) return; this._drawControlEl.style.top = `${this.#getToolbarTopOffset()}px`; const toolGroup = this._drawControlEl.__toolGroup; const actionGroup = this._drawControlEl.__actionGroup; const actionSection = this._drawControlEl.__actionSection; toolGroup.innerHTML = ''; actionGroup.innerHTML = ''; const tools = config.tools ?? ['polygon', 'circle', 'rectangle']; const toolButtons = [ ['polygon', '▱'], ['rectangle', '▭'], ['circle', '◉'], ]; toolButtons .filter(([tool]) => tools.includes(tool)) .forEach(([tool, icon]) => { toolGroup.appendChild( this.#createToolbarButton(icon, tool, `fleetops-google-draw-draw-${tool}`, () => { this.enableDrawingMode(tool); }) ); }); if (config.allowEdit) { actionGroup.appendChild( this.#createToolbarButton('✎', 'edit', 'fleetops-google-draw-edit-edit', () => { this.#editSelectedOverlay(); }) ); } if (config.allowDelete) { actionGroup.appendChild( this.#createToolbarButton('🗑', 'delete', 'fleetops-google-draw-edit-remove', () => { this.#deleteSelectedOverlay(); }) ); } actionSection.style.display = config.allowEdit || config.allowDelete ? '' : 'none'; } #createToolbarButton(icon, label, className, onClick) { const button = document.createElement('a'); button.href = '#'; button.className = className; button.setAttribute('aria-label', label); button.title = label; const srOnly = document.createElement('span'); srOnly.className = 'sr-only'; srOnly.textContent = label; button.appendChild(srOnly); button.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); onClick?.(); }); return button; } #showEditActionBar({ onSave, onCancel, onClearAll = null }) { this._drawActionEl?.remove(); const topOffset = this.#getToolbarTopOffset() + 90; const actionBar = document.createElement('ul'); actionBar.className = 'fleetops-google-draw-actions fleetops-google-draw-actions-top'; actionBar.style.cssText = ` position:absolute; top:${topOffset}px; right:78px; z-index:6; display:flex; `; const save = this.#createActionButton('Save', onSave); const cancel = this.#createActionButton('Cancel', onCancel); const clearAll = typeof onClearAll === 'function' ? this.#createActionButton('Clear All', onClearAll) : null; actionBar.appendChild(save); actionBar.appendChild(cancel); if (clearAll) { actionBar.appendChild(clearAll); } this._drawActionEl = actionBar; this._map?.getDiv()?.appendChild(actionBar); return () => { actionBar.remove(); if (this._drawActionEl === actionBar) { this._drawActionEl = null; } }; } #createActionButton(label, onClick) { const item = document.createElement('li'); const button = document.createElement('a'); button.href = '#'; button.textContent = label; button.className = 'fleetops-google-draw-action-link'; button.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); onClick?.(); }); item.appendChild(button); return item; } #editSelectedOverlay() { const layer = this._selectedOverlay; if (!layer || !this._draftOverlays.has(layer) || typeof layer.setEditable !== 'function') return; const originalState = this.#captureOverlayState(layer); if (!originalState) return; layer.setEditable(true); const cleanupActionBar = this.#showEditActionBar({ onSave: () => { const geoJson = this.#geoJsonFromOverlay(layer); layer.setEditable(false); cleanupActionBar?.(); this.#fireNormalizedEvent('draw:edited', { type: 'draw:edited', layer, layerType: layer.__overlayType ?? 'polygon', geoJson, toGeoJSON: () => geoJson, }); }, onCancel: () => { this.#restoreOverlayState(layer, originalState); layer.setEditable(false); cleanupActionBar?.(); }, }); } #deleteSelectedOverlay() { const layer = this._selectedOverlay; if (!layer || !this._draftOverlays.has(layer)) return; this._pendingDeletedDrafts.add(layer); this.hideLayer(layer); const cleanupActionBar = this.#showEditActionBar({ onSave: () => { const deletedLayers = Array.from(this._pendingDeletedDrafts); deletedLayers.forEach((draftLayer) => { const geoJson = this.#geoJsonFromOverlay(draftLayer); this._draftOverlays.delete(draftLayer); this._pendingDeletedDrafts.delete(draftLayer); this.#fireNormalizedEvent('draw:deleted', { type: 'draw:deleted', layer: draftLayer, layerType: draftLayer.__overlayType ?? 'polygon', geoJson, toGeoJSON: () => geoJson, }); }); this._selectedOverlay = null;