@fleetbase/fleetops-engine 0.6.45 → 0.6.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/addon/components/admin/map-settings.hbs +26 -0
  2. package/addon/components/admin/map-settings.js +46 -0
  3. package/addon/components/admin/routing-settings.hbs +11 -0
  4. package/addon/components/cell/order-route-type.hbs +46 -0
  5. package/addon/components/cell/order-route-type.js +90 -0
  6. package/addon/components/cell/place-address.hbs +1 -0
  7. package/addon/components/display-place.hbs +45 -56
  8. package/addon/components/display-place.js +21 -0
  9. package/addon/components/layout/fleet-ops-sidebar/driver-listing.js +5 -5
  10. package/addon/components/layout/fleet-ops-sidebar/fleet-listing.js +5 -5
  11. package/addon/components/layout/fleet-ops-sidebar.js +8 -0
  12. package/addon/components/maintenance-schedule/form.js +2 -1
  13. package/addon/components/map/drawer/driver-listing.js +5 -5
  14. package/addon/components/map/drawer/place-listing.js +5 -5
  15. package/addon/components/map/drawer/position-listing.js +102 -21
  16. package/addon/components/map/drawer/vehicle-listing.js +5 -5
  17. package/addon/components/map/google-live-map.hbs +9 -0
  18. package/addon/components/map/google-live-map.js +445 -0
  19. package/addon/components/map/leaflet-live-map.hbs +186 -142
  20. package/addon/components/map/leaflet-live-map.js +121 -41
  21. package/addon/components/map/toolbar/visibility-control-panel.js +2 -2
  22. package/addon/components/map/toolbar/zones-panel.hbs +9 -9
  23. package/addon/components/map/toolbar.hbs +3 -3
  24. package/addon/components/map/toolbar.js +9 -1
  25. package/addon/components/modals/map-layer-form.hbs +132 -63
  26. package/addon/components/modals/orchestrator-import.hbs +4 -11
  27. package/addon/components/modals/orchestrator-import.js +17 -1
  28. package/addon/components/modals/update-order-activity.hbs +1 -1
  29. package/addon/components/orchestrator/order-pool.js +3 -2
  30. package/addon/components/orchestrator-workbench.js +29 -41
  31. package/addon/components/order/details/detail.js +2 -2
  32. package/addon/components/order/details/payload.hbs +5 -5
  33. package/addon/components/order/details/payload.js +18 -1
  34. package/addon/components/order/details/route.hbs +2 -2
  35. package/addon/components/order/details/route.js +76 -3
  36. package/addon/components/order/form/payload.hbs +33 -0
  37. package/addon/components/order/form/payload.js +1 -0
  38. package/addon/components/order/form/route.hbs +8 -8
  39. package/addon/components/order/form/route.js +78 -18
  40. package/addon/components/order/form.js +2 -2
  41. package/addon/components/order/route-editor.hbs +209 -147
  42. package/addon/components/order/route-editor.js +143 -102
  43. package/addon/components/place/address.hbs +1 -0
  44. package/addon/components/place/address.js +12 -0
  45. package/addon/components/route-list.hbs +43 -55
  46. package/addon/components/route-list.js +47 -0
  47. package/addon/components/route-optimization-wizard-panel.js +3 -4
  48. package/addon/components/vehicle/card.hbs +4 -4
  49. package/addon/controllers/maintenance/schedules/index.js +2 -1
  50. package/addon/controllers/operations/orders/index/details.js +110 -37
  51. package/addon/controllers/operations/orders/index/new.js +1 -1
  52. package/addon/controllers/operations/orders/index.js +8 -1
  53. package/addon/controllers/settings/map.js +97 -0
  54. package/addon/controllers/settings/routing.js +47 -12
  55. package/addon/extension.js +11 -0
  56. package/addon/helpers/place-address-html.js +6 -0
  57. package/addon/instance-initializers/register-google-routes.js +17 -0
  58. package/addon/instance-initializers/register-osrm.js +5 -0
  59. package/addon/routes/application.js +6 -0
  60. package/addon/routes/operations/orchestrator.js +13 -0
  61. package/addon/routes/operations/orders/index/details.js +29 -22
  62. package/addon/routes/operations/orders/index/new.js +9 -0
  63. package/addon/routes/operations/orders/index.js +0 -1
  64. package/addon/routes/settings/map.js +3 -0
  65. package/addon/routes.js +1 -0
  66. package/addon/services/entity-actions.js +1 -1
  67. package/addon/services/geofence-event-bus.js +2 -1
  68. package/addon/services/geofence.js +204 -168
  69. package/addon/services/google-routes.js +300 -0
  70. package/addon/services/leaflet-draw-restriction.js +2 -1
  71. package/addon/services/leaflet-map-manager.js +17 -0
  72. package/addon/services/map-adapter/google.js +2205 -0
  73. package/addon/services/map-adapter/leaflet.js +1042 -0
  74. package/addon/services/map-adapter-interface.js +551 -0
  75. package/addon/services/map-manager.js +768 -0
  76. package/addon/services/map-settings.js +75 -0
  77. package/addon/services/movement-tracker.js +83 -32
  78. package/addon/services/order-actions.js +11 -1
  79. package/addon/services/osrm.js +79 -6
  80. package/addon/services/position-playback.js +135 -62
  81. package/addon/services/route-engine.js +83 -0
  82. package/addon/services/service-areas.js +8 -1
  83. package/addon/styles/fleetops-engine.css +555 -0
  84. package/addon/templates/management/vehicles/index.hbs +0 -1
  85. package/addon/templates/operations/orders/index/details.hbs +0 -1
  86. package/addon/templates/settings/map.hbs +53 -0
  87. package/addon/templates/settings/routing.hbs +15 -4
  88. package/addon/utils/fleet-ops-options.js +1 -0
  89. package/addon/utils/has-valid-coordinates.js +6 -0
  90. package/addon/utils/map-provider-options.js +6 -0
  91. package/addon/utils/order-route-editing.js +86 -0
  92. package/addon/utils/order-route-summary.js +20 -0
  93. package/addon/utils/place-address-html.js +41 -0
  94. package/addon/utils/route-colors.js +43 -12
  95. package/addon/utils/route-visualization.js +93 -0
  96. package/addon/utils/vendor-integration.js +2 -1
  97. package/app/components/admin/map-settings.js +1 -0
  98. package/app/components/admin/routing-settings.js +1 -0
  99. package/app/components/cell/order-route-type.js +1 -0
  100. package/app/components/cell/place-address.js +1 -0
  101. package/app/components/map/google-live-map.js +1 -0
  102. package/app/components/place/address.js +1 -0
  103. package/app/controllers/settings/map.js +1 -0
  104. package/app/helpers/place-address-html.js +1 -0
  105. package/app/routes/settings/map.js +1 -0
  106. package/app/services/map-adapter/google.js +1 -0
  107. package/app/services/map-adapter/leaflet.js +1 -0
  108. package/app/services/map-adapter-interface.js +1 -0
  109. package/app/services/map-manager.js +1 -0
  110. package/app/services/map-settings.js +1 -0
  111. package/app/templates/settings/map.js +1 -0
  112. package/app/utils/has-valid-coordinates.js +1 -0
  113. package/app/utils/order-route-editing.js +2 -0
  114. package/app/utils/order-route-summary.js +1 -0
  115. package/app/utils/place-address-html.js +1 -0
  116. package/app/utils/route-colors.js +1 -1
  117. package/app/utils/route-visualization.js +1 -0
  118. package/assets/images/drawing-controls-spritesheet.svg +156 -0
  119. package/composer.json +1 -1
  120. package/extension.json +1 -1
  121. package/package.json +3 -3
  122. package/server/src/Auth/Schemas/FleetOps.php +5 -0
  123. package/server/src/Http/Controllers/Api/v1/OrderController.php +31 -3
  124. package/server/src/Http/Controllers/Api/v1/PayloadController.php +14 -1
  125. package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +2 -0
  126. package/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php +3 -2
  127. package/server/src/Http/Controllers/Api/v1/ZoneController.php +2 -0
  128. package/server/src/Http/Controllers/Internal/v1/LiveController.php +2 -1
  129. package/server/src/Http/Controllers/Internal/v1/OrchestrationController.php +7 -4
  130. package/server/src/Http/Controllers/Internal/v1/OrderController.php +61 -34
  131. package/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php +3 -2
  132. package/server/src/Http/Controllers/Internal/v1/SettingController.php +106 -3
  133. package/server/src/Http/Filter/OrderFilter.php +2 -1
  134. package/server/src/Http/Resources/v1/Index/Payload.php +9 -7
  135. package/server/src/Http/Resources/v1/Order.php +20 -1
  136. package/server/src/Models/Order.php +42 -1
  137. package/server/src/Models/OrderConfig.php +15 -0
  138. package/server/src/Models/Payload.php +131 -27
  139. package/server/src/Models/Place.php +182 -48
  140. package/server/src/Models/ServiceRate.php +4 -3
  141. package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +59 -23
  142. package/server/src/Support/Utils.php +36 -2
  143. package/server/src/routes.php +4 -0
  144. package/translations/en-us.yaml +1937 -1916
@@ -0,0 +1,2205 @@
1
+ /* global google */
2
+ /**
3
+ * GoogleMapsAdapter
4
+ *
5
+ * Implements the MapAdapterInterface using the Google Maps JavaScript API.
6
+ * Provides 1-to-1 feature parity with the LeafletAdapter, including:
7
+ *
8
+ * - Live tracking markers with smooth animation (custom rAF interpolation)
9
+ * - Marker rotation via CSS transforms on AdvancedMarkerElement
10
+ * - Geofence drawing via DrawingManager (with fallback for deprecation)
11
+ * - Editable polygons and circles
12
+ * - Custom right-click context menus
13
+ * - InfoWindow popups with custom HTML
14
+ * - GeoJSON rendering via the Data layer
15
+ * - Custom tile overlays via ImageMapType
16
+ * - Normalized event system matching the Leaflet adapter's event names
17
+ *
18
+ * Prerequisites:
19
+ * - `GOOGLE_MAPS_API_KEY` must be set in config/environment.js
20
+ * - `GOOGLE_MAPS_MAP_ID` must be set for AdvancedMarkerElement support
21
+ * - The Google Maps JS API is loaded lazily on first initializeMap() call
22
+ *
23
+ * @module services/map-adapter/google
24
+ */
25
+ import MapAdapterInterface from '../map-adapter-interface';
26
+ import { getOwner } from '@ember/application';
27
+ import { isArray } from '@ember/array';
28
+ import { debug } from '@ember/debug';
29
+ import { waypointIconHtml } from '../../utils/route-colors';
30
+ import { Circle, Feature, Polygon } from '@fleetbase/fleetops-data/utils/geojson';
31
+
32
+ const DEFAULT_GOOGLE_MAP_STYLES = [
33
+ {
34
+ featureType: 'all',
35
+ elementType: 'labels.icon',
36
+ stylers: [{ visibility: 'off' }],
37
+ },
38
+ {
39
+ featureType: 'poi',
40
+ elementType: 'all',
41
+ stylers: [{ visibility: 'off' }],
42
+ },
43
+ {
44
+ featureType: 'transit',
45
+ elementType: 'all',
46
+ stylers: [{ visibility: 'off' }],
47
+ },
48
+ {
49
+ featureType: 'administrative.land_parcel',
50
+ elementType: 'all',
51
+ stylers: [{ visibility: 'off' }],
52
+ },
53
+ {
54
+ featureType: 'road',
55
+ elementType: 'labels.icon',
56
+ stylers: [{ visibility: 'off' }],
57
+ },
58
+ {
59
+ featureType: 'landscape.man_made',
60
+ elementType: 'labels',
61
+ stylers: [{ visibility: 'off' }],
62
+ },
63
+ ];
64
+
65
+ function buildWaypointMarkerContent(label, color) {
66
+ const wrapper = document.createElement('div');
67
+ wrapper.className = 'fleetops-map-marker';
68
+ wrapper.style.cssText = 'transform-origin: center center; display: block;';
69
+ wrapper.innerHTML = waypointIconHtml(label, color);
70
+ return wrapper;
71
+ }
72
+
73
+ function buildWaypointMarkerSvg(label, color) {
74
+ const svg = `
75
+ <svg xmlns="http://www.w3.org/2000/svg" width="34" height="34" viewBox="0 0 34 34">
76
+ <circle cx="17" cy="17" r="15" fill="${color}" stroke="white" stroke-width="2" />
77
+ <text x="17" y="21" text-anchor="middle" font-family="Inter, Arial, sans-serif" font-size="14" font-weight="700" fill="white">${label}</text>
78
+ </svg>
79
+ `;
80
+
81
+ return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`;
82
+ }
83
+
84
+ export default class GoogleMapsAdapter extends MapAdapterInterface {
85
+ // ─── Internal State ────────────────────────────────────────────────────────
86
+
87
+ /** @type {google.maps.Map|null} */
88
+ _map = null;
89
+
90
+ /** @type {Map<string, google.maps.marker.AdvancedMarkerElement>} */
91
+ _markers = new Map();
92
+
93
+ /** @type {Map<string, google.maps.Polygon|google.maps.Polyline|google.maps.Circle>} */
94
+ _overlays = new Map();
95
+
96
+ /** @type {Map<string, google.maps.InfoWindow>} */
97
+ _popups = new Map();
98
+
99
+ /** @type {Map<string, google.maps.Data>} */
100
+ _geojsonLayers = new Map();
101
+
102
+ /** @type {google.maps.drawing.DrawingManager|null} */
103
+ _drawingManager = null;
104
+
105
+ /** @type {HTMLElement|null} DOM element for the custom draw toolbar */
106
+ _drawControlEl = null;
107
+
108
+ /** @type {google.maps.ImageMapType|null} */
109
+ _customTileLayer = null;
110
+
111
+ /** @type {Map<string, Function[]>} Normalized event name → array of [handler, gmListener] pairs */
112
+ _eventListeners = new Map();
113
+
114
+ /** @type {Map<string, HTMLElement>} Context menu DOM elements */
115
+ _contextMenuEls = new Map();
116
+
117
+ /** @type {Map<string, Array>} */
118
+ _contextMenus = new Map();
119
+
120
+ /** @type {Map<string, Function>} Active animation frame cancel functions */
121
+ _animations = new Map();
122
+
123
+ /** @type {boolean} Whether the Google Maps API has been loaded */
124
+ _apiLoaded = false;
125
+
126
+ /** @type {*|null} */
127
+ _advancedMarkerElementClass = null;
128
+
129
+ /** @type {boolean} */
130
+ _supportsAdvancedMarkers = false;
131
+
132
+ /** @type {Object|null} */
133
+ _drawControlConfig = null;
134
+
135
+ /** @type {HTMLElement|null} */
136
+ _drawActionEl = null;
137
+
138
+ /** @type {*|null} */
139
+ _selectedOverlay = null;
140
+
141
+ /** @type {Set<*>} */
142
+ _draftOverlays = new Set();
143
+
144
+ /** @type {Set<*>} */
145
+ _pendingDeletedDrafts = new Set();
146
+
147
+ /** @type {Function|null} */
148
+ _contextMenuCleanup = null;
149
+
150
+ /** @type {google.maps.OverlayView|null} */
151
+ _tooltipOverlayView = null;
152
+
153
+ // ─── Lifecycle ─────────────────────────────────────────────────────────────
154
+
155
+ async initializeMap(element, options = {}) {
156
+ this.destroyMap();
157
+ await this.#loadGoogleMapsApi(options);
158
+ const googleMaps = window.google;
159
+ const { Map } = await googleMaps.maps.importLibrary('maps');
160
+ const { AdvancedMarkerElement } = await googleMaps.maps.importLibrary('marker');
161
+ this._advancedMarkerElementClass = AdvancedMarkerElement;
162
+ const mergedGoogleOptions = options.googleOptions ?? {};
163
+ const mergedMapStyles = mergedGoogleOptions.styles === undefined ? DEFAULT_GOOGLE_MAP_STYLES : [...DEFAULT_GOOGLE_MAP_STYLES, ...mergedGoogleOptions.styles];
164
+ const mapId = options.mapId ?? mergedGoogleOptions.mapId ?? this.#getConfig()?.googleMaps?.mapId ?? this.#getConfig()?.GOOGLE_MAPS_MAP_ID ?? null;
165
+ this._supportsAdvancedMarkers = Boolean(mapId && mapId !== 'FLEETOPS_MAP');
166
+
167
+ this._map = new Map(element, {
168
+ center: { lat: options.lat ?? 1.3521, lng: options.lng ?? 103.8198 },
169
+ zoom: options.zoom ?? 12,
170
+ mapTypeId: options.mapTypeId ?? googleMaps.maps.MapTypeId.ROADMAP,
171
+ disableDefaultUI: options.disableDefaultUI ?? true,
172
+ gestureHandling: options.gestureHandling ?? 'greedy',
173
+ clickableIcons: mergedGoogleOptions.clickableIcons ?? false,
174
+ ...mergedGoogleOptions,
175
+ ...(mapId ? { mapId } : {}),
176
+ styles: mergedMapStyles,
177
+ });
178
+
179
+ await this.#initDrawingManager();
180
+
181
+ debug('[GoogleMapsAdapter] Map initialized');
182
+ return this._map;
183
+ }
184
+
185
+ destroyMap() {
186
+ // Cancel all running animations
187
+ for (const cancel of this._animations.values()) {
188
+ cancel();
189
+ }
190
+ this._animations.clear();
191
+
192
+ // Remove all markers
193
+ for (const marker of this._markers.values()) {
194
+ marker.map = null;
195
+ }
196
+ this._markers.clear();
197
+
198
+ // Remove all overlays
199
+ for (const overlay of this._overlays.values()) {
200
+ overlay.setMap(null);
201
+ }
202
+ this._overlays.clear();
203
+ this._routingControls.clear();
204
+
205
+ // Close all popups
206
+ for (const popup of this._popups.values()) {
207
+ popup.close();
208
+ }
209
+ this._popups.clear();
210
+
211
+ // Remove context menus
212
+ for (const el of this._contextMenuEls.values()) {
213
+ el.remove();
214
+ }
215
+ this._contextMenuEls.clear();
216
+ this._contextMenus.clear();
217
+ this._eventListeners.clear();
218
+
219
+ // Remove draw control
220
+ this._drawControlEl?.remove();
221
+ this._drawControlEl = null;
222
+ this._drawActionEl?.remove();
223
+ this._drawActionEl = null;
224
+ this._drawingManager?.setMap(null);
225
+ this._drawingManager = null;
226
+ this._selectedOverlay = null;
227
+ this._draftOverlays.clear();
228
+ this._pendingDeletedDrafts.clear();
229
+ this._contextMenuCleanup?.();
230
+ this._contextMenuCleanup = null;
231
+ this._tooltipOverlayView?.setMap?.(null);
232
+ this._tooltipOverlayView = null;
233
+ this._supportsAdvancedMarkers = false;
234
+
235
+ this._map = null;
236
+ debug('[GoogleMapsAdapter] Map destroyed');
237
+ }
238
+
239
+ invalidateSize() {
240
+ if (!this._map) return;
241
+ google.maps.event.trigger(this._map, 'resize');
242
+ }
243
+
244
+ // ─── Viewport ──────────────────────────────────────────────────────────────
245
+
246
+ setCenter(lat, lng, zoom) {
247
+ if (!this._map) return;
248
+ this._map.setCenter({ lat, lng });
249
+ if (zoom !== undefined) this._map.setZoom(zoom);
250
+ }
251
+
252
+ flyTo(lat, lng, zoom, options = {}) {
253
+ if (!this._map) return;
254
+ // Google Maps has no built-in flyTo; use panTo + optional zoom change
255
+ this._map.panTo({ lat, lng });
256
+ if (zoom !== undefined) {
257
+ // Delay zoom slightly to allow pan animation to start
258
+ setTimeout(() => this._map?.setZoom(zoom), options.delay ?? 200);
259
+ }
260
+ }
261
+
262
+ fitBounds(bounds, options = {}) {
263
+ if (!this._map) return;
264
+ const gmBounds = this.#normalizeBounds(bounds);
265
+ if (!gmBounds) return;
266
+ const maxZoom = Number.isFinite(options.maxZoom) ? options.maxZoom : null;
267
+ const padding = options.paddingBottomRight ?? options.padding ?? null;
268
+
269
+ if (maxZoom !== null) {
270
+ google.maps.event.addListenerOnce(this._map, 'idle', () => {
271
+ const currentZoom = this._map?.getZoom?.();
272
+ if (Number.isFinite(currentZoom) && currentZoom > maxZoom) {
273
+ this._map?.setZoom?.(maxZoom);
274
+ }
275
+ });
276
+ }
277
+
278
+ if (padding) {
279
+ this._map.fitBounds(gmBounds, { right: padding[0] ?? 0, bottom: padding[1] ?? 0 });
280
+ } else {
281
+ this._map.fitBounds(gmBounds);
282
+ }
283
+ }
284
+
285
+ panTo(lat, lng) {
286
+ if (!this._map) return;
287
+ this._map.panTo({ lat, lng });
288
+ }
289
+
290
+ zoomIn() {
291
+ if (!this._map) return;
292
+ this._map.setZoom(this._map.getZoom() + 1);
293
+ }
294
+
295
+ zoomOut() {
296
+ if (!this._map) return;
297
+ this._map.setZoom(this._map.getZoom() - 1);
298
+ }
299
+
300
+ getZoom() {
301
+ return this._map?.getZoom() ?? 0;
302
+ }
303
+
304
+ getCenter() {
305
+ const c = this._map?.getCenter();
306
+ return c ? { lat: c.lat(), lng: c.lng() } : { lat: 0, lng: 0 };
307
+ }
308
+
309
+ getBounds() {
310
+ const b = this._map?.getBounds();
311
+ if (!b) {
312
+ return [
313
+ [0, 0],
314
+ [0, 0],
315
+ ];
316
+ }
317
+
318
+ return [
319
+ [b.getSouthWest().lat(), b.getSouthWest().lng()],
320
+ [b.getNorthEast().lat(), b.getNorthEast().lng()],
321
+ ];
322
+ }
323
+
324
+ // ─── Markers ───────────────────────────────────────────────────────────────
325
+
326
+ async addMarker(id, lat, lng, options = {}) {
327
+ if (!this._map) return null;
328
+
329
+ if (!this._supportsAdvancedMarkers) {
330
+ const classicMarker = this.#createClassicMarker(lat, lng, options);
331
+ if (!classicMarker) return null;
332
+
333
+ this._markers.set(id, classicMarker);
334
+ return classicMarker;
335
+ }
336
+
337
+ const { AdvancedMarkerElement } = await google.maps.importLibrary('marker');
338
+
339
+ // Build the content element — either a custom HTML element or an img icon
340
+ let content = options.content ?? null;
341
+ if (!content && options.iconUrl) {
342
+ const img = document.createElement('img');
343
+ img.src = options.iconUrl;
344
+ const size = options.iconSize ?? [24, 24];
345
+ img.style.width = `${size[0]}px`;
346
+ img.style.height = `${size[1]}px`;
347
+ img.style.display = 'block';
348
+ img.alt = options.alt ?? options.title ?? '';
349
+ // Wrapper div allows rotation transforms without affecting the anchor
350
+ const wrapper = document.createElement('div');
351
+ wrapper.className = 'fleetops-map-marker';
352
+ wrapper.style.cssText = 'transform-origin: center center; display: block;';
353
+ wrapper.appendChild(img);
354
+ content = wrapper;
355
+ }
356
+
357
+ if (content && options.title) {
358
+ content.setAttribute?.('title', options.title);
359
+ }
360
+
361
+ const marker = new AdvancedMarkerElement({
362
+ map: this._map,
363
+ position: { lat, lng },
364
+ content,
365
+ title: options.title ?? '',
366
+ zIndex: options.zIndexOffset ?? 0,
367
+ });
368
+
369
+ // Apply initial rotation
370
+ if (options.rotationAngle && content) {
371
+ content.style.transform = `rotate(${options.rotationAngle}deg)`;
372
+ }
373
+
374
+ // Click handler
375
+ if (typeof options.onClick === 'function') {
376
+ marker.addListener('click', options.onClick);
377
+ }
378
+
379
+ if (typeof options.onRightClick === 'function') {
380
+ marker.addListener('rightclick', (event) => options.onRightClick(this.#normalizeMapEvent('rightclick', event)));
381
+ }
382
+
383
+ if (options.tooltip && content) {
384
+ marker.__tooltipCleanup = this.#attachMarkerTooltip(content, options.tooltip, options.tooltipOptions ?? {});
385
+ }
386
+
387
+ this._markers.set(id, marker);
388
+ return marker;
389
+ }
390
+
391
+ updateMarkerPosition(id, lat, lng, animated = true, duration = 500) {
392
+ const marker = this._markers.get(id);
393
+ if (!marker) return;
394
+
395
+ const nextPosition = this.#normalizeMarkerPosition(lat, lng);
396
+ if (!nextPosition) return;
397
+
398
+ // Cancel any running animation for this marker
399
+ const cancelPrev = this._animations.get(id);
400
+ if (cancelPrev) {
401
+ cancelPrev();
402
+ this._animations.delete(id);
403
+ }
404
+
405
+ if (!animated || duration <= 0) {
406
+ this.#setMarkerPosition(marker, nextPosition);
407
+ return;
408
+ }
409
+
410
+ // Smooth animation via requestAnimationFrame interpolation
411
+ const startPos = this.#extractMarkerPosition(marker);
412
+ if (!startPos) {
413
+ this.#setMarkerPosition(marker, nextPosition);
414
+ return;
415
+ }
416
+
417
+ const startLat = startPos.lat;
418
+ const startLng = startPos.lng;
419
+ const startTime = performance.now();
420
+ let rafId = null;
421
+ let cancelled = false;
422
+
423
+ const animate = (currentTime) => {
424
+ if (cancelled) return;
425
+ const elapsed = currentTime - startTime;
426
+ const progress = Math.min(elapsed / duration, 1);
427
+ // Ease-out cubic for natural deceleration
428
+ const eased = 1 - Math.pow(1 - progress, 3);
429
+ const currentLat = startLat + (lat - startLat) * eased;
430
+ const currentLng = startLng + (lng - startLng) * eased;
431
+ this.#setMarkerPosition(marker, { lat: currentLat, lng: currentLng });
432
+ if (progress < 1) {
433
+ rafId = requestAnimationFrame(animate);
434
+ } else {
435
+ this._animations.delete(id);
436
+ }
437
+ };
438
+
439
+ rafId = requestAnimationFrame(animate);
440
+ this._animations.set(id, () => {
441
+ cancelled = true;
442
+ if (rafId) cancelAnimationFrame(rafId);
443
+ });
444
+ }
445
+
446
+ setMarkerRotation(id, degrees) {
447
+ const marker = this._markers.get(id);
448
+ if (!marker?.content) return;
449
+ // Apply rotation to the content element
450
+ marker.content.style.transform = `rotate(${degrees}deg)`;
451
+ marker.content.style.transformOrigin = 'center center';
452
+ }
453
+
454
+ removeMarker(id) {
455
+ const marker = this._markers.get(id);
456
+ if (marker) {
457
+ // Cancel animation
458
+ const cancel = this._animations.get(id);
459
+ if (cancel) {
460
+ cancel();
461
+ this._animations.delete(id);
462
+ }
463
+ marker.__tooltipCleanup?.();
464
+ if (typeof marker.setMap === 'function') {
465
+ marker.setMap(null);
466
+ } else {
467
+ marker.map = null;
468
+ }
469
+ this._markers.delete(id);
470
+ }
471
+ }
472
+
473
+ // ─── Overlays ──────────────────────────────────────────────────────────────
474
+
475
+ addPolygon(id, coordinates, options = {}) {
476
+ if (!this._map) return null;
477
+ const paths = this.#normalizePolygonPaths(coordinates);
478
+ if (!paths) return null;
479
+
480
+ const polygon = new google.maps.Polygon({
481
+ paths,
482
+ map: this._map,
483
+ strokeColor: options.color ?? '#3388ff',
484
+ strokeWeight: options.weight ?? 3,
485
+ strokeOpacity: 1,
486
+ fillColor: options.fillColor ?? options.color ?? '#3388ff',
487
+ fillOpacity: options.fillOpacity ?? 0.2,
488
+ editable: options.editable ?? false,
489
+ clickable: options.clickable ?? true,
490
+ zIndex: options.zIndex ?? 1,
491
+ });
492
+
493
+ if (typeof options.onRightClick === 'function') {
494
+ polygon.addListener('rightclick', (event) => {
495
+ this._selectedOverlay = polygon;
496
+ options.onRightClick(this.#normalizeMapEvent('rightclick', event));
497
+ });
498
+ }
499
+
500
+ polygon.addListener('click', () => {
501
+ this._selectedOverlay = polygon;
502
+ });
503
+
504
+ if (options.tooltip) {
505
+ polygon.__labelText = options.tooltip;
506
+ polygon.__labelPaths = paths;
507
+ polygon.__labelMarker = this.#createOverlayLabel(paths, options.tooltip);
508
+ polygon.__tooltipCleanup = this.#attachOverlayLabelHover(polygon, polygon.__labelMarker);
509
+ }
510
+
511
+ this._overlays.set(id, polygon);
512
+ return polygon;
513
+ }
514
+
515
+ addPolyline(id, coordinates, options = {}) {
516
+ if (!this._map) return null;
517
+ const path = this.#normalizeLinePath(coordinates);
518
+ if (!path) return null;
519
+
520
+ const polyline = new google.maps.Polyline({
521
+ path,
522
+ map: this._map,
523
+ strokeColor: options.color ?? '#3388ff',
524
+ strokeWeight: options.weight ?? 3,
525
+ strokeOpacity: options.opacity ?? 1,
526
+ });
527
+
528
+ this._overlays.set(id, polyline);
529
+ return polyline;
530
+ }
531
+
532
+ addCircle(id, lat, lng, radiusMeters, options = {}) {
533
+ if (!this._map) return null;
534
+ const circle = new google.maps.Circle({
535
+ center: { lat, lng },
536
+ radius: radiusMeters,
537
+ map: this._map,
538
+ strokeColor: options.color ?? '#3388ff',
539
+ strokeWeight: options.weight ?? 3,
540
+ strokeOpacity: 1,
541
+ fillColor: options.fillColor ?? options.color ?? '#3388ff',
542
+ fillOpacity: options.fillOpacity ?? 0.2,
543
+ editable: options.editable ?? false,
544
+ clickable: options.clickable ?? true,
545
+ });
546
+
547
+ this._overlays.set(id, circle);
548
+ return circle;
549
+ }
550
+
551
+ removeOverlay(id) {
552
+ const overlay = this._overlays.get(id);
553
+ if (overlay) {
554
+ overlay.__tooltipCleanup?.();
555
+ overlay.setMap(null);
556
+ if (overlay.__labelMarker) {
557
+ this.#setOverlayLabelMap(overlay.__labelMarker, null);
558
+ }
559
+ this._overlays.delete(id);
560
+ }
561
+ }
562
+
563
+ async addRoutingControl(route, options = {}) {
564
+ if (!this._map || !route?.waypoints?.length) return null;
565
+
566
+ const handleId = options.id ?? `route:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 8)}`;
567
+ const routeStyles = options.polylineOptions?.styles ?? null;
568
+ const polylineIds = [];
569
+
570
+ if (route.coordinates?.length) {
571
+ const polylineLayers = routeStyles?.length
572
+ ? routeStyles.map((style, index) => ({
573
+ id: `${handleId}:polyline:${index}`,
574
+ options: {
575
+ color: style.color,
576
+ weight: style.weight,
577
+ opacity: style.opacity,
578
+ },
579
+ }))
580
+ : [
581
+ {
582
+ id: `${handleId}:polyline:0`,
583
+ options: {
584
+ color: options.polylineOptions?.color ?? options.color ?? '#2563eb',
585
+ weight: options.polylineOptions?.weight ?? 4,
586
+ opacity: options.polylineOptions?.opacity ?? 0.85,
587
+ },
588
+ },
589
+ ];
590
+
591
+ polylineLayers.forEach(({ id, options: polylineOptions }) => {
592
+ const polyline = this.addPolyline(id, route.coordinates, polylineOptions);
593
+ if (polyline) {
594
+ polylineIds.push(id);
595
+ }
596
+ });
597
+ }
598
+ const markerIds = [];
599
+
600
+ const markerWaypoints = options.markerWaypoints ?? route.waypoints ?? [];
601
+
602
+ if (!options.suppressMarkers) {
603
+ for (const [index, waypoint] of markerWaypoints.entries()) {
604
+ const markerId = `${handleId}:marker:${index}`;
605
+ const markerOptions = this.#buildRouteMarkerOptions(waypoint, index, route, options);
606
+ if (markerOptions === null) continue;
607
+
608
+ await this.addMarker(markerId, waypoint[0], waypoint[1], markerOptions);
609
+ markerIds.push(markerId);
610
+ }
611
+ }
612
+
613
+ const handle = {
614
+ id: handleId,
615
+ engine: route.engine,
616
+ route,
617
+ markerIds,
618
+ polylineIds,
619
+ tag: options.tag ?? null,
620
+ raw: route.raw,
621
+ bounds: route.bounds,
622
+ };
623
+
624
+ this._routingControls.set(handleId, handle);
625
+ return handle;
626
+ }
627
+
628
+ removeRoutingControl(handle) {
629
+ const resolvedHandle = typeof handle === 'string' ? this._routingControls.get(handle) : handle;
630
+ if (!resolvedHandle) return false;
631
+
632
+ resolvedHandle.markerIds?.forEach((id) => this.removeMarker(id));
633
+ resolvedHandle.polylineIds?.forEach((id) => this.removeOverlay(id));
634
+ this._routingControls.delete(resolvedHandle.id);
635
+ return true;
636
+ }
637
+
638
+ positionWaypoints(waypointsOrBounds, options = {}) {
639
+ if (!this._map) return null;
640
+
641
+ const isBounds = options.isBounds === true;
642
+ const bounds = isBounds ? waypointsOrBounds : null;
643
+ const waypoints = isBounds ? null : waypointsOrBounds;
644
+ const paddingBottomRight = options.paddingBottomRight ?? [300, 0];
645
+ const singlePointZoom = options.singlePointZoom ?? 18;
646
+ const maxZoom = options.maxZoom ?? (isArray(waypoints) && waypoints.length === 2 ? 15 : 14);
647
+ const panBy = options.panBy ?? [0, 0];
648
+
649
+ if (isArray(waypoints) && waypoints.length === 1) {
650
+ this.flyTo(waypoints[0][0], waypoints[0][1], singlePointZoom, { animate: true });
651
+ google.maps.event.addListenerOnce(this._map, 'idle', () => this.panBy(panBy[0], panBy[1]));
652
+ return true;
653
+ }
654
+
655
+ if (bounds || (isArray(waypoints) && waypoints.length > 1)) {
656
+ this.fitBounds(bounds ?? waypoints, {
657
+ paddingBottomRight,
658
+ maxZoom,
659
+ animate: true,
660
+ });
661
+ google.maps.event.addListenerOnce(this._map, 'idle', () => this.panBy(panBy[0], panBy[1]));
662
+ return true;
663
+ }
664
+
665
+ return null;
666
+ }
667
+
668
+ #getConfig() {
669
+ try {
670
+ const owner = getOwner(this);
671
+ const config = owner?.resolveRegistration('config:environment');
672
+ return config?.['@fleetbase/fleetops-engine'] ?? config ?? null;
673
+ } catch {
674
+ return null;
675
+ }
676
+ }
677
+
678
+ removeLayer(layer) {
679
+ if (!layer) return;
680
+
681
+ layer.__tooltipCleanup?.();
682
+
683
+ if (typeof layer.setMap === 'function') {
684
+ layer.setMap(null);
685
+ } else if ('map' in layer) {
686
+ layer.map = null;
687
+ }
688
+
689
+ this._draftOverlays.delete(layer);
690
+ this._pendingDeletedDrafts.delete(layer);
691
+ if (this._selectedOverlay === layer) {
692
+ this._selectedOverlay = null;
693
+ }
694
+ }
695
+
696
+ // ─── Layer Visibility ──────────────────────────────────────────────────────
697
+
698
+ showLayer(layer) {
699
+ if (!layer) return;
700
+ if (typeof layer.setMap === 'function') {
701
+ layer.setMap(this._map);
702
+ } else if (layer.map === null) {
703
+ layer.map = this._map;
704
+ }
705
+ this.#syncOverlayPresentation(layer);
706
+ if (layer.__labelMarker) {
707
+ this.#setOverlayLabelMap(layer.__labelMarker, this._map);
708
+ }
709
+ layer.__hidden = false;
710
+ }
711
+
712
+ hideLayer(layer) {
713
+ if (!layer) return;
714
+ if (typeof layer.setMap === 'function') {
715
+ layer.setMap(null);
716
+ } else {
717
+ layer.map = null;
718
+ }
719
+ if (layer.__labelMarker) {
720
+ this.#setOverlayLabelMap(layer.__labelMarker, null);
721
+ }
722
+ layer.__tooltipCleanup?.hide?.();
723
+ layer.__hidden = true;
724
+ }
725
+
726
+ isLayerHidden(layer) {
727
+ if (!layer) return true;
728
+ return layer.__hidden === true;
729
+ }
730
+
731
+ isLayerVisible(layer) {
732
+ return !this.isLayerHidden(layer);
733
+ }
734
+
735
+ // ─── Drawing Tools ─────────────────────────────────────────────────────────
736
+
737
+ enableDrawingMode(type, options = {}) {
738
+ if (!this._drawingManager) {
739
+ debug('[GoogleMapsAdapter] DrawingManager not initialized');
740
+ return;
741
+ }
742
+
743
+ const modeMap = {
744
+ polygon: google.maps.drawing.OverlayType.POLYGON,
745
+ circle: google.maps.drawing.OverlayType.CIRCLE,
746
+ rectangle: google.maps.drawing.OverlayType.RECTANGLE,
747
+ polyline: google.maps.drawing.OverlayType.POLYLINE,
748
+ marker: google.maps.drawing.OverlayType.MARKER,
749
+ };
750
+
751
+ this._drawingManager.setDrawingMode(modeMap[type] ?? null);
752
+ this._drawingManager.setMap(this._map);
753
+ this._drawingOnCreate = typeof options.onCreate === 'function' ? options.onCreate : null;
754
+ }
755
+
756
+ disableDrawingMode() {
757
+ this._drawingManager?.setDrawingMode(null);
758
+ this._drawingOnCreate = null;
759
+ // Fire normalized event for listeners
760
+ this.#fireNormalizedEvent('draw:drawstop', {});
761
+ }
762
+
763
+ showDrawControl(config = {}) {
764
+ this._drawControlConfig = config;
765
+ this.#ensureDrawToolbar();
766
+
767
+ if (this._drawControlEl) {
768
+ this._drawControlEl.style.display = '';
769
+ }
770
+
771
+ this.#applyDrawToolbarConfig(config);
772
+
773
+ if (config?.defaultMode) {
774
+ this.enableDrawingMode(config.defaultMode);
775
+ }
776
+ }
777
+
778
+ hideDrawControl() {
779
+ if (this._drawControlEl) {
780
+ this._drawControlEl.style.display = 'none';
781
+ }
782
+ this._drawActionEl?.remove();
783
+ this._drawActionEl = null;
784
+ this.#restorePendingDeletedDrafts();
785
+ this.disableDrawingMode();
786
+ }
787
+
788
+ // ─── Popups / Info Windows ─────────────────────────────────────────────────
789
+
790
+ openPopup(id, lat, lng, htmlContent) {
791
+ if (!this._map) return null;
792
+ const infoWindow = new google.maps.InfoWindow({
793
+ content: typeof htmlContent === 'string' ? htmlContent : htmlContent,
794
+ position: { lat, lng },
795
+ });
796
+ infoWindow.open({ map: this._map });
797
+ this._popups.set(id, infoWindow);
798
+ return infoWindow;
799
+ }
800
+
801
+ closePopup(id) {
802
+ const popup = this._popups.get(id);
803
+ if (popup) {
804
+ popup.close();
805
+ this._popups.delete(id);
806
+ }
807
+ }
808
+
809
+ // ─── Context Menus ─────────────────────────────────────────────────────────
810
+
811
+ registerContextMenu(target, items) {
812
+ this._contextMenus.set(target, items);
813
+ }
814
+
815
+ removeContextMenu(target) {
816
+ this._contextMenus.delete(target);
817
+ const el = this._contextMenuEls.get(target);
818
+ if (el) {
819
+ el.remove();
820
+ this._contextMenuEls.delete(target);
821
+ }
822
+ }
823
+
824
+ getContextMenuItems(target) {
825
+ return this._contextMenus.get(target) ?? [];
826
+ }
827
+
828
+ showContextMenu(event, items = []) {
829
+ const latlng = event?.latlng;
830
+ if (!latlng || !items.length) return;
831
+ this.closeContextMenu();
832
+ this.#renderContextMenu(event?.originalEvent, latlng, items);
833
+ }
834
+
835
+ closeContextMenu() {
836
+ this._contextMenuCleanup?.();
837
+ this._contextMenuCleanup = null;
838
+ }
839
+
840
+ // ─── Events ────────────────────────────────────────────────────────────────
841
+
842
+ on(event, handler) {
843
+ if (!this._map) return;
844
+ const gmEvent = this.#normalizeEvent(event);
845
+
846
+ if (gmEvent.startsWith('draw:')) {
847
+ if (!this._eventListeners.has(event)) {
848
+ this._eventListeners.set(event, []);
849
+ }
850
+
851
+ this._eventListeners.get(event).push({ handler });
852
+ } else {
853
+ const listener = this._map.addListener(gmEvent, (e) => {
854
+ handler(this.#normalizeMapEvent(event, e));
855
+ });
856
+ if (!this._eventListeners.has(event)) {
857
+ this._eventListeners.set(event, []);
858
+ }
859
+ this._eventListeners.get(event).push({ handler, listener });
860
+ }
861
+ }
862
+
863
+ off(event, handler) {
864
+ const listeners = this._eventListeners.get(event) ?? [];
865
+ const entry = listeners.find((e) => e.handler === handler);
866
+ if (entry) {
867
+ if (entry.listener) {
868
+ google.maps.event.removeListener(entry.listener);
869
+ }
870
+ this._eventListeners.set(
871
+ event,
872
+ listeners.filter((e) => e !== entry)
873
+ );
874
+ }
875
+ }
876
+
877
+ once(event, handler) {
878
+ if (!this._map) return;
879
+ if (event.startsWith('draw:')) {
880
+ const wrappedHandler = (payload) => {
881
+ this.off(event, wrappedHandler);
882
+ handler(payload);
883
+ };
884
+
885
+ this.on(event, wrappedHandler);
886
+ return wrappedHandler;
887
+ }
888
+
889
+ const gmEvent = this.#normalizeEvent(event);
890
+ const listener = google.maps.event.addListenerOnce(this._map, gmEvent, (e) => {
891
+ handler(this.#normalizeMapEvent(event, e));
892
+ });
893
+ return listener;
894
+ }
895
+
896
+ // ─── Utilities ─────────────────────────────────────────────────────────────
897
+
898
+ distanceBetween(lat1, lng1, lat2, lng2) {
899
+ return google.maps.geometry.spherical.computeDistanceBetween(new google.maps.LatLng(lat1, lng1), new google.maps.LatLng(lat2, lng2));
900
+ }
901
+
902
+ addGeoJson(id, geojson, options = {}) {
903
+ if (!this._map) return null;
904
+ const dataLayer = new google.maps.Data({ map: this._map });
905
+ dataLayer.addGeoJson(geojson);
906
+ if (options.style) {
907
+ dataLayer.setStyle(options.style);
908
+ }
909
+ this._geojsonLayers.set(id, dataLayer);
910
+ return dataLayer;
911
+ }
912
+
913
+ removeGeoJson(id) {
914
+ const layer = this._geojsonLayers.get(id);
915
+ if (layer) {
916
+ layer.setMap(null);
917
+ this._geojsonLayers.delete(id);
918
+ }
919
+ }
920
+
921
+ setTileLayer(url, options = {}) {
922
+ if (!this._map) return;
923
+ if (this._customTileLayer) {
924
+ this._map.overlayMapTypes.clear();
925
+ }
926
+ // Create a custom ImageMapType for the tile URL
927
+ this._customTileLayer = new google.maps.ImageMapType({
928
+ getTileUrl: (coord, zoom) => {
929
+ return url
930
+ .replace('{z}', zoom)
931
+ .replace('{x}', coord.x)
932
+ .replace('{y}', coord.y)
933
+ .replace('{s}', ['a', 'b', 'c'][Math.floor(Math.random() * 3)]);
934
+ },
935
+ tileSize: new google.maps.Size(256, 256),
936
+ name: options.name ?? 'Custom Tiles',
937
+ opacity: options.opacity ?? 1,
938
+ });
939
+ this._map.overlayMapTypes.insertAt(0, this._customTileLayer);
940
+ }
941
+
942
+ registerMarker(id, markerObject) {
943
+ return super.registerMarker(id, markerObject);
944
+ }
945
+
946
+ registerPolygon(id, polygonObject) {
947
+ return super.registerPolygon(id, polygonObject);
948
+ }
949
+
950
+ showCoordinates(event) {
951
+ const lat = event?.latlng?.lat ?? event?.latlng?.lat?.();
952
+ const lng = event?.latlng?.lng ?? event?.latlng?.lng?.();
953
+
954
+ return { lat, lng };
955
+ }
956
+
957
+ centerMap(event) {
958
+ const lat = event?.latlng?.lat ?? event?.latlng?.lat?.();
959
+ const lng = event?.latlng?.lng ?? event?.latlng?.lng?.();
960
+
961
+ if (Number.isFinite(lat) && Number.isFinite(lng)) {
962
+ this._map?.panTo({ lat, lng });
963
+ }
964
+ }
965
+
966
+ toggleDrawControl() {
967
+ this.#ensureDrawToolbar();
968
+ if (!this._drawControlEl) return;
969
+
970
+ const isHidden = this._drawControlEl.style.display === 'none' || this._drawControlEl.style.display === '';
971
+ if (!isHidden) {
972
+ this.hideDrawControl();
973
+ return;
974
+ }
975
+
976
+ this.showDrawControl(
977
+ this._drawControlConfig ?? {
978
+ tools: ['polygon', 'circle', 'rectangle'],
979
+ allowEdit: true,
980
+ allowDelete: true,
981
+ defaultMode: null,
982
+ }
983
+ );
984
+ }
985
+
986
+ editPolygon(layer, { focusBounds = null } = {}) {
987
+ if (!layer || typeof layer.setEditable !== 'function') {
988
+ return Promise.resolve({ type: 'unsupported' });
989
+ }
990
+
991
+ const originalState = this.#captureOverlayState(layer);
992
+ if (!originalState) {
993
+ return Promise.resolve({ type: 'unsupported' });
994
+ }
995
+
996
+ if (focusBounds) {
997
+ try {
998
+ this.fitBounds(focusBounds, { paddingBottomRight: [0, 0], maxZoom: 16, animate: true });
999
+ } catch {
1000
+ // noop
1001
+ }
1002
+ }
1003
+
1004
+ layer.setEditable(true);
1005
+ const cleanupActionBar = this.#showEditActionBar({
1006
+ onSave: () => {
1007
+ const geoJson = this.#geoJsonFromOverlay(layer);
1008
+ finalize({
1009
+ type: 'edited',
1010
+ layer,
1011
+ geoJson,
1012
+ toGeoJSON: () => geoJson,
1013
+ });
1014
+ },
1015
+ onCancel: () => {
1016
+ this.#restoreOverlayState(layer, originalState);
1017
+ finalize({ type: 'cancel' });
1018
+ },
1019
+ });
1020
+
1021
+ let settled = false;
1022
+ const finalize = (result) => {
1023
+ if (settled) return;
1024
+ settled = true;
1025
+ layer.setEditable(false);
1026
+ cleanupActionBar?.();
1027
+ resolve(result);
1028
+ };
1029
+
1030
+ let resolve;
1031
+ return new Promise((promiseResolve) => {
1032
+ resolve = promiseResolve;
1033
+ });
1034
+ }
1035
+
1036
+ panBy(x, y = 0) {
1037
+ this._map?.panBy?.(x, y);
1038
+ }
1039
+
1040
+ #buildRouteMarkerOptions(waypoint, index, route, options) {
1041
+ if (typeof options.createMarker === 'function') {
1042
+ const customOptions = options.createMarker(waypoint, index, route);
1043
+ if (customOptions === null || customOptions === false) {
1044
+ return null;
1045
+ }
1046
+
1047
+ if (customOptions && typeof customOptions === 'object') {
1048
+ if (customOptions.waypointLabel) {
1049
+ return {
1050
+ ...customOptions,
1051
+ content: buildWaypointMarkerContent(customOptions.waypointLabel, customOptions.waypointColor ?? '#2563eb'),
1052
+ iconSize: customOptions.iconSize ?? [32, 32],
1053
+ iconAnchor: customOptions.iconAnchor ?? [16, 16],
1054
+ };
1055
+ }
1056
+
1057
+ return customOptions;
1058
+ }
1059
+ }
1060
+
1061
+ return {
1062
+ iconUrl: '/assets/images/marker-icon.png',
1063
+ iconSize: [25, 41],
1064
+ iconAnchor: [12, 41],
1065
+ draggable: false,
1066
+ ...options.markerOptions,
1067
+ };
1068
+ }
1069
+
1070
+ // ─── Private ───────────────────────────────────────────────────────────────
1071
+
1072
+ /**
1073
+ * Load the Google Maps JavaScript API dynamically.
1074
+ * Uses the configured API key from environment config.
1075
+ *
1076
+ * @param {Object} options
1077
+ * @returns {Promise<void>}
1078
+ */
1079
+ async #loadGoogleMapsApi(options = {}) {
1080
+ if (this._apiLoaded || (typeof google !== 'undefined' && google.maps)) {
1081
+ this._apiLoaded = true;
1082
+ return;
1083
+ }
1084
+
1085
+ const apiKey = options.apiKey ?? '';
1086
+
1087
+ if (!apiKey) {
1088
+ debug('[GoogleMapsAdapter] Warning: No Google Maps API key configured.');
1089
+ }
1090
+
1091
+ return new Promise((resolve, reject) => {
1092
+ if (typeof google !== 'undefined' && google.maps) {
1093
+ this._apiLoaded = true;
1094
+ resolve();
1095
+ return;
1096
+ }
1097
+
1098
+ const callbackName = `__fleetops_gmaps_cb_${Date.now()}`;
1099
+ window[callbackName] = () => {
1100
+ this._apiLoaded = true;
1101
+ delete window[callbackName];
1102
+ resolve();
1103
+ };
1104
+
1105
+ const script = document.createElement('script');
1106
+ script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=drawing,geometry,marker,routes&callback=${callbackName}&loading=async`;
1107
+ script.async = true;
1108
+ script.onerror = () => reject(new Error('[GoogleMapsAdapter] Failed to load Google Maps API'));
1109
+ document.head.appendChild(script);
1110
+ });
1111
+ }
1112
+
1113
+ /**
1114
+ * Initialize the DrawingManager (hidden by default).
1115
+ */
1116
+ async #initDrawingManager() {
1117
+ try {
1118
+ const googleMaps = window.google;
1119
+ const { DrawingManager } = await googleMaps.maps.importLibrary('drawing');
1120
+ this._drawingManager = new DrawingManager({
1121
+ drawingControl: false,
1122
+ drawingMode: null,
1123
+ polygonOptions: {
1124
+ strokeColor: '#3388ff',
1125
+ fillColor: '#3388ff',
1126
+ fillOpacity: 0.2,
1127
+ strokeWeight: 3,
1128
+ editable: true,
1129
+ clickable: true,
1130
+ },
1131
+ circleOptions: {
1132
+ strokeColor: '#3388ff',
1133
+ fillColor: '#3388ff',
1134
+ fillOpacity: 0.2,
1135
+ strokeWeight: 3,
1136
+ editable: true,
1137
+ clickable: true,
1138
+ },
1139
+ rectangleOptions: {
1140
+ strokeColor: '#3388ff',
1141
+ fillColor: '#3388ff',
1142
+ fillOpacity: 0.2,
1143
+ strokeWeight: 3,
1144
+ editable: true,
1145
+ clickable: true,
1146
+ },
1147
+ });
1148
+
1149
+ // Bridge DrawingManager events to normalized 'draw:created' event
1150
+ this._drawingManager.addListener('overlaycomplete', (e) => {
1151
+ const typeMap = {
1152
+ [googleMaps.maps.drawing.OverlayType.POLYGON]: 'polygon',
1153
+ [googleMaps.maps.drawing.OverlayType.CIRCLE]: 'circle',
1154
+ [googleMaps.maps.drawing.OverlayType.RECTANGLE]: 'rectangle',
1155
+ [googleMaps.maps.drawing.OverlayType.POLYLINE]: 'polyline',
1156
+ [googleMaps.maps.drawing.OverlayType.MARKER]: 'marker',
1157
+ };
1158
+
1159
+ const geoJson = this.#overlayToGeoJson(e.overlay, e.type);
1160
+ this._selectedOverlay = e.overlay;
1161
+ this._draftOverlays.add(e.overlay);
1162
+ e.overlay.__overlayType = typeMap[e.type] ?? e.type;
1163
+ this.#bindOverlaySelection(e.overlay);
1164
+
1165
+ const normalizedEvent = {
1166
+ layerType: typeMap[e.type] ?? e.type,
1167
+ layer: e.overlay,
1168
+ geoJson,
1169
+ toGeoJSON: () => geoJson,
1170
+ };
1171
+
1172
+ this._drawingOnCreate?.(normalizedEvent);
1173
+ this.#fireNormalizedEvent('draw:created', normalizedEvent);
1174
+ this._drawingManager.setDrawingMode(null);
1175
+ });
1176
+ } catch (e) {
1177
+ debug('[GoogleMapsAdapter] DrawingManager not available: ' + e.message);
1178
+ }
1179
+ }
1180
+
1181
+ #ensureDrawToolbar() {
1182
+ if (this._drawControlEl || !this._map) return;
1183
+
1184
+ const topOffset = this.#getToolbarTopOffset();
1185
+ const container = document.createElement('div');
1186
+ container.className = 'fleetops-google-draw';
1187
+ container.style.cssText = `
1188
+ position:absolute;
1189
+ top:${topOffset}px;
1190
+ right:16px;
1191
+ z-index:5;
1192
+ display:none;
1193
+ pointer-events:none;
1194
+ `;
1195
+
1196
+ const toolSection = document.createElement('div');
1197
+ toolSection.className = 'fleetops-google-draw-section';
1198
+ const toolGroup = document.createElement('div');
1199
+ toolGroup.className = 'fleetops-google-draw-toolbar fleetops-google-draw-toolbar-top fleetops-google-draw-bar';
1200
+
1201
+ const actionSection = document.createElement('div');
1202
+ actionSection.className = 'fleetops-google-draw-section';
1203
+ const actionGroup = document.createElement('div');
1204
+ actionGroup.className = 'fleetops-google-draw-toolbar fleetops-google-draw-bar';
1205
+
1206
+ toolSection.appendChild(toolGroup);
1207
+ actionSection.appendChild(actionGroup);
1208
+ container.appendChild(toolSection);
1209
+ container.appendChild(actionSection);
1210
+ this._drawControlEl = container;
1211
+ this._drawControlEl.__toolGroup = toolGroup;
1212
+ this._drawControlEl.__actionGroup = actionGroup;
1213
+ this._drawControlEl.__toolSection = toolSection;
1214
+ this._drawControlEl.__actionSection = actionSection;
1215
+ this._map.getDiv()?.appendChild(container);
1216
+ }
1217
+
1218
+ #applyDrawToolbarConfig(config = {}) {
1219
+ if (!this._drawControlEl) return;
1220
+
1221
+ this._drawControlEl.style.top = `${this.#getToolbarTopOffset()}px`;
1222
+ const toolGroup = this._drawControlEl.__toolGroup;
1223
+ const actionGroup = this._drawControlEl.__actionGroup;
1224
+ const actionSection = this._drawControlEl.__actionSection;
1225
+ toolGroup.innerHTML = '';
1226
+ actionGroup.innerHTML = '';
1227
+
1228
+ const tools = config.tools ?? ['polygon', 'circle', 'rectangle'];
1229
+ const toolButtons = [
1230
+ ['polygon', '▱'],
1231
+ ['rectangle', '▭'],
1232
+ ['circle', '◉'],
1233
+ ];
1234
+
1235
+ toolButtons
1236
+ .filter(([tool]) => tools.includes(tool))
1237
+ .forEach(([tool, icon]) => {
1238
+ toolGroup.appendChild(
1239
+ this.#createToolbarButton(icon, tool, `fleetops-google-draw-draw-${tool}`, () => {
1240
+ this.enableDrawingMode(tool);
1241
+ })
1242
+ );
1243
+ });
1244
+
1245
+ if (config.allowEdit) {
1246
+ actionGroup.appendChild(
1247
+ this.#createToolbarButton('✎', 'edit', 'fleetops-google-draw-edit-edit', () => {
1248
+ this.#editSelectedOverlay();
1249
+ })
1250
+ );
1251
+ }
1252
+
1253
+ if (config.allowDelete) {
1254
+ actionGroup.appendChild(
1255
+ this.#createToolbarButton('🗑', 'delete', 'fleetops-google-draw-edit-remove', () => {
1256
+ this.#deleteSelectedOverlay();
1257
+ })
1258
+ );
1259
+ }
1260
+
1261
+ actionSection.style.display = config.allowEdit || config.allowDelete ? '' : 'none';
1262
+ }
1263
+
1264
+ #createToolbarButton(icon, label, className, onClick) {
1265
+ const button = document.createElement('a');
1266
+ button.href = '#';
1267
+ button.className = className;
1268
+ button.setAttribute('aria-label', label);
1269
+ button.title = label;
1270
+ const srOnly = document.createElement('span');
1271
+ srOnly.className = 'sr-only';
1272
+ srOnly.textContent = label;
1273
+ button.appendChild(srOnly);
1274
+ button.addEventListener('click', (event) => {
1275
+ event.preventDefault();
1276
+ event.stopPropagation();
1277
+ onClick?.();
1278
+ });
1279
+ return button;
1280
+ }
1281
+
1282
+ #showEditActionBar({ onSave, onCancel, onClearAll = null }) {
1283
+ this._drawActionEl?.remove();
1284
+
1285
+ const topOffset = this.#getToolbarTopOffset() + 90;
1286
+ const actionBar = document.createElement('ul');
1287
+ actionBar.className = 'fleetops-google-draw-actions fleetops-google-draw-actions-top';
1288
+ actionBar.style.cssText = `
1289
+ position:absolute;
1290
+ top:${topOffset}px;
1291
+ right:78px;
1292
+ z-index:6;
1293
+ display:flex;
1294
+ `;
1295
+
1296
+ const save = this.#createActionButton('Save', onSave);
1297
+ const cancel = this.#createActionButton('Cancel', onCancel);
1298
+ const clearAll = typeof onClearAll === 'function' ? this.#createActionButton('Clear All', onClearAll) : null;
1299
+ actionBar.appendChild(save);
1300
+ actionBar.appendChild(cancel);
1301
+ if (clearAll) {
1302
+ actionBar.appendChild(clearAll);
1303
+ }
1304
+
1305
+ this._drawActionEl = actionBar;
1306
+ this._map?.getDiv()?.appendChild(actionBar);
1307
+
1308
+ return () => {
1309
+ actionBar.remove();
1310
+ if (this._drawActionEl === actionBar) {
1311
+ this._drawActionEl = null;
1312
+ }
1313
+ };
1314
+ }
1315
+
1316
+ #createActionButton(label, onClick) {
1317
+ const item = document.createElement('li');
1318
+ const button = document.createElement('a');
1319
+ button.href = '#';
1320
+ button.textContent = label;
1321
+ button.className = 'fleetops-google-draw-action-link';
1322
+ button.addEventListener('click', (event) => {
1323
+ event.preventDefault();
1324
+ event.stopPropagation();
1325
+ onClick?.();
1326
+ });
1327
+ item.appendChild(button);
1328
+ return item;
1329
+ }
1330
+
1331
+ #editSelectedOverlay() {
1332
+ const layer = this._selectedOverlay;
1333
+ if (!layer || !this._draftOverlays.has(layer) || typeof layer.setEditable !== 'function') return;
1334
+
1335
+ const originalState = this.#captureOverlayState(layer);
1336
+ if (!originalState) return;
1337
+
1338
+ layer.setEditable(true);
1339
+
1340
+ const cleanupActionBar = this.#showEditActionBar({
1341
+ onSave: () => {
1342
+ const geoJson = this.#geoJsonFromOverlay(layer);
1343
+ layer.setEditable(false);
1344
+ cleanupActionBar?.();
1345
+ this.#fireNormalizedEvent('draw:edited', {
1346
+ type: 'draw:edited',
1347
+ layer,
1348
+ layerType: layer.__overlayType ?? 'polygon',
1349
+ geoJson,
1350
+ toGeoJSON: () => geoJson,
1351
+ });
1352
+ },
1353
+ onCancel: () => {
1354
+ this.#restoreOverlayState(layer, originalState);
1355
+ layer.setEditable(false);
1356
+ cleanupActionBar?.();
1357
+ },
1358
+ });
1359
+ }
1360
+
1361
+ #deleteSelectedOverlay() {
1362
+ const layer = this._selectedOverlay;
1363
+ if (!layer || !this._draftOverlays.has(layer)) return;
1364
+
1365
+ this._pendingDeletedDrafts.add(layer);
1366
+ this.hideLayer(layer);
1367
+
1368
+ const cleanupActionBar = this.#showEditActionBar({
1369
+ onSave: () => {
1370
+ const deletedLayers = Array.from(this._pendingDeletedDrafts);
1371
+
1372
+ deletedLayers.forEach((draftLayer) => {
1373
+ const geoJson = this.#geoJsonFromOverlay(draftLayer);
1374
+ this._draftOverlays.delete(draftLayer);
1375
+ this._pendingDeletedDrafts.delete(draftLayer);
1376
+ this.#fireNormalizedEvent('draw:deleted', {
1377
+ type: 'draw:deleted',
1378
+ layer: draftLayer,
1379
+ layerType: draftLayer.__overlayType ?? 'polygon',
1380
+ geoJson,
1381
+ toGeoJSON: () => geoJson,
1382
+ });
1383
+ });
1384
+
1385
+ this._selectedOverlay = null;
1386
+ cleanupActionBar?.();
1387
+ },
1388
+ onCancel: () => {
1389
+ this.#restorePendingDeletedDrafts();
1390
+ cleanupActionBar?.();
1391
+ },
1392
+ onClearAll: () => {
1393
+ this._draftOverlays.forEach((draftLayer) => {
1394
+ this._pendingDeletedDrafts.add(draftLayer);
1395
+ this.hideLayer(draftLayer);
1396
+ });
1397
+ },
1398
+ });
1399
+ }
1400
+
1401
+ #bindOverlaySelection(layer) {
1402
+ if (!layer || typeof layer.addListener !== 'function') return;
1403
+ layer.addListener('click', () => {
1404
+ this._selectedOverlay = layer;
1405
+ });
1406
+ layer.addListener('rightclick', () => {
1407
+ this._selectedOverlay = layer;
1408
+ });
1409
+ }
1410
+
1411
+ #restorePendingDeletedDrafts() {
1412
+ this._pendingDeletedDrafts.forEach((layer) => {
1413
+ this.showLayer(layer);
1414
+ });
1415
+ this._pendingDeletedDrafts.clear();
1416
+ }
1417
+
1418
+ #attachMarkerTooltip(contentEl, tooltipContent, options = {}) {
1419
+ if (!contentEl || !tooltipContent) return null;
1420
+
1421
+ let tooltipEl = null;
1422
+
1423
+ const ensureTooltip = () => {
1424
+ if (tooltipEl) return tooltipEl;
1425
+ tooltipEl = document.createElement('div');
1426
+ tooltipEl.className = 'fleetops-google-hover-tooltip';
1427
+
1428
+ if (options.html) {
1429
+ tooltipEl.innerHTML = tooltipContent;
1430
+ } else {
1431
+ tooltipEl.textContent = tooltipContent;
1432
+ }
1433
+
1434
+ document.body.appendChild(tooltipEl);
1435
+ return tooltipEl;
1436
+ };
1437
+
1438
+ const updatePosition = (event) => {
1439
+ const el = ensureTooltip();
1440
+ el.style.left = `${(event?.clientX ?? 0) + 12}px`;
1441
+ el.style.top = `${(event?.clientY ?? 0) - 12}px`;
1442
+ };
1443
+
1444
+ const show = (event) => {
1445
+ const el = ensureTooltip();
1446
+ el.style.display = 'block';
1447
+ updatePosition(event);
1448
+ };
1449
+
1450
+ const hide = () => {
1451
+ if (tooltipEl) {
1452
+ tooltipEl.style.display = 'none';
1453
+ }
1454
+ };
1455
+
1456
+ contentEl.addEventListener('mouseenter', show);
1457
+ contentEl.addEventListener('mousemove', updatePosition);
1458
+ contentEl.addEventListener('mouseleave', hide);
1459
+
1460
+ return () => {
1461
+ contentEl.removeEventListener('mouseenter', show);
1462
+ contentEl.removeEventListener('mousemove', updatePosition);
1463
+ contentEl.removeEventListener('mouseleave', hide);
1464
+ tooltipEl?.remove();
1465
+ tooltipEl = null;
1466
+ };
1467
+ }
1468
+
1469
+ #createOverlayLabel(paths, text) {
1470
+ if (!this._map || !text) return null;
1471
+
1472
+ const centroid = this.#getPolygonLabelPosition(paths);
1473
+ if (!centroid) return null;
1474
+
1475
+ const content = document.createElement('div');
1476
+ content.className = 'fleetops-google-polygon-label';
1477
+ content.textContent = text;
1478
+
1479
+ return this.#createOverlayLabelDomOverlay(centroid, content, text);
1480
+ }
1481
+
1482
+ #attachOverlayLabelHover(overlay, labelOverlay) {
1483
+ if (!overlay || !labelOverlay) return null;
1484
+
1485
+ const updateCursorPosition = (event) => {
1486
+ const domEvent = event?.domEvent;
1487
+ if (!domEvent) return;
1488
+
1489
+ const mapDiv = this._map?.getDiv?.();
1490
+ const mapRect = mapDiv?.getBoundingClientRect?.();
1491
+ if (!mapRect) return;
1492
+
1493
+ const cursorOffset = 5;
1494
+ const pointerXWithinMap = domEvent.clientX - mapRect.left;
1495
+ const pointerYWithinMap = domEvent.clientY - mapRect.top;
1496
+ const paneCenterX = mapRect.width / 2;
1497
+ const paneCenterY = mapRect.height / 2;
1498
+
1499
+ labelOverlay.__followCursor = true;
1500
+ labelOverlay.__cursorPoint = {
1501
+ x: pointerXWithinMap - paneCenterX + cursorOffset,
1502
+ y: pointerYWithinMap - paneCenterY + cursorOffset,
1503
+ };
1504
+ labelOverlay.draw?.();
1505
+ };
1506
+
1507
+ const show = (event) => {
1508
+ updateCursorPosition(event);
1509
+ };
1510
+
1511
+ const move = (event) => {
1512
+ updateCursorPosition(event);
1513
+ };
1514
+
1515
+ const hide = () => {
1516
+ labelOverlay.__followCursor = false;
1517
+ labelOverlay.__cursorPoint = null;
1518
+ labelOverlay.draw?.();
1519
+ };
1520
+
1521
+ const mouseOverListener = overlay.addListener('mouseover', show);
1522
+ const mouseMoveListener = overlay.addListener('mousemove', move);
1523
+ const mouseOutListener = overlay.addListener('mouseout', hide);
1524
+
1525
+ const cleanup = () => {
1526
+ google.maps.event.removeListener(mouseOverListener);
1527
+ google.maps.event.removeListener(mouseMoveListener);
1528
+ google.maps.event.removeListener(mouseOutListener);
1529
+ };
1530
+
1531
+ cleanup.hide = hide;
1532
+
1533
+ return cleanup;
1534
+ }
1535
+
1536
+ #createOverlayLabelDomOverlay(position, content, title) {
1537
+ const overlay = new google.maps.OverlayView();
1538
+
1539
+ overlay.position = position;
1540
+ overlay.content = content;
1541
+ overlay.title = title;
1542
+ overlay.__container = null;
1543
+ overlay.__followCursor = false;
1544
+ overlay.__cursorPoint = null;
1545
+
1546
+ overlay.onAdd = () => {
1547
+ const container = document.createElement('div');
1548
+ container.style.position = 'absolute';
1549
+ container.style.transform = 'translate(-50%, -50%)';
1550
+ container.style.pointerEvents = 'none';
1551
+ container.appendChild(overlay.content);
1552
+ overlay.__container = container;
1553
+ overlay.getPanes()?.overlayMouseTarget?.appendChild(container);
1554
+ };
1555
+
1556
+ overlay.draw = () => {
1557
+ const container = overlay.__container;
1558
+ if (!container) return;
1559
+
1560
+ if (overlay.__followCursor && overlay.__cursorPoint) {
1561
+ container.style.position = 'absolute';
1562
+ container.style.transform = 'translate(0, 0)';
1563
+ container.style.left = `${overlay.__cursorPoint.x}px`;
1564
+ container.style.top = `${overlay.__cursorPoint.y}px`;
1565
+ return;
1566
+ }
1567
+
1568
+ const projection = overlay.getProjection?.();
1569
+ if (!projection || !overlay.position) return;
1570
+
1571
+ const point = projection.fromLatLngToDivPixel(new google.maps.LatLng(overlay.position.lat, overlay.position.lng));
1572
+ if (!point) return;
1573
+
1574
+ container.style.position = 'absolute';
1575
+ container.style.transform = 'translate(-50%, -50%)';
1576
+ container.style.left = `${point.x}px`;
1577
+ container.style.top = `${point.y}px`;
1578
+ };
1579
+
1580
+ overlay.onRemove = () => {
1581
+ overlay.__container?.remove();
1582
+ overlay.__container = null;
1583
+ };
1584
+
1585
+ return overlay;
1586
+ }
1587
+
1588
+ #setOverlayLabelMap(label, map) {
1589
+ if (!label) return;
1590
+
1591
+ if (typeof label.setMap === 'function') {
1592
+ label.setMap(map);
1593
+ return;
1594
+ }
1595
+
1596
+ label.map = map;
1597
+ }
1598
+
1599
+ #syncOverlayPresentation(layer) {
1600
+ if (!layer) return;
1601
+
1602
+ if (typeof layer.setOptions === 'function') {
1603
+ layer.setOptions({
1604
+ strokeOpacity: 1,
1605
+ fillOpacity: layer.get?.('fillOpacity') ?? 0.2,
1606
+ visible: true,
1607
+ });
1608
+ }
1609
+
1610
+ if (layer.__labelText && !layer.__labelMarker) {
1611
+ const currentPaths = this.#extractPolygonPaths(layer) ?? layer.__labelPaths;
1612
+ layer.__labelPaths = currentPaths;
1613
+ layer.__labelMarker = this.#createOverlayLabel(currentPaths, layer.__labelText);
1614
+ }
1615
+
1616
+ if (layer.__labelMarker) {
1617
+ const labelPaths = this.#extractPolygonPaths(layer) ?? layer.__labelPaths;
1618
+ if (labelPaths) {
1619
+ layer.__labelPaths = labelPaths;
1620
+ const centroid = this.#getPolygonLabelPosition(labelPaths);
1621
+ if (centroid) {
1622
+ layer.__labelMarker.position = centroid;
1623
+ layer.__labelMarker.draw?.();
1624
+ }
1625
+ }
1626
+
1627
+ if (layer.__labelMarker.content) {
1628
+ layer.__labelMarker.content.textContent = layer.__labelText ?? layer.__labelMarker.title ?? '';
1629
+ }
1630
+
1631
+ layer.__labelMarker.title = layer.__labelText ?? layer.__labelMarker.title ?? '';
1632
+ }
1633
+ }
1634
+
1635
+ #extractPolygonPaths(layer) {
1636
+ if (!layer || typeof layer.getPath !== 'function') return null;
1637
+
1638
+ const path = layer.getPath()?.getArray?.() ?? [];
1639
+ const points = path
1640
+ .map((point) => ({
1641
+ lat: typeof point.lat === 'function' ? point.lat() : point.lat,
1642
+ lng: typeof point.lng === 'function' ? point.lng() : point.lng,
1643
+ }))
1644
+ .filter((point) => Number.isFinite(point.lat) && Number.isFinite(point.lng));
1645
+
1646
+ return points.length >= 3 ? points : null;
1647
+ }
1648
+
1649
+ #getPolygonLabelPosition(paths) {
1650
+ const firstRing = isArray(paths?.[0]) ? paths[0] : paths;
1651
+ if (!isArray(firstRing) || firstRing.length === 0) return null;
1652
+
1653
+ const totals = firstRing.reduce(
1654
+ (acc, point) => {
1655
+ acc.lat += point.lat;
1656
+ acc.lng += point.lng;
1657
+ acc.count += 1;
1658
+ return acc;
1659
+ },
1660
+ { lat: 0, lng: 0, count: 0 }
1661
+ );
1662
+
1663
+ if (totals.count === 0) return null;
1664
+ return {
1665
+ lat: totals.lat / totals.count,
1666
+ lng: totals.lng / totals.count,
1667
+ };
1668
+ }
1669
+
1670
+ #normalizeBounds(bounds) {
1671
+ if (!bounds) return null;
1672
+
1673
+ if (bounds instanceof google.maps.LatLngBounds) {
1674
+ return bounds;
1675
+ }
1676
+
1677
+ if (
1678
+ isArray(bounds) &&
1679
+ bounds.length === 2 &&
1680
+ isArray(bounds[0]) &&
1681
+ typeof bounds[0][0] === 'number' &&
1682
+ typeof bounds[0][1] === 'number' &&
1683
+ typeof bounds[1]?.[0] === 'number' &&
1684
+ typeof bounds[1]?.[1] === 'number'
1685
+ ) {
1686
+ const [[swLat, swLng], [neLat, neLng]] = bounds;
1687
+ return new google.maps.LatLngBounds(new google.maps.LatLng(swLat, swLng), new google.maps.LatLng(neLat, neLng));
1688
+ }
1689
+
1690
+ const flatPoints = [];
1691
+ this.#collectBoundPoints(bounds, flatPoints);
1692
+ if (flatPoints.length === 0) return null;
1693
+
1694
+ const normalizedBounds = new google.maps.LatLngBounds();
1695
+ flatPoints.forEach((point) => normalizedBounds.extend(point));
1696
+ return normalizedBounds;
1697
+ }
1698
+
1699
+ #collectBoundPoints(input, points) {
1700
+ if (!input) return;
1701
+
1702
+ if (isArray(input)) {
1703
+ if (input.length >= 2 && typeof input[0] === 'number' && typeof input[1] === 'number') {
1704
+ points.push({ lat: Number(input[0]), lng: Number(input[1]) });
1705
+ return;
1706
+ }
1707
+
1708
+ input.forEach((entry) => this.#collectBoundPoints(entry, points));
1709
+ return;
1710
+ }
1711
+
1712
+ if (typeof input.lat === 'number' && typeof input.lng === 'number') {
1713
+ points.push({ lat: input.lat, lng: input.lng });
1714
+ }
1715
+ }
1716
+
1717
+ #captureOverlayState(layer) {
1718
+ if (typeof layer.getPath === 'function') {
1719
+ return {
1720
+ type: 'polygon',
1721
+ paths: layer
1722
+ .getPath()
1723
+ .getArray()
1724
+ .map((point) => ({ lat: point.lat(), lng: point.lng() })),
1725
+ };
1726
+ }
1727
+
1728
+ if (typeof layer.getBounds === 'function') {
1729
+ const bounds = layer.getBounds();
1730
+ return {
1731
+ type: 'rectangle',
1732
+ bounds: {
1733
+ north: bounds.getNorthEast().lat(),
1734
+ east: bounds.getNorthEast().lng(),
1735
+ south: bounds.getSouthWest().lat(),
1736
+ west: bounds.getSouthWest().lng(),
1737
+ },
1738
+ };
1739
+ }
1740
+
1741
+ if (typeof layer.getCenter === 'function' && typeof layer.getRadius === 'function') {
1742
+ const center = layer.getCenter();
1743
+ return {
1744
+ type: 'circle',
1745
+ center: { lat: center.lat(), lng: center.lng() },
1746
+ radius: layer.getRadius(),
1747
+ };
1748
+ }
1749
+
1750
+ return null;
1751
+ }
1752
+
1753
+ #restoreOverlayState(layer, state) {
1754
+ if (!layer || !state) return;
1755
+
1756
+ if (state.type === 'polygon') {
1757
+ layer.setPath(state.paths);
1758
+ return;
1759
+ }
1760
+
1761
+ if (state.type === 'rectangle') {
1762
+ layer.setBounds(state.bounds);
1763
+ return;
1764
+ }
1765
+
1766
+ if (state.type === 'circle') {
1767
+ layer.setCenter(state.center);
1768
+ layer.setRadius(state.radius);
1769
+ }
1770
+ }
1771
+
1772
+ /**
1773
+ * Convert a Google Maps overlay to the same geometry contract used by the
1774
+ * pre-agnostic Leaflet flow wherever possible.
1775
+ *
1776
+ * @param {*} overlay
1777
+ * @param {string} type
1778
+ * @returns {Object|null}
1779
+ */
1780
+ #overlayToGeoJson(overlay, type) {
1781
+ if (type === google.maps.drawing.OverlayType.POLYGON) {
1782
+ const path = overlay.getPath().getArray();
1783
+ const coords = path.map((p) => [p.lng(), p.lat()]);
1784
+ coords.push(coords[0]); // Close the ring
1785
+ return new Polygon([coords]);
1786
+ }
1787
+
1788
+ if (type === google.maps.drawing.OverlayType.RECTANGLE) {
1789
+ const bounds = overlay.getBounds();
1790
+ const ne = bounds.getNorthEast();
1791
+ const sw = bounds.getSouthWest();
1792
+ const coords = [
1793
+ [sw.lng(), sw.lat()],
1794
+ [ne.lng(), sw.lat()],
1795
+ [ne.lng(), ne.lat()],
1796
+ [sw.lng(), ne.lat()],
1797
+ [sw.lng(), sw.lat()],
1798
+ ];
1799
+
1800
+ return new Polygon([coords]);
1801
+ }
1802
+
1803
+ if (type === google.maps.drawing.OverlayType.CIRCLE) {
1804
+ const center = overlay.getCenter();
1805
+ const radius = overlay.getRadius();
1806
+ return new Circle([center.lng(), center.lat()], radius);
1807
+ }
1808
+
1809
+ if (type === google.maps.drawing.OverlayType.POLYLINE) {
1810
+ const path = overlay.getPath().getArray();
1811
+ const coords = path.map((p) => [p.lng(), p.lat()]);
1812
+ return new Feature({
1813
+ type: 'Feature',
1814
+ geometry: { type: 'LineString', coordinates: coords },
1815
+ properties: {},
1816
+ });
1817
+ }
1818
+
1819
+ return null;
1820
+ }
1821
+
1822
+ #geoJsonFromOverlay(overlay) {
1823
+ if (!overlay) return null;
1824
+
1825
+ if (typeof overlay.getPath === 'function') {
1826
+ return this.#overlayToGeoJson(overlay, google.maps.drawing.OverlayType.POLYGON);
1827
+ }
1828
+
1829
+ if (typeof overlay.getBounds === 'function' && typeof overlay.getCenter !== 'function') {
1830
+ return this.#overlayToGeoJson(overlay, google.maps.drawing.OverlayType.RECTANGLE);
1831
+ }
1832
+
1833
+ if (typeof overlay.getCenter === 'function' && typeof overlay.getRadius === 'function') {
1834
+ return this.#overlayToGeoJson(overlay, google.maps.drawing.OverlayType.CIRCLE);
1835
+ }
1836
+
1837
+ return null;
1838
+ }
1839
+
1840
+ /**
1841
+ * Normalize cross-provider event names to Google Maps event names.
1842
+ *
1843
+ * @param {string} event
1844
+ * @returns {string}
1845
+ */
1846
+ #normalizeEvent(event) {
1847
+ const map = {
1848
+ click: 'click',
1849
+ dblclick: 'dblclick',
1850
+ rightclick: 'rightclick',
1851
+ moveend: 'idle',
1852
+ zoomend: 'zoom_changed',
1853
+ load: 'tilesloaded',
1854
+ contextmenu: 'rightclick',
1855
+ };
1856
+ return map[event] ?? event;
1857
+ }
1858
+
1859
+ /**
1860
+ * Map normalized draw event name to Google Maps DrawingManager event.
1861
+ *
1862
+ * @param {string} normalizedEvent
1863
+ * @returns {string}
1864
+ */
1865
+ #drawEvent(normalizedEvent) {
1866
+ const map = {
1867
+ 'draw:created': 'overlaycomplete',
1868
+ 'draw:edited': 'overlaycomplete',
1869
+ 'draw:deleted': 'overlaycomplete',
1870
+ 'draw:drawstop': 'drawingmode_changed',
1871
+ };
1872
+ return map[normalizedEvent] ?? normalizedEvent;
1873
+ }
1874
+
1875
+ /**
1876
+ * Normalize a Google Maps map event to a cross-provider event object.
1877
+ *
1878
+ * @param {string} event - Original event name
1879
+ * @param {*} gmEvent - Google Maps event object
1880
+ * @returns {Object}
1881
+ */
1882
+ #normalizeMapEvent(event, gmEvent) {
1883
+ if (!gmEvent) return { type: event };
1884
+ const latlng = gmEvent.latLng ? { lat: gmEvent.latLng.lat(), lng: gmEvent.latLng.lng() } : null;
1885
+ return {
1886
+ type: event,
1887
+ latlng,
1888
+ originalEvent: gmEvent.domEvent ?? gmEvent,
1889
+ _gmEvent: gmEvent,
1890
+ };
1891
+ }
1892
+
1893
+ /**
1894
+ * Normalize a Google Maps drawing event.
1895
+ *
1896
+ * @param {string} event
1897
+ * @param {*} gmEvent
1898
+ * @returns {Object}
1899
+ */
1900
+ #normalizeDrawEvent(event, gmEvent) {
1901
+ return {
1902
+ type: event,
1903
+ layer: gmEvent?.overlay ?? null,
1904
+ layerType: gmEvent?.type ?? null,
1905
+ _gmEvent: gmEvent,
1906
+ };
1907
+ }
1908
+
1909
+ /**
1910
+ * Fire a normalized event to all registered listeners.
1911
+ *
1912
+ * @param {string} event
1913
+ * @param {*} data
1914
+ */
1915
+ #fireNormalizedEvent(event, data) {
1916
+ const listeners = this._eventListeners.get(event) ?? [];
1917
+ listeners.forEach(({ handler }) => {
1918
+ try {
1919
+ handler(data);
1920
+ } catch (e) {
1921
+ debug(`[GoogleMapsAdapter] Error in event handler for "${event}": ${e.message}`);
1922
+ }
1923
+ });
1924
+ }
1925
+
1926
+ #getToolbarTopOffset() {
1927
+ const topbar = document.querySelector('#map-topbar-container');
1928
+ const topbarHeight = topbar?.getBoundingClientRect?.().height ?? 0;
1929
+ return Math.max(16, Math.round(topbarHeight + 12));
1930
+ }
1931
+
1932
+ /**
1933
+ * Render a DOM-based context menu at the given DOM coordinates.
1934
+ *
1935
+ * @param {MouseEvent} domEvent
1936
+ * @param {google.maps.LatLng} latLng
1937
+ * @param {Array} items
1938
+ */
1939
+ #renderContextMenu(domEvent, latLng, items) {
1940
+ const latitude = typeof latLng?.lat === 'function' ? latLng.lat() : latLng?.lat;
1941
+ const longitude = typeof latLng?.lng === 'function' ? latLng.lng() : latLng?.lng;
1942
+ if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return;
1943
+
1944
+ // Remove any existing context menu
1945
+ this.closeContextMenu();
1946
+
1947
+ const menu = document.createElement('div');
1948
+ menu.className = 'fleetops-google-contextmenu';
1949
+ menu.style.cssText = `
1950
+ position: fixed;
1951
+ z-index: 9999;
1952
+ left: ${domEvent?.clientX ?? 0}px;
1953
+ top: ${domEvent?.clientY ?? 0}px;
1954
+ `;
1955
+
1956
+ items.forEach((item) => {
1957
+ if (item.separator) {
1958
+ const sep = document.createElement('div');
1959
+ sep.className = 'fleetops-google-contextmenu-separator';
1960
+ menu.appendChild(sep);
1961
+ return;
1962
+ }
1963
+ const el = document.createElement('a');
1964
+ el.href = '#';
1965
+ el.className = 'fleetops-google-contextmenu-item';
1966
+ el.textContent = item.label;
1967
+ el.addEventListener('click', (event) => {
1968
+ event.preventDefault();
1969
+ event.stopPropagation();
1970
+ item.action({ latlng: { lat: latitude, lng: longitude }, domEvent });
1971
+ this.closeContextMenu();
1972
+ });
1973
+ menu.appendChild(el);
1974
+ });
1975
+
1976
+ document.body.appendChild(menu);
1977
+
1978
+ const close = (e) => {
1979
+ if (!menu.contains(e.target)) {
1980
+ menu.remove();
1981
+ document.removeEventListener('click', close);
1982
+ }
1983
+ };
1984
+ setTimeout(() => document.addEventListener('click', close), 0);
1985
+ this._contextMenuCleanup = () => {
1986
+ menu.remove();
1987
+ document.removeEventListener('click', close);
1988
+ };
1989
+ }
1990
+
1991
+ #normalizeLatLngPoint(point) {
1992
+ if (point && typeof point.lat === 'number' && typeof point.lng === 'number') {
1993
+ return { lat: point.lat, lng: point.lng };
1994
+ }
1995
+
1996
+ if (isArray(point) && point.length >= 2) {
1997
+ const lat = Number(point[0]);
1998
+ const lng = Number(point[1]);
1999
+
2000
+ if (Number.isFinite(lat) && Number.isFinite(lng)) {
2001
+ return { lat, lng };
2002
+ }
2003
+ }
2004
+
2005
+ return null;
2006
+ }
2007
+
2008
+ #normalizeLinePath(coordinates) {
2009
+ if (!isArray(coordinates)) return null;
2010
+
2011
+ const path = coordinates.map((point) => this.#normalizeLatLngPoint(point)).filter(Boolean);
2012
+ return path.length >= 2 ? path : null;
2013
+ }
2014
+
2015
+ #normalizePolygonPaths(coordinates) {
2016
+ if (!isArray(coordinates) || coordinates.length === 0) return null;
2017
+
2018
+ const firstPoint = coordinates[0];
2019
+ const hasNestedRings = isArray(firstPoint) && isArray(firstPoint[0]);
2020
+ const rings = hasNestedRings ? coordinates : [coordinates];
2021
+
2022
+ const normalizedRings = rings.map((ring) => ring.map((point) => this.#normalizeLatLngPoint(point)).filter(Boolean)).filter((ring) => ring.length >= 3);
2023
+
2024
+ if (normalizedRings.length === 0) {
2025
+ return null;
2026
+ }
2027
+
2028
+ return normalizedRings.length === 1 ? normalizedRings[0] : normalizedRings;
2029
+ }
2030
+
2031
+ #normalizeMarkerPosition(lat, lng) {
2032
+ const normalizedLat = Number(lat);
2033
+ const normalizedLng = Number(lng);
2034
+
2035
+ if (!Number.isFinite(normalizedLat) || !Number.isFinite(normalizedLng)) {
2036
+ return null;
2037
+ }
2038
+
2039
+ return { lat: normalizedLat, lng: normalizedLng };
2040
+ }
2041
+
2042
+ #extractMarkerPosition(marker) {
2043
+ const position = marker?.position;
2044
+ if (!position) {
2045
+ return null;
2046
+ }
2047
+
2048
+ if (typeof position.lat === 'function' && typeof position.lng === 'function') {
2049
+ const lat = position.lat();
2050
+ const lng = position.lng();
2051
+
2052
+ if (Number.isFinite(lat) && Number.isFinite(lng)) {
2053
+ return { lat, lng };
2054
+ }
2055
+ }
2056
+
2057
+ if (Number.isFinite(position.lat) && Number.isFinite(position.lng)) {
2058
+ return { lat: position.lat, lng: position.lng };
2059
+ }
2060
+
2061
+ return null;
2062
+ }
2063
+
2064
+ #setMarkerPosition(marker, position) {
2065
+ try {
2066
+ if (typeof marker.setPosition === 'function') {
2067
+ marker.setPosition(position);
2068
+ } else {
2069
+ marker.position = position;
2070
+ }
2071
+ } catch (error) {
2072
+ debug(`[GoogleMapsAdapter] Failed to set marker position: ${error.message}`);
2073
+ }
2074
+ }
2075
+
2076
+ #createClassicMarker(lat, lng, options = {}) {
2077
+ const position = { lat, lng };
2078
+ const markerOptions = {
2079
+ map: this._map,
2080
+ position,
2081
+ title: options.title ?? options.tooltip ?? '',
2082
+ zIndex: options.zIndexOffset ?? 0,
2083
+ };
2084
+
2085
+ if (options.waypointLabel) {
2086
+ markerOptions.icon = {
2087
+ url: buildWaypointMarkerSvg(options.waypointLabel, options.waypointColor ?? '#2563eb'),
2088
+ scaledSize: new google.maps.Size(34, 34),
2089
+ anchor: new google.maps.Point(17, 17),
2090
+ };
2091
+ } else if (options.iconUrl) {
2092
+ const size = options.iconSize ?? [24, 24];
2093
+ markerOptions.icon = {
2094
+ url: options.iconUrl,
2095
+ scaledSize: new google.maps.Size(size[0], size[1]),
2096
+ };
2097
+ }
2098
+
2099
+ const marker = new google.maps.Marker(markerOptions);
2100
+
2101
+ if (typeof options.onClick === 'function') {
2102
+ marker.addListener('click', options.onClick);
2103
+ }
2104
+
2105
+ if (typeof options.onRightClick === 'function') {
2106
+ marker.addListener('rightclick', (event) => options.onRightClick(this.#normalizeMapEvent('rightclick', event)));
2107
+ }
2108
+
2109
+ if (options.tooltip) {
2110
+ marker.__tooltipCleanup = this.#attachClassicMarkerTooltip(marker, options.tooltip, options.tooltipOptions ?? {});
2111
+ }
2112
+
2113
+ return marker;
2114
+ }
2115
+
2116
+ #attachClassicMarkerTooltip(marker, tooltipContent, options = {}) {
2117
+ if (!marker || !tooltipContent || !this._map) return null;
2118
+
2119
+ let tooltipEl = null;
2120
+
2121
+ const ensureTooltip = () => {
2122
+ if (tooltipEl) return tooltipEl;
2123
+
2124
+ tooltipEl = document.createElement('div');
2125
+ tooltipEl.className = options.className || 'fleetops-google-hover-tooltip';
2126
+
2127
+ tooltipEl.innerHTML = options.html ? tooltipContent : `<div class="fleetops-google-hover-tooltip__title">${tooltipContent}</div>`;
2128
+
2129
+ Object.assign(tooltipEl.style, {
2130
+ position: 'fixed',
2131
+ display: 'none',
2132
+ zIndex: options.zIndex || 9999,
2133
+ pointerEvents: 'none',
2134
+ });
2135
+
2136
+ document.body.appendChild(tooltipEl);
2137
+
2138
+ return tooltipEl;
2139
+ };
2140
+
2141
+ const positionTooltip = (event) => {
2142
+ const domEvent = event?.domEvent;
2143
+
2144
+ if (!domEvent) return false;
2145
+
2146
+ const el = ensureTooltip();
2147
+
2148
+ const offsetX = options.offsetX ?? 12;
2149
+ const offsetY = options.offsetY ?? 12;
2150
+
2151
+ el.style.left = `${domEvent.clientX + offsetX}px`;
2152
+ el.style.top = `${domEvent.clientY + offsetY}px`;
2153
+
2154
+ return true;
2155
+ };
2156
+
2157
+ const show = (event) => {
2158
+ const el = ensureTooltip();
2159
+ el.style.display = positionTooltip(event) ? 'block' : 'none';
2160
+ };
2161
+
2162
+ const move = (event) => {
2163
+ if (!tooltipEl) return;
2164
+
2165
+ tooltipEl.style.display = positionTooltip(event) ? 'block' : 'none';
2166
+ };
2167
+
2168
+ const hide = () => {
2169
+ if (tooltipEl) {
2170
+ tooltipEl.style.display = 'none';
2171
+ }
2172
+ };
2173
+
2174
+ const mouseOverListener = marker.addListener('mouseover', show);
2175
+ const mouseMoveListener = marker.addListener('mousemove', move);
2176
+ const mouseOutListener = marker.addListener('mouseout', hide);
2177
+
2178
+ return () => {
2179
+ google.maps.event.removeListener(mouseOverListener);
2180
+ google.maps.event.removeListener(mouseMoveListener);
2181
+ google.maps.event.removeListener(mouseOutListener);
2182
+
2183
+ tooltipEl?.remove();
2184
+ tooltipEl = null;
2185
+ };
2186
+ }
2187
+
2188
+ #ensureTooltipOverlayView() {
2189
+ if (this._tooltipOverlayView || !this._map) {
2190
+ return this._tooltipOverlayView;
2191
+ }
2192
+
2193
+ const overlayView = new google.maps.OverlayView();
2194
+
2195
+ overlayView.onAdd = () => {};
2196
+ overlayView.draw = () => {};
2197
+ overlayView.onRemove = () => {};
2198
+
2199
+ overlayView.setMap(this._map);
2200
+
2201
+ this._tooltipOverlayView = overlayView;
2202
+
2203
+ return overlayView;
2204
+ }
2205
+ }