@fleetbase/fleetops-engine 0.6.45 → 0.6.47

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 (148) 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/details.hbs +1 -2
  37. package/addon/components/order/form/details.js +6 -0
  38. package/addon/components/order/form/payload.hbs +33 -0
  39. package/addon/components/order/form/payload.js +1 -0
  40. package/addon/components/order/form/route.hbs +8 -8
  41. package/addon/components/order/form/route.js +86 -18
  42. package/addon/components/order/form.js +2 -2
  43. package/addon/components/order/route-editor.hbs +209 -147
  44. package/addon/components/order/route-editor.js +143 -102
  45. package/addon/components/place/address.hbs +1 -0
  46. package/addon/components/place/address.js +12 -0
  47. package/addon/components/route-list.hbs +43 -55
  48. package/addon/components/route-list.js +47 -0
  49. package/addon/components/route-optimization-wizard-panel.js +3 -4
  50. package/addon/components/vehicle/card.hbs +4 -4
  51. package/addon/controllers/maintenance/schedules/index.js +2 -1
  52. package/addon/controllers/operations/orders/index/details.js +110 -37
  53. package/addon/controllers/operations/orders/index/new.js +1 -1
  54. package/addon/controllers/operations/orders/index.js +8 -1
  55. package/addon/controllers/settings/map.js +97 -0
  56. package/addon/controllers/settings/payments/index.js +1 -1
  57. package/addon/controllers/settings/routing.js +47 -12
  58. package/addon/extension.js +11 -0
  59. package/addon/helpers/place-address-html.js +6 -0
  60. package/addon/instance-initializers/register-google-routes.js +17 -0
  61. package/addon/instance-initializers/register-osrm.js +5 -0
  62. package/addon/routes/application.js +6 -0
  63. package/addon/routes/operations/orchestrator.js +13 -0
  64. package/addon/routes/operations/orders/index/details.js +29 -22
  65. package/addon/routes/operations/orders/index/new.js +9 -0
  66. package/addon/routes/operations/orders/index.js +0 -1
  67. package/addon/routes/settings/map.js +3 -0
  68. package/addon/routes.js +1 -0
  69. package/addon/services/entity-actions.js +1 -1
  70. package/addon/services/geofence-event-bus.js +2 -1
  71. package/addon/services/geofence.js +204 -168
  72. package/addon/services/google-routes.js +300 -0
  73. package/addon/services/leaflet-draw-restriction.js +2 -1
  74. package/addon/services/leaflet-map-manager.js +17 -0
  75. package/addon/services/map-adapter/google.js +2205 -0
  76. package/addon/services/map-adapter/leaflet.js +1042 -0
  77. package/addon/services/map-adapter-interface.js +551 -0
  78. package/addon/services/map-manager.js +768 -0
  79. package/addon/services/map-settings.js +75 -0
  80. package/addon/services/movement-tracker.js +83 -32
  81. package/addon/services/order-actions.js +11 -1
  82. package/addon/services/osrm.js +79 -6
  83. package/addon/services/position-playback.js +135 -62
  84. package/addon/services/route-engine.js +83 -0
  85. package/addon/services/service-areas.js +8 -1
  86. package/addon/styles/fleetops-engine.css +555 -0
  87. package/addon/templates/management/vehicles/index.hbs +0 -1
  88. package/addon/templates/operations/orders/index/details.hbs +0 -1
  89. package/addon/templates/settings/map.hbs +53 -0
  90. package/addon/templates/settings/routing.hbs +15 -4
  91. package/addon/utils/fleet-ops-options.js +1 -0
  92. package/addon/utils/has-valid-coordinates.js +6 -0
  93. package/addon/utils/map-provider-options.js +6 -0
  94. package/addon/utils/order-route-editing.js +86 -0
  95. package/addon/utils/order-route-summary.js +20 -0
  96. package/addon/utils/place-address-html.js +41 -0
  97. package/addon/utils/route-colors.js +43 -12
  98. package/addon/utils/route-visualization.js +93 -0
  99. package/addon/utils/vendor-integration.js +2 -1
  100. package/app/components/admin/map-settings.js +1 -0
  101. package/app/components/admin/routing-settings.js +1 -0
  102. package/app/components/cell/order-route-type.js +1 -0
  103. package/app/components/cell/place-address.js +1 -0
  104. package/app/components/map/google-live-map.js +1 -0
  105. package/app/components/place/address.js +1 -0
  106. package/app/controllers/settings/map.js +1 -0
  107. package/app/helpers/place-address-html.js +1 -0
  108. package/app/routes/settings/map.js +1 -0
  109. package/app/services/map-adapter/google.js +1 -0
  110. package/app/services/map-adapter/leaflet.js +1 -0
  111. package/app/services/map-adapter-interface.js +1 -0
  112. package/app/services/map-manager.js +1 -0
  113. package/app/services/map-settings.js +1 -0
  114. package/app/templates/settings/map.js +1 -0
  115. package/app/utils/has-valid-coordinates.js +1 -0
  116. package/app/utils/order-route-editing.js +2 -0
  117. package/app/utils/order-route-summary.js +1 -0
  118. package/app/utils/place-address-html.js +1 -0
  119. package/app/utils/route-colors.js +1 -1
  120. package/app/utils/route-visualization.js +1 -0
  121. package/assets/images/drawing-controls-spritesheet.svg +156 -0
  122. package/composer.json +1 -1
  123. package/extension.json +1 -1
  124. package/package.json +4 -4
  125. package/pnpm-workspace.yaml +7 -0
  126. package/server/src/Auth/Schemas/FleetOps.php +5 -0
  127. package/server/src/Http/Controllers/Api/v1/OrderController.php +31 -3
  128. package/server/src/Http/Controllers/Api/v1/PayloadController.php +14 -1
  129. package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +2 -0
  130. package/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php +3 -2
  131. package/server/src/Http/Controllers/Api/v1/ZoneController.php +2 -0
  132. package/server/src/Http/Controllers/Internal/v1/LiveController.php +2 -1
  133. package/server/src/Http/Controllers/Internal/v1/OrchestrationController.php +7 -4
  134. package/server/src/Http/Controllers/Internal/v1/OrderController.php +90 -34
  135. package/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php +3 -2
  136. package/server/src/Http/Controllers/Internal/v1/SettingController.php +106 -3
  137. package/server/src/Http/Filter/OrderFilter.php +2 -1
  138. package/server/src/Http/Resources/v1/Index/Payload.php +9 -7
  139. package/server/src/Http/Resources/v1/Order.php +20 -1
  140. package/server/src/Models/Order.php +42 -1
  141. package/server/src/Models/OrderConfig.php +15 -0
  142. package/server/src/Models/Payload.php +131 -27
  143. package/server/src/Models/Place.php +182 -48
  144. package/server/src/Models/ServiceRate.php +4 -3
  145. package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +59 -23
  146. package/server/src/Support/Utils.php +36 -2
  147. package/server/src/routes.php +4 -0
  148. package/translations/en-us.yaml +1937 -1916
@@ -0,0 +1,1042 @@
1
+ /**
2
+ * LeafletAdapter
3
+ *
4
+ * Implements the MapAdapterInterface using the Leaflet library (via ember-leaflet).
5
+ * This adapter wraps all existing Leaflet-specific logic, preserving 100% of
6
+ * the original FleetOps map functionality while conforming to the provider-agnostic
7
+ * interface.
8
+ *
9
+ * All Leaflet-specific plugins are supported:
10
+ * - leaflet-rotatedmarker → setMarkerRotation()
11
+ * - leaflet.marker.slideto → updateMarkerPosition() with animation
12
+ * - leaflet-draw → enableDrawingMode() / showDrawControl()
13
+ * - leaflet-contextmenu → registerContextMenu()
14
+ *
15
+ * @module services/map-adapter/leaflet
16
+ */
17
+ import MapAdapterInterface from '../map-adapter-interface';
18
+ import { isArray } from '@ember/array';
19
+ import { debug } from '@ember/debug';
20
+ import { isNone } from '@ember/utils';
21
+ import { next } from '@ember/runloop';
22
+ import { createGeoJsonFromLayer } from '../../utils/leaflet-to-geojson';
23
+ import { waypointIconHtml } from '../../utils/route-colors';
24
+
25
+ const L = window.leaflet || window.L;
26
+
27
+ export default class LeafletAdapter extends MapAdapterInterface {
28
+ // ─── Internal State ────────────────────────────────────────────────────────
29
+
30
+ /** @type {L.Map} */
31
+ _map = null;
32
+
33
+ /** @type {Map<string, L.Marker>} */
34
+ _markers = new Map();
35
+
36
+ /** @type {Map<string, L.Layer>} */
37
+ _overlays = new Map();
38
+
39
+ /** @type {Map<string, L.Popup>} */
40
+ _popups = new Map();
41
+
42
+ /** @type {Map<string, L.GeoJSON>} */
43
+ _geojsonLayers = new Map();
44
+
45
+ /** @type {L.Control.Draw|null} */
46
+ _drawControl = null;
47
+
48
+ /** @type {L.FeatureGroup|null} */
49
+ _drawFeatureGroup = null;
50
+
51
+ /** @type {L.TileLayer|null} */
52
+ _tileLayer = null;
53
+
54
+ /** @type {Map<string, Function[]>} Normalized event → [handler, wrappedHandler] pairs */
55
+ _eventHandlers = new Map();
56
+
57
+ /** @type {Object|null} */
58
+ _drawControlConfig = null;
59
+
60
+ // ─── Lifecycle ─────────────────────────────────────────────────────────────
61
+
62
+ initializeMap(element, options = {}) {
63
+ if (this._map) {
64
+ debug('[LeafletAdapter] Map already initialized, returning existing instance.');
65
+ return this._map;
66
+ }
67
+
68
+ const mapOptions = {
69
+ center: [options.lat ?? 1.3521, options.lng ?? 103.8198],
70
+ zoom: options.zoom ?? 12,
71
+ zoomControl: options.zoomControl ?? false,
72
+ contextmenu: options.contextmenu ?? false,
73
+ contextmenuWidth: options.contextmenuWidth ?? 140,
74
+ ...options.leafletOptions,
75
+ };
76
+
77
+ this._map = L.map(element, mapOptions);
78
+
79
+ // Add default tile layer if a URL is provided
80
+ if (options.tileUrl) {
81
+ this._tileLayer = L.tileLayer(options.tileUrl, options.tileOptions ?? {}).addTo(this._map);
82
+ }
83
+
84
+ debug('[LeafletAdapter] Map initialized');
85
+ return this._map;
86
+ }
87
+
88
+ setMapInstance(mapInstance) {
89
+ this._map = mapInstance;
90
+ return mapInstance;
91
+ }
92
+
93
+ destroyMap() {
94
+ try {
95
+ this._map?.remove();
96
+ } catch (e) {
97
+ debug('[LeafletAdapter] Error removing map: ' + e.message);
98
+ }
99
+ this._map = null;
100
+ this._markers.clear();
101
+ this._overlays.clear();
102
+ this._popups.clear();
103
+ this._geojsonLayers.clear();
104
+ this._routingControls.clear();
105
+ this._drawControl = null;
106
+ this._drawFeatureGroup = null;
107
+ this._tileLayer = null;
108
+ this._eventHandlers.clear();
109
+ debug('[LeafletAdapter] Map destroyed');
110
+ }
111
+
112
+ invalidateSize() {
113
+ this._map?.invalidateSize(false);
114
+ }
115
+
116
+ // ─── Viewport ──────────────────────────────────────────────────────────────
117
+
118
+ setCenter(lat, lng, zoom) {
119
+ if (!this._map) return;
120
+ if (zoom !== undefined) {
121
+ this._map.setView([lat, lng], zoom);
122
+ } else {
123
+ this._map.setView([lat, lng]);
124
+ }
125
+ }
126
+
127
+ flyTo(lat, lng, zoom, options = {}) {
128
+ if (!this._map) return;
129
+ this._map.flyTo([lat, lng], zoom, options);
130
+ }
131
+
132
+ fitBounds(bounds, options = {}) {
133
+ if (!this._map) return;
134
+ // Normalize bounds: accept [[swLat, swLng], [neLat, neLng]] or L.LatLngBounds
135
+ const leafletBounds = bounds instanceof L.LatLngBounds ? bounds : L.latLngBounds(bounds);
136
+ this._map.fitBounds(leafletBounds, options);
137
+ }
138
+
139
+ panTo(lat, lng, options = {}) {
140
+ if (!this._map) return;
141
+ this._map.panTo([lat, lng], options);
142
+ }
143
+
144
+ zoomIn() {
145
+ this._map?.zoomIn();
146
+ }
147
+
148
+ zoomOut() {
149
+ this._map?.zoomOut();
150
+ }
151
+
152
+ getZoom() {
153
+ return this._map?.getZoom() ?? 0;
154
+ }
155
+
156
+ getCenter() {
157
+ if (!this._map?._loaded) {
158
+ return { lat: 0, lng: 0 };
159
+ }
160
+
161
+ try {
162
+ const c = this._map.getCenter();
163
+ return c ? { lat: c.lat, lng: c.lng } : { lat: 0, lng: 0 };
164
+ } catch {
165
+ return { lat: 0, lng: 0 };
166
+ }
167
+ }
168
+
169
+ getBounds() {
170
+ const b = this._map?.getBounds();
171
+ if (!b)
172
+ return [
173
+ [0, 0],
174
+ [0, 0],
175
+ ];
176
+ return [
177
+ [b.getSouth(), b.getWest()],
178
+ [b.getNorth(), b.getEast()],
179
+ ];
180
+ }
181
+
182
+ // ─── Markers ───────────────────────────────────────────────────────────────
183
+
184
+ addMarker(id, lat, lng, options = {}) {
185
+ if (!this._map) return null;
186
+
187
+ const iconOptions = options.iconUrl
188
+ ? L.icon({
189
+ iconUrl: options.iconUrl,
190
+ iconSize: options.iconSize ?? [24, 24],
191
+ iconAnchor: options.iconAnchor ?? [12, 12],
192
+ ...options.iconOptions,
193
+ })
194
+ : (options.icon ?? undefined);
195
+
196
+ const markerOptions = {
197
+ title: options.title,
198
+ alt: options.alt,
199
+ draggable: options.draggable ?? false,
200
+ rotationAngle: options.rotationAngle ?? 0,
201
+ zIndexOffset: options.zIndexOffset ?? 0,
202
+ };
203
+
204
+ if (iconOptions) {
205
+ markerOptions.icon = iconOptions;
206
+ }
207
+
208
+ const marker = L.marker([lat, lng], markerOptions).addTo(this._map);
209
+
210
+ if (options.popup) {
211
+ marker.bindPopup(options.popup);
212
+ }
213
+
214
+ if (options.tooltip) {
215
+ marker.bindTooltip(options.tooltip, options.tooltipOptions ?? {});
216
+ }
217
+
218
+ if (typeof options.onClick === 'function') {
219
+ marker.on('click', options.onClick);
220
+ }
221
+
222
+ this._markers.set(id, marker);
223
+ return marker;
224
+ }
225
+
226
+ updateMarkerPosition(id, lat, lng, animated = true, duration = 500) {
227
+ const marker = this._markers.get(id);
228
+ if (!marker) return;
229
+
230
+ if (animated && typeof marker.slideTo === 'function') {
231
+ marker.slideTo([lat, lng], { duration });
232
+ } else {
233
+ marker.setLatLng([lat, lng]);
234
+ }
235
+ }
236
+
237
+ setMarkerRotation(id, degrees) {
238
+ const marker = this._markers.get(id);
239
+ if (!marker) return;
240
+ if (typeof marker.setRotationAngle === 'function') {
241
+ marker.setRotationAngle(degrees);
242
+ }
243
+ }
244
+
245
+ removeMarker(id) {
246
+ const marker = this._markers.get(id);
247
+ if (marker) {
248
+ try {
249
+ marker.remove();
250
+ } catch (e) {
251
+ debug('[LeafletAdapter] Error removing marker: ' + e.message);
252
+ }
253
+ this._markers.delete(id);
254
+ }
255
+ }
256
+
257
+ // ─── Overlays ──────────────────────────────────────────────────────────────
258
+
259
+ addPolygon(id, coordinates, options = {}) {
260
+ if (!this._map) return null;
261
+ const polygon = L.polygon(coordinates, {
262
+ color: options.color ?? '#3388ff',
263
+ fillColor: options.fillColor ?? options.color ?? '#3388ff',
264
+ fillOpacity: options.fillOpacity ?? 0.2,
265
+ weight: options.weight ?? 3,
266
+ ...options.leafletOptions,
267
+ }).addTo(this._map);
268
+
269
+ if (options.tooltip) {
270
+ polygon.bindTooltip(options.tooltip, options.tooltipOptions ?? { permanent: true, sticky: true });
271
+ }
272
+
273
+ this._overlays.set(id, polygon);
274
+ return polygon;
275
+ }
276
+
277
+ addPolyline(id, coordinates, options = {}) {
278
+ if (!this._map) return null;
279
+ const polyline = L.polyline(coordinates, {
280
+ color: options.color ?? '#3388ff',
281
+ weight: options.weight ?? 3,
282
+ opacity: options.opacity ?? 1,
283
+ ...options.leafletOptions,
284
+ }).addTo(this._map);
285
+
286
+ this._overlays.set(id, polyline);
287
+ return polyline;
288
+ }
289
+
290
+ addCircle(id, lat, lng, radiusMeters, options = {}) {
291
+ if (!this._map) return null;
292
+ const circle = L.circle([lat, lng], {
293
+ radius: radiusMeters,
294
+ color: options.color ?? '#3388ff',
295
+ fillColor: options.fillColor ?? options.color ?? '#3388ff',
296
+ fillOpacity: options.fillOpacity ?? 0.2,
297
+ weight: options.weight ?? 3,
298
+ ...options.leafletOptions,
299
+ }).addTo(this._map);
300
+
301
+ this._overlays.set(id, circle);
302
+ return circle;
303
+ }
304
+
305
+ removeOverlay(id) {
306
+ const overlay = this._overlays.get(id);
307
+ if (overlay) {
308
+ try {
309
+ overlay.remove();
310
+ } catch (e) {
311
+ debug('[LeafletAdapter] Error removing overlay: ' + e.message);
312
+ }
313
+ this._overlays.delete(id);
314
+ }
315
+ }
316
+
317
+ addRoutingControl(route, options = {}) {
318
+ if (!this._map || !route?.waypoints?.length) return null;
319
+
320
+ const handleId = options.id ?? `route:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 8)}`;
321
+ const routeStyles = options.polylineOptions?.styles ?? null;
322
+ const polylineIds = [];
323
+
324
+ if (route.coordinates?.length) {
325
+ const polylineLayers = routeStyles?.length
326
+ ? routeStyles.map((style, index) => ({
327
+ id: `${handleId}:polyline:${index}`,
328
+ options: {
329
+ color: style.color,
330
+ weight: style.weight,
331
+ opacity: style.opacity,
332
+ leafletOptions: {
333
+ lineCap: style.lineCap,
334
+ lineJoin: style.lineJoin,
335
+ dashArray: style.dashArray,
336
+ ...(options.polylineOptions?.leafletOptions ?? {}),
337
+ },
338
+ },
339
+ }))
340
+ : [
341
+ {
342
+ id: `${handleId}:polyline:0`,
343
+ options: {
344
+ color: options.polylineOptions?.color ?? options.color ?? '#2563eb',
345
+ weight: options.polylineOptions?.weight ?? 4,
346
+ opacity: options.polylineOptions?.opacity ?? 0.85,
347
+ leafletOptions: options.polylineOptions?.leafletOptions ?? {},
348
+ },
349
+ },
350
+ ];
351
+
352
+ polylineLayers.forEach(({ id, options: polylineOptions }) => {
353
+ const polyline = this.addPolyline(id, route.coordinates, polylineOptions);
354
+ if (polyline) {
355
+ polylineIds.push(id);
356
+ }
357
+ });
358
+ }
359
+ const markerIds = [];
360
+
361
+ const markerWaypoints = options.markerWaypoints ?? route.waypoints ?? [];
362
+
363
+ if (!options.suppressMarkers) {
364
+ markerWaypoints.forEach((waypoint, index) => {
365
+ const markerId = `${handleId}:marker:${index}`;
366
+ const markerOptions = this.#buildRouteMarkerOptions(waypoint, index, route, options);
367
+ if (markerOptions === null) return;
368
+
369
+ this.addMarker(markerId, waypoint[0], waypoint[1], markerOptions);
370
+ markerIds.push(markerId);
371
+ });
372
+ }
373
+
374
+ const handle = {
375
+ id: handleId,
376
+ engine: route.engine,
377
+ route,
378
+ markerIds,
379
+ polylineIds,
380
+ tag: options.tag ?? null,
381
+ raw: route.raw,
382
+ bounds: route.bounds,
383
+ };
384
+
385
+ this._routingControls.set(handleId, handle);
386
+ return handle;
387
+ }
388
+
389
+ removeRoutingControl(handle) {
390
+ const resolvedHandle = typeof handle === 'string' ? this._routingControls.get(handle) : handle;
391
+ if (!resolvedHandle) return false;
392
+
393
+ resolvedHandle.markerIds?.forEach((id) => this.removeMarker(id));
394
+ resolvedHandle.polylineIds?.forEach((id) => this.removeOverlay(id));
395
+ this._routingControls.delete(resolvedHandle.id);
396
+ return true;
397
+ }
398
+
399
+ positionWaypoints(waypointsOrBounds, options = {}) {
400
+ if (!this._map) return null;
401
+
402
+ const isBounds = options.isBounds === true || waypointsOrBounds instanceof L.LatLngBounds;
403
+ const bounds = isBounds ? waypointsOrBounds : null;
404
+ const waypoints = isBounds ? null : waypointsOrBounds;
405
+ const paddingBottomRight = options.paddingBottomRight ?? [300, 0];
406
+ const singlePointZoom = options.singlePointZoom ?? 18;
407
+ const maxZoom = options.maxZoom ?? (isArray(waypoints) && waypoints.length === 2 ? 15 : 14);
408
+ const panBy = options.panBy ?? [0, 0];
409
+
410
+ if (isArray(waypoints) && waypoints.length === 1) {
411
+ this._map.flyTo(waypoints[0], singlePointZoom, { animate: true });
412
+ this._map.once('moveend', () => this.panBy(panBy[0], panBy[1]));
413
+ return true;
414
+ }
415
+
416
+ if (bounds || (isArray(waypoints) && waypoints.length > 1)) {
417
+ this.fitBounds(bounds ?? waypoints, {
418
+ paddingBottomRight,
419
+ maxZoom,
420
+ animate: true,
421
+ });
422
+ this._map.once('moveend', () => this.panBy(panBy[0], panBy[1]));
423
+ return true;
424
+ }
425
+
426
+ return null;
427
+ }
428
+
429
+ removeLayer(layer) {
430
+ if (!layer) return;
431
+
432
+ try {
433
+ if (typeof layer.remove === 'function') {
434
+ layer.remove();
435
+ } else {
436
+ this._map?.removeLayer?.(layer);
437
+ }
438
+ } catch (e) {
439
+ debug('[LeafletAdapter] Error removing layer: ' + e.message);
440
+ }
441
+
442
+ if (this._drawFeatureGroup?.hasLayer?.(layer)) {
443
+ this._drawFeatureGroup.removeLayer(layer);
444
+ }
445
+ }
446
+
447
+ // ─── Layer Visibility ──────────────────────────────────────────────────────
448
+
449
+ showLayer(layer, { soft = false } = {}) {
450
+ if (!this._map || !layer) return;
451
+ if (soft) {
452
+ if (layer.setStyle) {
453
+ const fillOpacity = layer.options?.fillOpacity ?? 0.2;
454
+ const hasFill = !isNone(layer.options?.fill);
455
+ layer.setStyle({ opacity: 1, fillOpacity: hasFill ? fillOpacity : 0 });
456
+ } else if (layer.setOpacity) {
457
+ layer.setOpacity(1);
458
+ }
459
+ layer.__hidden = false;
460
+ return;
461
+ }
462
+ const el = this.#getLayerEl(layer);
463
+ if (el) el.style.display = '';
464
+ this.#showOverlays(layer);
465
+ layer.__hidden = false;
466
+ }
467
+
468
+ hideLayer(layer, { soft = false } = {}) {
469
+ if (!this._map || !layer) return;
470
+ if (soft) {
471
+ if (layer.setStyle) layer.setStyle({ opacity: 0, fillOpacity: 0 });
472
+ else if (layer.setOpacity) layer.setOpacity(0);
473
+ layer.__hidden = true;
474
+ return;
475
+ }
476
+ const el = this.#getLayerEl(layer);
477
+ if (el) el.style.display = 'none';
478
+ this.#hideOverlays(layer, true);
479
+ layer.__hidden = true;
480
+ }
481
+
482
+ #hideOverlays(layer, remember = true) {
483
+ next(() => {
484
+ const tt = layer.getTooltip?.() ?? layer._tooltip ?? null;
485
+ const pp = layer.getPopup?.() ?? layer._popup ?? null;
486
+
487
+ if (remember) {
488
+ layer.__hadOpenTooltip = !!(tt && tt.isOpen && tt.isOpen());
489
+ layer.__hadOpenPopup = !!(pp && pp.isOpen && pp.isOpen());
490
+ }
491
+
492
+ try {
493
+ if (tt) layer.closeTooltip?.();
494
+ } catch {
495
+ // noop
496
+ }
497
+ try {
498
+ if (pp) layer.closePopup?.();
499
+ } catch {
500
+ // noop
501
+ }
502
+
503
+ if (tt?._container) tt._container.style.display = 'none';
504
+ if (pp?._container) pp._container.style.display = 'none';
505
+ });
506
+ }
507
+
508
+ #showOverlays(layer) {
509
+ const tt = layer.getTooltip?.() ?? layer._tooltip ?? null;
510
+ const pp = layer.getPopup?.() ?? layer._popup ?? null;
511
+
512
+ if (tt?._container) tt._container.style.display = '';
513
+ if (pp?._container) pp._container.style.display = '';
514
+
515
+ if (tt) {
516
+ const shouldOpen = layer.__hadOpenTooltip || tt.options?.permanent;
517
+ if (shouldOpen) {
518
+ try {
519
+ layer.openTooltip?.();
520
+ } catch {
521
+ // noop
522
+ }
523
+ }
524
+ }
525
+
526
+ if (pp && layer.__hadOpenPopup) {
527
+ try {
528
+ layer.openPopup?.();
529
+ } catch {
530
+ // noop
531
+ }
532
+ }
533
+
534
+ delete layer.__hadOpenTooltip;
535
+ delete layer.__hadOpenPopup;
536
+ }
537
+
538
+ isLayerHidden(layer) {
539
+ if (!layer) return true;
540
+ if (layer.__hidden) return true;
541
+ const el = this.#getLayerEl(layer);
542
+ return el ? el.style.display === 'none' : false;
543
+ }
544
+
545
+ isLayerVisible(layer) {
546
+ return !this.isLayerHidden(layer);
547
+ }
548
+
549
+ // ─── Drawing Tools ─────────────────────────────────────────────────────────
550
+
551
+ enableDrawingMode(type, options = {}) {
552
+ if (!this._map || !this._drawControl) {
553
+ debug('[LeafletAdapter] enableDrawingMode called before draw control was created');
554
+ return;
555
+ }
556
+ const modeMap = {
557
+ polygon: L.Draw?.Polygon,
558
+ circle: L.Draw?.Circle,
559
+ rectangle: L.Draw?.Rectangle,
560
+ polyline: L.Draw?.Polyline,
561
+ marker: L.Draw?.Marker,
562
+ };
563
+ const DrawClass = modeMap[type];
564
+ if (DrawClass) {
565
+ new DrawClass(this._map).enable();
566
+ }
567
+
568
+ if (typeof options.onCreate === 'function') {
569
+ this.once('draw:created', options.onCreate);
570
+ }
571
+ }
572
+
573
+ disableDrawingMode() {
574
+ // Leaflet Draw disables on 'draw:created' or via the toolbar
575
+ // Emit a synthetic stop event for listeners
576
+ this._map?.fire('draw:drawstop');
577
+ }
578
+
579
+ showDrawControl(config = {}) {
580
+ this._drawControlConfig = config;
581
+ if (!this._drawControl || !this._map) return;
582
+ if (!this._drawControl._map) {
583
+ this._drawControl.addTo(this._map);
584
+ }
585
+
586
+ const container = this._drawControl.getContainer?.();
587
+ if (container) container.style.display = '';
588
+
589
+ if (config?.defaultMode) {
590
+ this.enableDrawingMode(config.defaultMode);
591
+ }
592
+ }
593
+
594
+ hideDrawControl() {
595
+ if (!this._drawControl || !this._map) return;
596
+ const container = this._drawControl.getContainer?.();
597
+ if (container) container.style.display = 'none';
598
+ if (this._drawControl._map) {
599
+ this._map.removeControl(this._drawControl);
600
+ }
601
+ }
602
+
603
+ /**
604
+ * Store a reference to the Leaflet Draw control created by ember-leaflet.
605
+ * Called by the live-map component after draw-control creation.
606
+ *
607
+ * @param {L.Control.Draw} drawControl
608
+ * @param {L.FeatureGroup} featureGroup
609
+ */
610
+ setDrawControl(drawControl, featureGroup) {
611
+ this._drawControl = drawControl;
612
+ if (featureGroup) {
613
+ this._drawFeatureGroup = featureGroup;
614
+ }
615
+
616
+ if (this._drawControlConfig) {
617
+ this.showDrawControl(this._drawControlConfig);
618
+ }
619
+ }
620
+
621
+ registerMarker(id, markerObject) {
622
+ return super.registerMarker(id, markerObject);
623
+ }
624
+
625
+ registerPolygon(id, polygonObject) {
626
+ return super.registerPolygon(id, polygonObject);
627
+ }
628
+
629
+ getContextMenuItems(target) {
630
+ return target?.contextmenuItems ?? [];
631
+ }
632
+
633
+ showCoordinates(event) {
634
+ const wrappedLatLng = event?.latlng?.wrap?.() ?? event?.latlng;
635
+
636
+ return {
637
+ lat: wrappedLatLng?.lat,
638
+ lng: wrappedLatLng?.lng,
639
+ };
640
+ }
641
+
642
+ centerMap(event) {
643
+ if (event?.latlng) {
644
+ this._map?.panTo(event.latlng);
645
+ }
646
+ }
647
+
648
+ toggleDrawControl() {
649
+ if (!this._drawControl || !this._map) return;
650
+
651
+ if (this._drawControl._map) {
652
+ this.hideDrawControl();
653
+ return;
654
+ }
655
+
656
+ this.showDrawControl(this._drawControlConfig ?? {});
657
+ }
658
+
659
+ editPolygon(originalLayer, { focusBounds = null } = {}) {
660
+ const map = this._map;
661
+ const draw = this._drawControl;
662
+ const group = this._drawFeatureGroup;
663
+ if (!map || !draw || !group || !originalLayer) {
664
+ return Promise.resolve({ type: 'unsupported' });
665
+ }
666
+
667
+ this.showLayer(originalLayer, { soft: false });
668
+
669
+ const latlngs = originalLayer.getLatLngs?.();
670
+ if (!latlngs || !latlngs.length) {
671
+ return Promise.reject(new Error('LeafletAdapter editPolygon: layer has no coordinates'));
672
+ }
673
+
674
+ const proxy = L.polygon(latlngs, {
675
+ color: originalLayer.options?.color || '#3388ff',
676
+ weight: 3,
677
+ opacity: 0.9,
678
+ fill: true,
679
+ fillOpacity: originalLayer.options?.fillOpacity ?? 0.2,
680
+ });
681
+
682
+ const wasVisible = !originalLayer.__hidden;
683
+ this.hideLayer(originalLayer, { soft: false });
684
+ group.addLayer(proxy);
685
+
686
+ if (focusBounds) {
687
+ try {
688
+ map.fitBounds(focusBounds, { paddingBottomRight: [0, 0], maxZoom: 16, animate: true });
689
+ } catch {
690
+ // noop
691
+ }
692
+ }
693
+
694
+ this.showDrawControl({ allowEdit: true, allowDelete: true });
695
+ const editHandler = draw?._toolbars?.edit?._modes?.edit?.handler;
696
+ if (!editHandler) {
697
+ group.removeLayer(proxy);
698
+ if (wasVisible) {
699
+ this.showLayer(originalLayer, { soft: false });
700
+ }
701
+ return Promise.reject(new Error('LeafletAdapter editPolygon: edit handler unavailable'));
702
+ }
703
+
704
+ editHandler.enable();
705
+
706
+ return new Promise((resolve) => {
707
+ let settled = false;
708
+
709
+ const cleanup = (result) => {
710
+ if (settled) return;
711
+ settled = true;
712
+
713
+ try {
714
+ editHandler.disable();
715
+ } catch {
716
+ // noop
717
+ }
718
+
719
+ try {
720
+ group.removeLayer(proxy);
721
+ } catch {
722
+ // noop
723
+ }
724
+
725
+ try {
726
+ proxy.remove();
727
+ } catch {
728
+ // noop
729
+ }
730
+
731
+ if (wasVisible) {
732
+ this.showLayer(originalLayer, { soft: false });
733
+ }
734
+
735
+ this.hideDrawControl();
736
+ resolve(result);
737
+ };
738
+
739
+ const onEdited = (evt) => {
740
+ let geoJson = null;
741
+ try {
742
+ const edited = proxy.getLatLngs?.();
743
+ if (edited?.length) {
744
+ originalLayer.setLatLngs(edited);
745
+ originalLayer.redraw?.();
746
+ geoJson = createGeoJsonFromLayer(originalLayer, { layerType: 'polygon' });
747
+ }
748
+ } catch {
749
+ // noop
750
+ }
751
+
752
+ cleanup({
753
+ type: 'edited',
754
+ layer: originalLayer,
755
+ proxy,
756
+ event: evt,
757
+ geoJson,
758
+ toGeoJSON: () => geoJson,
759
+ });
760
+ };
761
+
762
+ const onEditStop = () => cleanup({ type: 'cancel' });
763
+
764
+ map.once('draw:edited', onEdited);
765
+ map.once('draw:editstop', onEditStop);
766
+ });
767
+ }
768
+
769
+ panBy(x, y = 0) {
770
+ this._map?.panBy?.([x, y]);
771
+ }
772
+
773
+ #buildRouteMarkerOptions(waypoint, index, route, options) {
774
+ if (typeof options.createMarker === 'function') {
775
+ const customOptions = options.createMarker(waypoint, index, route);
776
+ if (customOptions === null || customOptions === false) {
777
+ return null;
778
+ }
779
+
780
+ if (customOptions && typeof customOptions === 'object') {
781
+ if (customOptions.waypointLabel) {
782
+ return {
783
+ ...customOptions,
784
+ icon: L.divIcon({
785
+ className: 'fleetops-waypoint-marker',
786
+ html: waypointIconHtml(customOptions.waypointLabel, customOptions.waypointColor ?? '#2563eb'),
787
+ iconSize: customOptions.iconSize ?? [32, 32],
788
+ iconAnchor: customOptions.iconAnchor ?? [16, 16],
789
+ popupAnchor: customOptions.popupAnchor ?? [0, -20],
790
+ }),
791
+ };
792
+ }
793
+
794
+ return customOptions;
795
+ }
796
+ }
797
+
798
+ return {
799
+ iconUrl: '/assets/images/marker-icon.png',
800
+ iconSize: [25, 41],
801
+ iconAnchor: [12, 41],
802
+ iconOptions: {
803
+ iconRetinaUrl: '/assets/images/marker-icon-2x.png',
804
+ shadowUrl: '/assets/images/marker-shadow.png',
805
+ },
806
+ draggable: false,
807
+ ...options.markerOptions,
808
+ };
809
+ }
810
+
811
+ // ─── Popups ────────────────────────────────────────────────────────────────
812
+
813
+ openPopup(id, lat, lng, htmlContent) {
814
+ if (!this._map) return null;
815
+ const popup = L.popup().setLatLng([lat, lng]).setContent(htmlContent).openOn(this._map);
816
+ this._popups.set(id, popup);
817
+ return popup;
818
+ }
819
+
820
+ closePopup(id) {
821
+ const popup = this._popups.get(id);
822
+ if (popup) {
823
+ this._map?.closePopup(popup);
824
+ this._popups.delete(id);
825
+ }
826
+ }
827
+
828
+ // ─── Context Menus ─────────────────────────────────────────────────────────
829
+
830
+ registerContextMenu(target, items) {
831
+ if (!this._map) return;
832
+
833
+ if (target === 'map') {
834
+ // Use leaflet-contextmenu plugin if available
835
+ if (typeof this._map.contextmenu?.addItem === 'function') {
836
+ items.forEach((item) => {
837
+ if (item.separator) {
838
+ this._map.contextmenu.addItem({ separator: true });
839
+ } else {
840
+ this._map.contextmenu.addItem({
841
+ text: item.label,
842
+ callback: item.action,
843
+ icon: item.icon,
844
+ });
845
+ }
846
+ });
847
+ } else {
848
+ // Fallback: listen to contextmenu event and render a custom DOM menu
849
+ this._map.on('contextmenu', (e) => this.#renderDomContextMenu(e.latlng, e.originalEvent, items));
850
+ }
851
+ } else {
852
+ const marker = this._markers.get(target);
853
+ if (marker && typeof marker.bindContextMenu === 'function') {
854
+ marker.bindContextMenu({ contextmenu: true, contextmenuItems: items.map((i) => ({ text: i.label, callback: i.action })) });
855
+ }
856
+ }
857
+ }
858
+
859
+ removeContextMenu(target) {
860
+ if (target === 'map') {
861
+ this._map?.contextmenu?.removeAllItems?.();
862
+ } else {
863
+ const marker = this._markers.get(target);
864
+ marker?.unbindContextMenu?.();
865
+ }
866
+ }
867
+
868
+ // ─── Events ────────────────────────────────────────────────────────────────
869
+
870
+ on(event, handler) {
871
+ if (!this._map) return;
872
+ const normalized = this.#normalizeEvent(event);
873
+ const wrappedHandler = normalized.startsWith('draw:') ? (payload) => handler(this.#normalizeDrawEvent(event, payload)) : handler;
874
+
875
+ this._map.on(normalized, wrappedHandler);
876
+
877
+ if (!this._eventHandlers.has(event)) {
878
+ this._eventHandlers.set(event, []);
879
+ }
880
+
881
+ this._eventHandlers.get(event).push({ handler, wrappedHandler });
882
+ }
883
+
884
+ off(event, handler) {
885
+ if (!this._map) return;
886
+ const normalized = this.#normalizeEvent(event);
887
+ const entries = this._eventHandlers.get(event) ?? [];
888
+ const entry = entries.find((item) => item.handler === handler);
889
+ const wrappedHandler = entry?.wrappedHandler ?? handler;
890
+
891
+ this._map.off(normalized, wrappedHandler);
892
+
893
+ if (entry) {
894
+ this._eventHandlers.set(
895
+ event,
896
+ entries.filter((item) => item !== entry)
897
+ );
898
+ }
899
+ }
900
+
901
+ once(event, handler) {
902
+ if (!this._map) return;
903
+ const normalized = this.#normalizeEvent(event);
904
+ const wrappedHandler = normalized.startsWith('draw:') ? (payload) => handler(this.#normalizeDrawEvent(event, payload)) : handler;
905
+
906
+ this._map.once(normalized, wrappedHandler);
907
+ }
908
+
909
+ // ─── Utilities ─────────────────────────────────────────────────────────────
910
+
911
+ distanceBetween(lat1, lng1, lat2, lng2) {
912
+ if (this._map) {
913
+ return this._map.distance([lat1, lng1], [lat2, lng2]);
914
+ }
915
+ // Haversine fallback
916
+ return L.latLng(lat1, lng1).distanceTo(L.latLng(lat2, lng2));
917
+ }
918
+
919
+ addGeoJson(id, geojson, options = {}) {
920
+ if (!this._map) return null;
921
+ const layer = L.geoJSON(geojson, options).addTo(this._map);
922
+ this._geojsonLayers.set(id, layer);
923
+ return layer;
924
+ }
925
+
926
+ removeGeoJson(id) {
927
+ const layer = this._geojsonLayers.get(id);
928
+ if (layer) {
929
+ layer.remove();
930
+ this._geojsonLayers.delete(id);
931
+ }
932
+ }
933
+
934
+ setTileLayer(url, options = {}) {
935
+ if (!this._map) return;
936
+ if (this._tileLayer) {
937
+ this._tileLayer.remove();
938
+ }
939
+ this._tileLayer = L.tileLayer(url, options).addTo(this._map);
940
+ }
941
+
942
+ // ─── Private ───────────────────────────────────────────────────────────────
943
+
944
+ #getLayerEl(layer) {
945
+ return layer?._icon ?? layer?._path ?? layer?._container ?? null;
946
+ }
947
+
948
+ /**
949
+ * Normalize cross-provider event names to Leaflet event names.
950
+ * @param {string} event
951
+ * @returns {string}
952
+ */
953
+ #normalizeEvent(event) {
954
+ const map = {
955
+ rightclick: 'contextmenu',
956
+ 'draw:created': 'draw:created',
957
+ 'draw:edited': 'draw:edited',
958
+ 'draw:deleted': 'draw:deleted',
959
+ };
960
+ return map[event] ?? event;
961
+ }
962
+
963
+ #normalizeDrawEvent(event, payload) {
964
+ if (event === 'draw:created' && payload?.layer) {
965
+ const layerType = payload.layerType ?? payload.type ?? null;
966
+ const geoJson = createGeoJsonFromLayer(payload.layer, { layerType });
967
+
968
+ return {
969
+ ...payload,
970
+ type: event,
971
+ layer: payload.layer,
972
+ layerType,
973
+ geoJson,
974
+ toGeoJSON: () => geoJson,
975
+ };
976
+ }
977
+
978
+ return {
979
+ ...payload,
980
+ type: event,
981
+ };
982
+ }
983
+
984
+ /**
985
+ * Render a simple DOM-based context menu as a fallback when the
986
+ * leaflet-contextmenu plugin is not available.
987
+ *
988
+ * @param {L.LatLng} latlng
989
+ * @param {MouseEvent} originalEvent
990
+ * @param {Array} items
991
+ */
992
+ #renderDomContextMenu(latlng, originalEvent, items) {
993
+ // Remove any existing context menu
994
+ document.querySelector('.fleetops-map-contextmenu')?.remove();
995
+
996
+ const menu = document.createElement('div');
997
+ menu.className = 'fleetops-map-contextmenu';
998
+ menu.style.cssText = `
999
+ position: fixed;
1000
+ z-index: 9999;
1001
+ background: white;
1002
+ border: 1px solid #e2e8f0;
1003
+ border-radius: 6px;
1004
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
1005
+ padding: 4px 0;
1006
+ min-width: 160px;
1007
+ font-size: 13px;
1008
+ left: ${originalEvent.clientX}px;
1009
+ top: ${originalEvent.clientY}px;
1010
+ `;
1011
+
1012
+ items.forEach((item) => {
1013
+ if (item.separator) {
1014
+ const sep = document.createElement('div');
1015
+ sep.style.cssText = 'height: 1px; background: #e2e8f0; margin: 4px 0;';
1016
+ menu.appendChild(sep);
1017
+ return;
1018
+ }
1019
+ const el = document.createElement('div');
1020
+ el.textContent = item.label;
1021
+ el.style.cssText = 'padding: 6px 14px; cursor: pointer; color: #374151;';
1022
+ el.addEventListener('mouseenter', () => (el.style.background = '#f3f4f6'));
1023
+ el.addEventListener('mouseleave', () => (el.style.background = ''));
1024
+ el.addEventListener('click', () => {
1025
+ item.action({ latlng, originalEvent });
1026
+ menu.remove();
1027
+ });
1028
+ menu.appendChild(el);
1029
+ });
1030
+
1031
+ document.body.appendChild(menu);
1032
+
1033
+ // Close on next click
1034
+ const close = (e) => {
1035
+ if (!menu.contains(e.target)) {
1036
+ menu.remove();
1037
+ document.removeEventListener('click', close);
1038
+ }
1039
+ };
1040
+ setTimeout(() => document.addEventListener('click', close), 0);
1041
+ }
1042
+ }