@fleetbase/fleetops-engine 0.6.44 → 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 (172) 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/entity/form.hbs +24 -0
  10. package/addon/components/entity/form.js +30 -0
  11. package/addon/components/layout/fleet-ops-sidebar/driver-listing.js +5 -5
  12. package/addon/components/layout/fleet-ops-sidebar/fleet-listing.js +5 -5
  13. package/addon/components/layout/fleet-ops-sidebar.js +8 -0
  14. package/addon/components/leaflet-draw-control.js +1 -1
  15. package/addon/components/maintenance-schedule/form.js +2 -1
  16. package/addon/components/map/drawer/driver-listing.js +5 -5
  17. package/addon/components/map/drawer/geofence-event-listing.js +5 -2
  18. package/addon/components/map/drawer/place-listing.js +5 -5
  19. package/addon/components/map/drawer/position-listing.js +102 -21
  20. package/addon/components/map/drawer/vehicle-listing.js +5 -5
  21. package/addon/components/map/google-live-map.hbs +9 -0
  22. package/addon/components/map/google-live-map.js +445 -0
  23. package/addon/components/map/leaflet-live-map.hbs +186 -142
  24. package/addon/components/map/leaflet-live-map.js +121 -41
  25. package/addon/components/map/toolbar/visibility-control-panel.js +2 -2
  26. package/addon/components/map/toolbar/zones-panel.hbs +9 -9
  27. package/addon/components/map/toolbar.hbs +3 -3
  28. package/addon/components/map/toolbar.js +9 -1
  29. package/addon/components/modals/map-layer-form.hbs +132 -63
  30. package/addon/components/modals/orchestrator-import.hbs +4 -11
  31. package/addon/components/modals/orchestrator-import.js +17 -1
  32. package/addon/components/modals/update-order-activity.hbs +1 -1
  33. package/addon/components/orchestrator/order-pool.js +3 -2
  34. package/addon/components/orchestrator-workbench.js +29 -41
  35. package/addon/components/order/details/detail.js +2 -2
  36. package/addon/components/order/details/payload.hbs +5 -5
  37. package/addon/components/order/details/payload.js +18 -1
  38. package/addon/components/order/details/route.hbs +2 -2
  39. package/addon/components/order/details/route.js +76 -3
  40. package/addon/components/order/form/payload.hbs +33 -0
  41. package/addon/components/order/form/payload.js +1 -0
  42. package/addon/components/order/form/route.hbs +8 -8
  43. package/addon/components/order/form/route.js +78 -18
  44. package/addon/components/order/form/service-rate.hbs +1 -1
  45. package/addon/components/order/form.js +2 -2
  46. package/addon/components/order/route-editor.hbs +209 -147
  47. package/addon/components/order/route-editor.js +143 -102
  48. package/addon/components/place/address.hbs +1 -0
  49. package/addon/components/place/address.js +12 -0
  50. package/addon/components/route-list.hbs +43 -55
  51. package/addon/components/route-list.js +47 -0
  52. package/addon/components/route-optimization-wizard-panel.js +3 -4
  53. package/addon/components/service-rate/details.hbs +6 -6
  54. package/addon/components/service-rate/form.hbs +28 -15
  55. package/addon/components/vehicle/card.hbs +4 -4
  56. package/addon/controllers/maintenance/schedules/index.js +2 -1
  57. package/addon/controllers/operations/orders/index/details/virtual.js +5 -0
  58. package/addon/controllers/operations/orders/index/details.js +113 -15
  59. package/addon/controllers/operations/orders/index/new.js +1 -1
  60. package/addon/controllers/operations/orders/index.js +8 -1
  61. package/addon/controllers/operations/service-rates/index/edit.js +1 -0
  62. package/addon/controllers/operations/service-rates/index/new.js +1 -0
  63. package/addon/controllers/settings/map.js +97 -0
  64. package/addon/controllers/settings/routing.js +47 -12
  65. package/addon/extension.js +11 -0
  66. package/addon/helpers/f-to-int.js +6 -0
  67. package/addon/helpers/place-address-html.js +6 -0
  68. package/addon/instance-initializers/register-google-routes.js +17 -0
  69. package/addon/instance-initializers/register-osrm.js +5 -0
  70. package/addon/routes/application.js +9 -3
  71. package/addon/routes/operations/orchestrator.js +13 -0
  72. package/addon/routes/operations/orders/index/details/virtual.js +1 -0
  73. package/addon/routes/operations/orders/index/details.js +47 -24
  74. package/addon/routes/operations/orders/index/new.js +9 -0
  75. package/addon/routes/operations/orders/index.js +0 -1
  76. package/addon/routes/settings/map.js +3 -0
  77. package/addon/routes.js +1 -0
  78. package/addon/services/entity-actions.js +1 -1
  79. package/addon/services/equipment-actions.js +4 -6
  80. package/addon/services/geofence-event-bus.js +2 -1
  81. package/addon/services/geofence.js +204 -168
  82. package/addon/services/google-routes.js +300 -0
  83. package/addon/services/leaflet-draw-restriction.js +2 -1
  84. package/addon/services/leaflet-map-manager.js +21 -0
  85. package/addon/services/maintenance-actions.js +4 -6
  86. package/addon/services/map-adapter/google.js +2205 -0
  87. package/addon/services/map-adapter/leaflet.js +1042 -0
  88. package/addon/services/map-adapter-interface.js +551 -0
  89. package/addon/services/map-manager.js +768 -0
  90. package/addon/services/map-settings.js +75 -0
  91. package/addon/services/movement-tracker.js +83 -32
  92. package/addon/services/order-actions.js +11 -1
  93. package/addon/services/osrm.js +79 -6
  94. package/addon/services/part-actions.js +4 -6
  95. package/addon/services/position-playback.js +135 -62
  96. package/addon/services/route-engine.js +83 -0
  97. package/addon/services/service-areas.js +8 -1
  98. package/addon/services/service-rate-actions.js +8 -3
  99. package/addon/styles/fleetops-engine.css +555 -0
  100. package/addon/templates/management/vehicles/index.hbs +0 -1
  101. package/addon/templates/operations/orders/index/details/virtual.hbs +7 -1
  102. package/addon/templates/operations/orders/index/details.hbs +0 -1
  103. package/addon/templates/settings/map.hbs +53 -0
  104. package/addon/templates/settings/routing.hbs +15 -4
  105. package/addon/utils/fleet-ops-options.js +34 -0
  106. package/addon/utils/has-valid-coordinates.js +6 -0
  107. package/addon/utils/map-provider-options.js +6 -0
  108. package/addon/utils/order-route-editing.js +86 -0
  109. package/addon/utils/order-route-summary.js +20 -0
  110. package/addon/utils/place-address-html.js +41 -0
  111. package/addon/utils/route-colors.js +43 -12
  112. package/addon/utils/route-visualization.js +93 -0
  113. package/addon/utils/serialize-payload.js +1 -1
  114. package/addon/utils/vendor-integration.js +2 -1
  115. package/app/components/admin/map-settings.js +1 -0
  116. package/app/components/admin/routing-settings.js +1 -0
  117. package/app/components/cell/order-route-type.js +1 -0
  118. package/app/components/cell/place-address.js +1 -0
  119. package/app/components/map/google-live-map.js +1 -0
  120. package/app/components/place/address.js +1 -0
  121. package/app/controllers/operations/orders/index/details/virtual.js +1 -0
  122. package/app/controllers/settings/map.js +1 -0
  123. package/app/helpers/f-to-int.js +1 -0
  124. package/app/helpers/place-address-html.js +1 -0
  125. package/app/routes/settings/map.js +1 -0
  126. package/app/services/map-adapter/google.js +1 -0
  127. package/app/services/map-adapter/leaflet.js +1 -0
  128. package/app/services/map-adapter-interface.js +1 -0
  129. package/app/services/map-manager.js +1 -0
  130. package/app/services/map-settings.js +1 -0
  131. package/app/templates/settings/map.js +1 -0
  132. package/app/utils/has-valid-coordinates.js +1 -0
  133. package/app/utils/order-route-editing.js +2 -0
  134. package/app/utils/order-route-summary.js +1 -0
  135. package/app/utils/place-address-html.js +1 -0
  136. package/app/utils/route-colors.js +1 -1
  137. package/app/utils/route-visualization.js +1 -0
  138. package/assets/images/drawing-controls-spritesheet.svg +156 -0
  139. package/composer.json +1 -1
  140. package/extension.json +1 -1
  141. package/package.json +3 -3
  142. package/server/src/Auth/Schemas/FleetOps.php +5 -0
  143. package/server/src/Http/Controllers/Api/v1/OrderController.php +36 -8
  144. package/server/src/Http/Controllers/Api/v1/PayloadController.php +14 -1
  145. package/server/src/Http/Controllers/Api/v1/PurchaseRateController.php +11 -3
  146. package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +2 -0
  147. package/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php +7 -2
  148. package/server/src/Http/Controllers/Api/v1/ServiceRateController.php +6 -1
  149. package/server/src/Http/Controllers/Api/v1/ZoneController.php +2 -0
  150. package/server/src/Http/Controllers/Internal/v1/LiveController.php +2 -1
  151. package/server/src/Http/Controllers/Internal/v1/OrchestrationController.php +7 -4
  152. package/server/src/Http/Controllers/Internal/v1/OrderController.php +61 -34
  153. package/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php +3 -2
  154. package/server/src/Http/Controllers/Internal/v1/SettingController.php +106 -3
  155. package/server/src/Http/Filter/OrderFilter.php +2 -1
  156. package/server/src/Http/Requests/CreateServiceRateRequest.php +2 -2
  157. package/server/src/Http/Resources/v1/Index/Payload.php +9 -7
  158. package/server/src/Http/Resources/v1/Order.php +20 -2
  159. package/server/src/Http/Resources/v1/ServiceQuote.php +2 -2
  160. package/server/src/Models/Entity.php +7 -0
  161. package/server/src/Models/Order.php +81 -8
  162. package/server/src/Models/OrderConfig.php +15 -0
  163. package/server/src/Models/Payload.php +131 -27
  164. package/server/src/Models/Place.php +182 -48
  165. package/server/src/Models/ServiceRate.php +125 -32
  166. package/server/src/Observers/PurchaseRateObserver.php +3 -5
  167. package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +59 -23
  168. package/server/src/Rules/ComputableAlgo.php +1 -3
  169. package/server/src/Support/Algo.php +87 -1
  170. package/server/src/Support/Utils.php +69 -0
  171. package/server/src/routes.php +4 -0
  172. 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
+ }