@fleetbase/fleetops-engine
Version:
Fleet & Transport Management Extension for Fleetbase
1,457 lines (1,238 loc) • 78.4 kB
JavaScript
/* 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;