@fleetbase/fleetops-engine 0.6.20 → 0.6.22

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 (162) hide show
  1. package/addon/components/custom-entity/form.hbs +14 -14
  2. package/addon/components/device/card.hbs +1 -0
  3. package/addon/components/device/card.js +3 -0
  4. package/addon/components/device/details.hbs +92 -43
  5. package/addon/components/device/form.hbs +108 -60
  6. package/addon/components/device/form.js +36 -8
  7. package/addon/components/device/manager.hbs +29 -0
  8. package/addon/components/device/manager.js +95 -0
  9. package/addon/components/device/panel-header.hbs +32 -0
  10. package/addon/components/device/panel-header.js +3 -0
  11. package/addon/components/device/pill.hbs +16 -0
  12. package/addon/components/device/pill.js +3 -0
  13. package/addon/components/driver/details.hbs +4 -0
  14. package/addon/components/driver/details.js +19 -1
  15. package/addon/components/driver/form.hbs +14 -3
  16. package/addon/components/driver/form.js +49 -47
  17. package/addon/components/driver/pill.hbs +17 -0
  18. package/addon/components/driver/pill.js +3 -0
  19. package/addon/components/entity/form.hbs +7 -5
  20. package/addon/components/layout/fleet-ops-sidebar.js +12 -12
  21. package/addon/components/map/drawer/device-event-listing.hbs +64 -0
  22. package/addon/components/map/drawer/device-event-listing.js +181 -0
  23. package/addon/components/map/drawer/position-listing.hbs +100 -0
  24. package/addon/components/map/drawer/position-listing.js +455 -0
  25. package/addon/components/map/drawer.js +2 -0
  26. package/addon/components/map/leaflet-live-map.hbs +7 -2
  27. package/addon/components/modals/attach-device.hbs +18 -0
  28. package/addon/components/modals/attach-device.js +3 -0
  29. package/addon/components/order/details/detail.hbs +2 -54
  30. package/addon/components/order/details/detail.js +1 -0
  31. package/addon/components/order/details/payload.hbs +6 -4
  32. package/addon/components/order/details/payload.js +2 -0
  33. package/addon/components/order/pill.hbs +34 -0
  34. package/addon/components/order/pill.js +3 -0
  35. package/addon/components/order-config-manager/custom-fields.js +1 -1
  36. package/addon/components/positions-replay.hbs +339 -0
  37. package/addon/components/positions-replay.js +409 -0
  38. package/addon/components/sensor/details.hbs +64 -38
  39. package/addon/components/sensor/form.hbs +112 -63
  40. package/addon/components/sensor/form.js +36 -24
  41. package/addon/components/sensor/panel-header.hbs +32 -0
  42. package/addon/components/sensor/panel-header.js +3 -0
  43. package/addon/components/telematic/details.hbs +40 -16
  44. package/addon/components/telematic/form.hbs +63 -64
  45. package/addon/components/telematic/form.js +73 -4
  46. package/addon/components/vehicle/card.hbs +2 -2
  47. package/addon/components/vehicle/details.hbs +4 -0
  48. package/addon/components/vehicle/details.js +19 -1
  49. package/addon/components/vehicle/form.hbs +4 -0
  50. package/addon/components/vehicle/pill.hbs +34 -0
  51. package/addon/components/vehicle/pill.js +3 -0
  52. package/addon/controllers/analytics/reports/index/edit.js +1 -1
  53. package/addon/controllers/connectivity/devices/index/details.js +22 -1
  54. package/addon/controllers/connectivity/devices/index/edit.js +66 -1
  55. package/addon/controllers/connectivity/devices/index.js +51 -9
  56. package/addon/controllers/connectivity/events/index.js +65 -16
  57. package/addon/controllers/connectivity/sensors/index/details.js +22 -1
  58. package/addon/controllers/connectivity/sensors/index/edit.js +66 -1
  59. package/addon/controllers/connectivity/sensors/index.js +66 -6
  60. package/addon/controllers/connectivity/telematics/index/details.js +22 -1
  61. package/addon/controllers/connectivity/telematics/index/edit.js +66 -1
  62. package/addon/controllers/connectivity/telematics/index.js +20 -11
  63. package/addon/controllers/management/fleets/index/details.js +26 -21
  64. package/addon/controllers/management/fleets/index/edit.js +9 -6
  65. package/addon/controllers/management/vehicles/index/details.js +26 -13
  66. package/addon/controllers/settings/custom-fields.js +6 -0
  67. package/addon/helpers/get-fleet-ops-option-label.js +11 -0
  68. package/addon/routes/connectivity/devices/index/details.js +27 -1
  69. package/addon/routes/connectivity/devices/index/edit.js +27 -1
  70. package/addon/routes/connectivity/sensors/index/details.js +27 -1
  71. package/addon/routes/connectivity/sensors/index/edit.js +27 -1
  72. package/addon/routes/connectivity/telematics/index/details.js +27 -1
  73. package/addon/routes/connectivity/telematics/index/edit.js +27 -1
  74. package/addon/routes/management/drivers/index/details/positions.js +3 -0
  75. package/addon/routes/management/vehicles/index/details/positions.js +3 -0
  76. package/addon/routes.js +4 -0
  77. package/addon/services/movement-tracker.js +81 -30
  78. package/addon/services/position-playback.js +486 -0
  79. package/addon/services/resource-metadata.js +46 -0
  80. package/addon/styles/fleetops-engine.css +157 -0
  81. package/addon/templates/connectivity/devices/index/details/index.hbs +2 -2
  82. package/addon/templates/connectivity/devices/index/details.hbs +15 -2
  83. package/addon/templates/connectivity/devices/index/edit.hbs +1 -1
  84. package/addon/templates/connectivity/events/index.hbs +1 -1
  85. package/addon/templates/connectivity/sensors/index/details/index.hbs +2 -2
  86. package/addon/templates/connectivity/sensors/index/details.hbs +15 -2
  87. package/addon/templates/connectivity/sensors/index/edit.hbs +1 -1
  88. package/addon/templates/connectivity/telematics/index/details/index.hbs +2 -2
  89. package/addon/templates/connectivity/telematics/index/details.hbs +14 -2
  90. package/addon/templates/connectivity/telematics/index/edit.hbs +1 -1
  91. package/addon/templates/management/drivers/index/details/positions.hbs +2 -0
  92. package/addon/templates/management/vehicles/index/details/devices.hbs +1 -2
  93. package/addon/templates/management/vehicles/index/details/positions.hbs +1 -0
  94. package/addon/utils/fleet-ops-options.js +95 -0
  95. package/app/components/device/card.js +1 -0
  96. package/app/components/device/manager.js +1 -0
  97. package/app/components/device/panel-header.js +1 -0
  98. package/app/components/device/pill.js +1 -0
  99. package/app/components/driver/pill.js +1 -0
  100. package/app/components/map/drawer/device-event-listing.js +1 -0
  101. package/app/components/map/drawer/position-listing.js +1 -0
  102. package/app/components/modals/attach-device.js +1 -0
  103. package/app/components/order/pill.js +1 -0
  104. package/app/components/positions-replay.js +1 -0
  105. package/app/components/sensor/panel-header.js +1 -0
  106. package/app/components/vehicle/pill.js +1 -0
  107. package/app/helpers/get-fleet-ops-option-label.js +1 -0
  108. package/app/routes/management/drivers/index/details/positions.js +1 -0
  109. package/app/routes/management/vehicles/index/details/positions.js +1 -0
  110. package/app/services/position-playback.js +1 -0
  111. package/app/services/resource-metadata.js +1 -0
  112. package/app/templates/management/drivers/index/details/positions.js +1 -0
  113. package/app/templates/management/vehicles/index/details/positions.js +1 -0
  114. package/composer.json +1 -1
  115. package/extension.json +1 -1
  116. package/package.json +4 -4
  117. package/server/config/telematics.php +111 -0
  118. package/server/migrations/2025_10_27_000001_add_telematics_integration_fields.php +70 -0
  119. package/server/migrations/2025_10_27_171322_fix_device_column_names.php +107 -0
  120. package/server/migrations/2025_10_27_203023_add_company_uuid_to_device_events_table.php +28 -0
  121. package/server/src/Console/Commands/ReplayVehicleLocations.php +225 -0
  122. package/server/src/Contracts/TelematicProviderDescriptor.php +72 -0
  123. package/server/src/Contracts/TelematicProviderInterface.php +119 -0
  124. package/server/src/Exceptions/TelematicProviderException.php +14 -0
  125. package/server/src/Exceptions/TelematicRateLimitExceededException.php +12 -0
  126. package/server/src/Http/Controllers/Api/v1/DriverController.php +24 -14
  127. package/server/src/Http/Controllers/Api/v1/VehicleController.php +27 -7
  128. package/server/src/Http/Controllers/Internal/v1/DeviceController.php +22 -0
  129. package/server/src/Http/Controllers/Internal/v1/PositionController.php +240 -0
  130. package/server/src/Http/Controllers/Internal/v1/SensorController.php +11 -0
  131. package/server/src/Http/Controllers/Internal/v1/TelematicController.php +141 -0
  132. package/server/src/Http/Controllers/TelematicWebhookController.php +169 -0
  133. package/server/src/Http/Filter/DeviceEventFilter.php +68 -0
  134. package/server/src/Http/Filter/PositionFilter.php +35 -0
  135. package/server/src/Http/Resources/v1/Position.php +44 -0
  136. package/server/src/Jobs/ReplayPositions.php +64 -0
  137. package/server/src/Jobs/SendPositionReplay.php +65 -0
  138. package/server/src/Jobs/SyncTelematicDevicesJob.php +106 -0
  139. package/server/src/Jobs/TestTelematicConnectionJob.php +102 -0
  140. package/server/src/Models/Asset.php +10 -8
  141. package/server/src/Models/Device.php +79 -12
  142. package/server/src/Models/DeviceEvent.php +33 -3
  143. package/server/src/Models/Driver.php +28 -1
  144. package/server/src/Models/Maintenance.php +15 -12
  145. package/server/src/Models/Part.php +2 -0
  146. package/server/src/Models/Payload.php +0 -1
  147. package/server/src/Models/Place.php +4 -1
  148. package/server/src/Models/Position.php +27 -17
  149. package/server/src/Models/Sensor.php +78 -13
  150. package/server/src/Models/Telematic.php +116 -6
  151. package/server/src/Models/TrackingNumber.php +3 -1
  152. package/server/src/Models/Vehicle.php +8 -11
  153. package/server/src/Models/WorkOrder.php +8 -5
  154. package/server/src/Providers/FleetOpsServiceProvider.php +2 -0
  155. package/server/src/Support/Telematics/Providers/AbstractProvider.php +151 -0
  156. package/server/src/Support/Telematics/Providers/FlespiProvider.php +182 -0
  157. package/server/src/Support/Telematics/Providers/GeotabProvider.php +181 -0
  158. package/server/src/Support/Telematics/Providers/SamsaraProvider.php +177 -0
  159. package/server/src/Support/Telematics/TelematicProviderRegistry.php +147 -0
  160. package/server/src/Support/Telematics/TelematicService.php +223 -0
  161. package/server/src/Support/Utils.php +1 -1
  162. package/server/src/routes.php +24 -1
@@ -10,17 +10,19 @@ import LeafletTrackingMarkerComponent from '../components/leaflet-tracking-marke
10
10
  export class EventBuffer {
11
11
  @tracked events = [];
12
12
  @tracked waitTime = 1000 * 3;
13
+ @tracked callback;
13
14
  @tracked intervalId;
14
15
  @tracked model;
15
16
 
16
- constructor(model, waitTime = 1000 * 3) {
17
+ constructor(model, { callback = null, waitTime = 1000 * 3 }) {
17
18
  this.model = model;
19
+ this.callback = callback;
18
20
  this.waitTime = waitTime;
19
21
  }
20
22
 
21
23
  start() {
22
24
  this.intervalId = setInterval(() => {
23
- const bufferReady = this.process.isIdle && this.events.length;
25
+ const bufferReady = this.process.isIdle && this.events.length > 0;
24
26
  if (bufferReady) {
25
27
  this.process.perform();
26
28
  }
@@ -32,11 +34,11 @@ export class EventBuffer {
32
34
  }
33
35
 
34
36
  clear() {
35
- this.events.length = 0;
37
+ this.events = [];
36
38
  }
37
39
 
38
40
  add(event) {
39
- this.events.pushObject(event);
41
+ this.events = [...this.events, event];
40
42
  }
41
43
 
42
44
  removeByIndex(index) {
@@ -44,65 +46,112 @@ export class EventBuffer {
44
46
  }
45
47
 
46
48
  remove(event) {
47
- this.events = this.events.removeObject(event);
49
+ this.events = this.events.filter((e) => e !== event);
48
50
  }
49
51
 
50
52
  @task *process() {
51
53
  debug('Processing movement tracker event buffer.');
54
+
55
+ // Take a snapshot of events to process and clear buffer immediately
56
+ // This prevents losing events that arrive during processing
57
+ const eventsToProcess = [...this.events];
58
+ this.events = []; // Clear immediately to accept new events
59
+
52
60
  // Sort events by created_at
53
- this.events = this.events.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
61
+ eventsToProcess.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
62
+ debug(`[MovementTracker EventBuffer processing ${eventsToProcess.length} events]`);
54
63
 
55
64
  // Process sorted events
56
- for (const output of this.events) {
65
+ for (const output of eventsToProcess) {
57
66
  const { event, data } = output;
58
67
 
68
+ // get movingObject marker
69
+ const marker = this.model.leafletLayer || this.model._layer || this.model._marker;
70
+ if (!marker || !marker._map) {
71
+ debug('No marker or marker not on map yet');
72
+ continue;
73
+ }
74
+
59
75
  // log incoming event
60
76
  debug(`${event} - ${data.id} ${data.additionalData?.index ? '#' + data.additionalData?.index : ''} (${output.created_at}) [ ${data.location.coordinates.join(' ')} ]`);
61
77
 
62
- // get movingObject marker
63
- const marker = this.model._layer || this.model._marker;
64
- if (marker) {
65
- if (typeof marker.setRotationAngle === 'function' && data.heading) {
78
+ // GeoJSON -> Leaflet [lat, lng]
79
+ const [lng, lat] = data.location.coordinates;
80
+ const nextLatLng = [lat, lng];
81
+
82
+ // Calc speed
83
+ const map = marker._map;
84
+ const prev = marker.getLatLng();
85
+ const meters = map ? map.distance(prev, nextLatLng) : prev.distanceTo(nextLatLng);
86
+
87
+ // Assume payload speed is m/s; if it's km/h, convert: mps = kmh / 3.6
88
+ let mps = Number.isFinite(data.speed) && data.speed > 0 ? data.speed : null;
89
+
90
+ // Reduce animation duration and clamp between 100ms and 500ms
91
+ // This makes animations faster and prevents long delays
92
+ const durationMs = mps ? Math.max(100, Math.min((meters / mps) * 1000, 500)) : 500;
93
+
94
+ try {
95
+ // Apply rotation if heading is valid
96
+ if (typeof marker.setRotationAngle === 'function' && Number.isFinite(data.heading) && data.heading !== -1) {
66
97
  marker.setRotationAngle(data.heading);
67
98
  }
68
99
 
100
+ // Move marker with animation
69
101
  if (typeof marker.slideTo === 'function') {
70
- marker.slideTo(data.location.coordinates);
102
+ marker.slideTo(nextLatLng, { duration: durationMs });
71
103
  } else {
72
- marker.setLatLng(data.location.coordinates);
104
+ marker.setLatLng(nextLatLng);
73
105
  }
74
106
 
75
- yield timeout(1000);
107
+ if (typeof this.callback === 'function') {
108
+ this.callback(output, { nextLatLng, duration: durationMs, mps });
109
+ }
110
+
111
+ // Wait for animation to complete
112
+ yield timeout(durationMs + 50);
113
+ } catch (err) {
114
+ debug('MovementTracker EventBuffer error: ' + err.message);
76
115
  }
77
116
  }
78
117
 
79
- // Clear the buffer
80
- this.clear();
118
+ // Don't clear here - we already cleared at the start
119
+ debug(`[MovementTracker EventBuffer finished processing ${eventsToProcess.length} events]`);
81
120
  }
82
121
  }
83
122
 
84
123
  export default class MovementTrackerService extends Service {
85
124
  @service socket;
86
125
  @tracked channels = [];
126
+ @tracked buffers = new Map();
87
127
 
88
128
  constructor() {
89
129
  super(...arguments);
90
130
  this.registerTrackingMarker();
91
131
  }
92
132
 
93
- _getOwner(owner = null) {
133
+ #getOwner(owner = null) {
94
134
  return owner ?? window.Fleetbase ?? getOwner(this);
95
135
  }
96
136
 
137
+ #getBuffer(key, model, opts = {}) {
138
+ let buf = this.buffers.get(key);
139
+ if (!buf) {
140
+ buf = new EventBuffer(model, opts);
141
+ buf.start();
142
+ this.buffers.set(key, buf);
143
+ }
144
+ return buf;
145
+ }
146
+
97
147
  registerTrackingMarker(_owner = null) {
98
- const owner = this._getOwner(_owner);
148
+ const owner = this.#getOwner(_owner);
99
149
  const emberLeafletService = owner.lookup('service:ember-leaflet');
100
150
 
101
151
  if (emberLeafletService) {
102
152
  const alreadyRegistered = emberLeafletService.components.find((registeredComponent) => registeredComponent.name === 'leaflet-tracking-marker');
103
- if (alreadyRegistered) {
104
- return;
105
- }
153
+ if (alreadyRegistered) return;
154
+
106
155
  // we then invoke the `registerComponent` method
107
156
  emberLeafletService.registerComponent('leaflet-tracking-marker', {
108
157
  as: 'tracking-marker',
@@ -123,37 +172,39 @@ export default class MovementTrackerService extends Service {
123
172
  });
124
173
  }
125
174
 
126
- async track(model) {
175
+ async track(model, options = {}) {
127
176
  // Create socket instance
128
177
  const socket = this.socket.instance();
129
178
 
130
179
  // Get model type and identifier
131
180
  const type = getModelName(model);
132
181
  const identifier = model.id;
133
- debug(`Tracking movement started for ${type} with id ${identifier}`, model);
182
+
183
+ // Location events to listen for
184
+ const locationEvents = [`${type}.location_changed`, `${type}.simulated_location_changed`, 'position.changed', 'position.simulated'];
134
185
 
135
186
  // Listen on the specific channel
136
- const channelId = `${type}.${identifier}`;
187
+ const channelId = options?.channelId ?? `${type}.${identifier}`;
137
188
  const channel = socket.subscribe(channelId);
138
189
 
190
+ // Debug output
191
+ debug(`Tracking movement started for ${type} with id ${identifier}${options?.channelId ? ' on channel ' + channelId : ''}`, model);
192
+
139
193
  // Track the channel
140
- this.channels.pushObject(channel);
194
+ this.channels = [...this.channels, channel];
141
195
 
142
196
  // Listen to the channel for events
143
197
  await channel.listener('subscribe').once();
144
198
 
145
199
  // Create event buffer for tracking model
146
- const eventBuffer = new EventBuffer(model);
147
-
148
- // Start tracking with event buffer
149
- eventBuffer.start();
200
+ const eventBuffer = this.#getBuffer(channelId, model, options);
150
201
 
151
202
  // Get incoming data and console out
152
203
  (async () => {
153
204
  for await (let output of channel) {
154
205
  const { event } = output;
155
206
 
156
- if (event === `${type}.location_changed` || event === `${type}.simulated_location_changed`) {
207
+ if (locationEvents.includes(event)) {
157
208
  eventBuffer.add(output);
158
209
  debug(`Socket Event : ${event} : Added to EventBuffer : ${JSON.stringify(output)}`);
159
210
  }
@@ -0,0 +1,486 @@
1
+ import Service from '@ember/service';
2
+ import { tracked } from '@glimmer/tracking';
3
+ import { task, timeout } from 'ember-concurrency';
4
+ import { debug } from '@ember/debug';
5
+ import { isArray } from '@ember/array';
6
+
7
+ /**
8
+ * PositionPlayback Service
9
+ *
10
+ * Client-side service for replaying historical position data with full playback controls.
11
+ * Unlike movement-tracker which uses socket connections for real-time tracking,
12
+ * this service handles pre-loaded position data entirely on the client side.
13
+ *
14
+ * Features:
15
+ * - Play/Pause/Stop controls
16
+ * - Step forward/backward through positions
17
+ * - Adjustable playback speed (can be changed during playback)
18
+ * - Jump to specific position
19
+ * - Progress callbacks
20
+ * - Automatic marker animation with rotation
21
+ * - Real-time replay: respects actual time intervals between positions
22
+ */
23
+ export default class PositionPlaybackService extends Service {
24
+ @tracked isPlaying = false;
25
+ @tracked isPaused = false;
26
+ @tracked currentIndex = 0;
27
+ @tracked positions = [];
28
+ @tracked speed = 1;
29
+ @tracked marker = null;
30
+ @tracked map = null;
31
+ @tracked callback = null;
32
+
33
+ /**
34
+ * Initialize replay session with positions and marker
35
+ *
36
+ * @param {Object} options - Configuration options
37
+ * @param {Object} options.subject - Model/subject being tracked (must have leafletLayer property)
38
+ * @param {Object} options.leafletLayer - Optional manual leaflet layer instance (overrides subject.leafletLayer)
39
+ * @param {Array} options.positions - Array of position objects to replay
40
+ * @param {Number} options.speed - Initial playback speed multiplier (default: 1)
41
+ * @param {Function} options.callback - Callback function called after each position update
42
+ * @param {Object} options.map - Optional Leaflet map instance for auto-panning
43
+ */
44
+ initialize(options = {}) {
45
+ const { subject, leafletLayer, positions = [], speed = 1, callback = null, map = null } = options;
46
+
47
+ // Get marker from subject or manual layer
48
+ this.marker = leafletLayer || subject?.leafletLayer || subject?._layer || subject?._marker;
49
+
50
+ if (!this.marker) {
51
+ debug('[PositionPlayback] Warning: No leaflet marker found. Marker must be provided or subject must have leafletLayer property.');
52
+ }
53
+
54
+ this.positions = positions;
55
+ this.speed = speed;
56
+ this.callback = callback;
57
+ this.map = map;
58
+ this.currentIndex = 0;
59
+ this.isPlaying = false;
60
+ this.isPaused = false;
61
+
62
+ debug(`[PositionPlayback] Initialized with ${positions.length} positions at ${speed}x speed`);
63
+ }
64
+
65
+ /**
66
+ * Start or resume playback
67
+ */
68
+ play() {
69
+ if (this.positions.length === 0) {
70
+ debug('[PositionPlayback] Cannot play: No positions loaded');
71
+ return;
72
+ }
73
+
74
+ if (this.isPlaying) {
75
+ debug('[PositionPlayback] Already playing');
76
+ return;
77
+ }
78
+
79
+ // If we're at the end, restart from beginning
80
+ if (this.currentIndex >= this.positions.length) {
81
+ this.currentIndex = 0;
82
+ }
83
+
84
+ this.isPlaying = true;
85
+ this.isPaused = false;
86
+
87
+ debug(`[PositionPlayback] Starting playback from position ${this.currentIndex + 1}/${this.positions.length}`);
88
+
89
+ this.playbackTask.perform();
90
+ }
91
+
92
+ /**
93
+ * Pause playback (can be resumed)
94
+ */
95
+ pause() {
96
+ if (!this.isPlaying) {
97
+ debug('[PositionPlayback] Not playing');
98
+ return;
99
+ }
100
+
101
+ this.isPlaying = false;
102
+ this.isPaused = true;
103
+
104
+ debug(`[PositionPlayback] Paused at position ${this.currentIndex + 1}/${this.positions.length}`);
105
+ }
106
+
107
+ /**
108
+ * Stop playback and reset to beginning
109
+ */
110
+ stop() {
111
+ this.isPlaying = false;
112
+ this.isPaused = false;
113
+ this.currentIndex = 0;
114
+
115
+ debug('[PositionPlayback] Stopped and reset to beginning');
116
+ }
117
+
118
+ /**
119
+ * Set playback speed (can be changed during playback)
120
+ *
121
+ * @param {Number} speed - Speed multiplier (e.g., 1 = normal, 2 = 2x speed, 0.5 = half speed)
122
+ */
123
+ setSpeed(speed) {
124
+ this.speed = speed;
125
+ debug(`[PositionPlayback] Speed changed to ${speed}x`);
126
+ }
127
+
128
+ /**
129
+ * Step forward by N positions
130
+ *
131
+ * @param {Number} steps - Number of positions to step forward (default: 1)
132
+ */
133
+ stepForward(steps = 1) {
134
+ const targetIndex = Math.min(this.currentIndex + steps, this.positions.length - 1);
135
+
136
+ if (targetIndex === this.currentIndex) {
137
+ debug('[PositionPlayback] Already at last position');
138
+ return;
139
+ }
140
+
141
+ this.jumpToPosition(targetIndex);
142
+ debug(`[PositionPlayback] Stepped forward ${steps} position(s) to ${targetIndex + 1}/${this.positions.length}`);
143
+ }
144
+
145
+ /**
146
+ * Step backward by N positions
147
+ *
148
+ * @param {Number} steps - Number of positions to step backward (default: 1)
149
+ */
150
+ stepBackward(steps = 1) {
151
+ const targetIndex = Math.max(this.currentIndex - steps, 0);
152
+
153
+ if (targetIndex === this.currentIndex) {
154
+ debug('[PositionPlayback] Already at first position');
155
+ return;
156
+ }
157
+
158
+ this.jumpToPosition(targetIndex);
159
+ debug(`[PositionPlayback] Stepped backward ${steps} position(s) to ${targetIndex + 1}/${this.positions.length}`);
160
+ }
161
+
162
+ /**
163
+ * Jump to specific position index (no animation)
164
+ *
165
+ * @param {Number} index - Target position index (0-based)
166
+ */
167
+ jumpToPosition(index) {
168
+ if (index < 0 || index >= this.positions.length) {
169
+ debug(`[PositionPlayback] Invalid index: ${index}`);
170
+ return;
171
+ }
172
+
173
+ const position = this.positions[index];
174
+ this.currentIndex = index;
175
+
176
+ if (!this.marker || !position) {
177
+ return;
178
+ }
179
+
180
+ // Update marker position without animation
181
+ const latLng = this.#getLatLngFromPosition(position);
182
+ if (latLng) {
183
+ // Update rotation if heading is available
184
+ if (typeof this.marker.setRotationAngle === 'function' && Number.isFinite(position.heading) && position.heading !== -1) {
185
+ this.marker.setRotationAngle(position.heading);
186
+ }
187
+
188
+ if (typeof this.marker.slideTo === 'function') {
189
+ this.marker.slideTo(latLng, { duration: 100 });
190
+ } else {
191
+ this.marker.setLatLng(latLng);
192
+ requestAnimationFrame(() => {
193
+ if (typeof this.marker.setRotationAngle === 'function' && Number.isFinite(position.heading) && position.heading !== -1) {
194
+ this.marker.setRotationAngle(position.heading);
195
+ }
196
+ });
197
+ }
198
+
199
+ // Pan map to position if map is provided
200
+ if (this.map) {
201
+ this.map.panTo(latLng, { animate: true });
202
+ }
203
+
204
+ // Trigger callback
205
+ this.#triggerCallback(position, index, { animated: false });
206
+ }
207
+
208
+ debug(`[PositionPlayback] Jumped to position ${index + 1}/${this.positions.length}`);
209
+ }
210
+
211
+ /**
212
+ * Get current playback progress as percentage
213
+ *
214
+ * @returns {Number} Progress percentage (0-100)
215
+ */
216
+ getProgress() {
217
+ if (this.positions.length === 0) {
218
+ return 0;
219
+ }
220
+ return Math.round((this.currentIndex / this.positions.length) * 100);
221
+ }
222
+
223
+ /**
224
+ * Get current position data
225
+ *
226
+ * @returns {Object|null} Current position object or null
227
+ */
228
+ getCurrentPosition() {
229
+ return this.positions[this.currentIndex] || null;
230
+ }
231
+
232
+ /**
233
+ * Reset replay state
234
+ */
235
+ reset() {
236
+ this.stop();
237
+ this.positions = [];
238
+ this.marker = null;
239
+ this.map = null;
240
+ this.callback = null;
241
+ this.speed = 1;
242
+
243
+ debug('[PositionPlayback] Reset complete');
244
+ }
245
+
246
+ /**
247
+ * Main playback task using ember-concurrency
248
+ * Handles sequential position updates with timing based on real intervals
249
+ */
250
+ @task *playbackTask() {
251
+ debug(`[PositionPlayback] Playback task started from position ${this.currentIndex}`);
252
+
253
+ while (this.isPlaying && this.currentIndex < this.positions.length) {
254
+ const position = this.positions[this.currentIndex];
255
+ const nextPosition = this.positions[this.currentIndex + 1];
256
+
257
+ if (!position) {
258
+ debug(`[PositionPlayback] Invalid position at index ${this.currentIndex}`);
259
+ this.currentIndex++;
260
+ continue;
261
+ }
262
+
263
+ // Get marker (it might have been updated)
264
+ const marker = this.marker;
265
+ if (!marker || !marker._map) {
266
+ debug('[PositionPlayback] Marker not available or not on map');
267
+ this.currentIndex++;
268
+ continue;
269
+ }
270
+
271
+ // Calculate next position
272
+ const latLng = this.#getLatLngFromPosition(position);
273
+ if (!latLng) {
274
+ debug(`[PositionPlayback] Invalid coordinates for position ${this.currentIndex}`);
275
+ this.currentIndex++;
276
+ continue;
277
+ }
278
+
279
+ // Calculate animation duration based on distance and speed
280
+ const animationDuration = this.#calculateAnimationDuration(marker, latLng, position);
281
+
282
+ try {
283
+ // Apply rotation if heading is valid
284
+ if (typeof marker.setRotationAngle === 'function' && Number.isFinite(position.heading) && position.heading !== -1) {
285
+ marker.setRotationAngle(position.heading);
286
+ }
287
+
288
+ // Move marker with animation
289
+ if (typeof marker.slideTo === 'function') {
290
+ marker.slideTo(latLng, { duration: animationDuration });
291
+ } else {
292
+ marker.setLatLng(latLng);
293
+ }
294
+
295
+ // Pan map to follow marker if map is provided
296
+ if (this.map) {
297
+ const targetLatLng = marker._slideToLatLng ?? marker.getLatLng();
298
+ this.map.panTo(targetLatLng, { animate: true });
299
+ }
300
+
301
+ // Trigger callback
302
+ this.#triggerCallback(position, this.currentIndex, { duration: animationDuration, animated: true });
303
+
304
+ // Wait for animation to complete
305
+ yield timeout(animationDuration + 50);
306
+
307
+ // Calculate delay until next position based on real-time interval
308
+ if (nextPosition) {
309
+ const delayUntilNext = this.#calculateDelayToNextPosition(position, nextPosition);
310
+
311
+ if (delayUntilNext > 0) {
312
+ debug(`[PositionPlayback] Waiting ${delayUntilNext}ms until next position (${this.speed}x speed)`);
313
+ yield timeout(delayUntilNext);
314
+ }
315
+ }
316
+ } catch (err) {
317
+ debug(`[PositionPlayback] Error processing position ${this.currentIndex}: ${err.message}`);
318
+ }
319
+
320
+ // Move to next position
321
+ this.currentIndex++;
322
+ }
323
+
324
+ // Playback complete
325
+ if (this.currentIndex >= this.positions.length) {
326
+ this.isPlaying = false;
327
+ this.isPaused = false;
328
+ debug('[PositionPlayback] Playback complete');
329
+
330
+ // Trigger completion callback
331
+ if (typeof this.callback === 'function') {
332
+ this.callback({ type: 'complete', totalPositions: this.positions.length });
333
+ }
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Calculate delay to next position based on real-time interval
339
+ *
340
+ * @private
341
+ * @param {Object} currentPosition - Current position object
342
+ * @param {Object} nextPosition - Next position object
343
+ * @returns {Number} Delay in milliseconds (adjusted by speed multiplier)
344
+ */
345
+ #calculateDelayToNextPosition(currentPosition, nextPosition) {
346
+ // Try to get timestamps from positions
347
+ const currentTime = this.#getTimestamp(currentPosition);
348
+ const nextTime = this.#getTimestamp(nextPosition);
349
+
350
+ if (!currentTime || !nextTime) {
351
+ // No timestamp data, use default delay
352
+ debug('[PositionPlayback] No timestamp data, using default delay');
353
+ return 1000 / this.speed; // 1 second default, adjusted by speed
354
+ }
355
+
356
+ // Calculate real-time interval in milliseconds
357
+ const realTimeInterval = nextTime - currentTime;
358
+
359
+ if (realTimeInterval <= 0) {
360
+ // Invalid interval, use minimum delay
361
+ return 100 / this.speed;
362
+ }
363
+
364
+ // Apply speed multiplier (higher speed = shorter delay)
365
+ const adjustedDelay = realTimeInterval / this.speed;
366
+
367
+ // Clamp between reasonable bounds (50ms to 60 seconds)
368
+ // At high speeds, we don't want delays too short
369
+ // At low speeds, we cap at 60 seconds to prevent extremely long waits
370
+ return Math.max(50, Math.min(adjustedDelay, 60000));
371
+ }
372
+
373
+ /**
374
+ * Get timestamp from position object
375
+ * Handles multiple timestamp field formats
376
+ *
377
+ * @private
378
+ * @param {Object} position - Position object
379
+ * @returns {Number|null} Timestamp in milliseconds or null
380
+ */
381
+ #getTimestamp(position) {
382
+ // Try different timestamp fields
383
+ const timestampFields = ['created_at', 'timestamp', 'recorded_at', 'time', 'datetime'];
384
+
385
+ for (const field of timestampFields) {
386
+ const value = position[field];
387
+ if (value) {
388
+ // Try to parse as date
389
+ const timestamp = new Date(value).getTime();
390
+ if (Number.isFinite(timestamp)) {
391
+ return timestamp;
392
+ }
393
+ }
394
+ }
395
+
396
+ return null;
397
+ }
398
+
399
+ /**
400
+ * Extract lat/lng from position object
401
+ * Handles multiple position data formats
402
+ *
403
+ * @private
404
+ * @param {Object} position - Position object
405
+ * @returns {Array|null} [lat, lng] or null if invalid
406
+ */
407
+ #getLatLngFromPosition(position) {
408
+ // Direct latitude/longitude properties
409
+ if (Number.isFinite(position.latitude) && Number.isFinite(position.longitude)) {
410
+ return [position.latitude, position.longitude];
411
+ }
412
+
413
+ // GeoJSON format in location.coordinates [lng, lat]
414
+ if (position.location?.coordinates && isArray(position.location.coordinates)) {
415
+ const [lng, lat] = position.location.coordinates;
416
+ if (Number.isFinite(lat) && Number.isFinite(lng)) {
417
+ return [lat, lng];
418
+ }
419
+ }
420
+
421
+ // Coordinates array [lat, lng]
422
+ if (position.coordinates && isArray(position.coordinates)) {
423
+ const [lat, lng] = position.coordinates;
424
+ if (Number.isFinite(lat) && Number.isFinite(lng)) {
425
+ return [lat, lng];
426
+ }
427
+ }
428
+
429
+ return null;
430
+ }
431
+
432
+ /**
433
+ * Calculate animation duration based on distance and speed
434
+ * This is for the marker movement animation, separate from the delay between positions
435
+ *
436
+ * @private
437
+ * @param {Object} marker - Leaflet marker
438
+ * @param {Array} nextLatLng - Target [lat, lng]
439
+ * @param {Object} position - Position object with optional speed data
440
+ * @returns {Number} Duration in milliseconds
441
+ */
442
+ #calculateAnimationDuration(marker, nextLatLng, position) {
443
+ const map = marker._map;
444
+ const prev = marker.getLatLng();
445
+ const meters = map ? map.distance(prev, nextLatLng) : prev.distanceTo(nextLatLng);
446
+
447
+ // Get speed from position data (assume m/s)
448
+ let mps = Number.isFinite(position.speed) && position.speed > 0 ? position.speed : null;
449
+
450
+ // If speed is in km/h, convert to m/s
451
+ if (mps && position.speed_unit === 'kmh') {
452
+ mps = mps / 3.6;
453
+ }
454
+
455
+ // Calculate base duration for animation
456
+ let baseDuration = mps ? (meters / mps) * 1000 : 500;
457
+
458
+ // For animation, we want it relatively quick regardless of playback speed
459
+ // The playback speed affects the delay between positions, not the animation speed
460
+ // Clamp between 100ms and 1000ms for smooth animation
461
+ const duration = Math.max(100, Math.min(baseDuration, 1000));
462
+
463
+ return duration;
464
+ }
465
+
466
+ /**
467
+ * Trigger callback with position data
468
+ *
469
+ * @private
470
+ * @param {Object} position - Position object
471
+ * @param {Number} index - Current position index
472
+ * @param {Object} metadata - Additional metadata
473
+ */
474
+ #triggerCallback(position, index, metadata = {}) {
475
+ if (typeof this.callback === 'function') {
476
+ this.callback({
477
+ type: 'position',
478
+ position,
479
+ index,
480
+ totalPositions: this.positions.length,
481
+ progress: this.getProgress(),
482
+ ...metadata,
483
+ });
484
+ }
485
+ }
486
+ }