@fleetbase/fleetops-engine 0.6.21 → 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.
- package/addon/components/device/card.hbs +1 -0
- package/addon/components/device/card.js +3 -0
- package/addon/components/device/manager.hbs +29 -0
- package/addon/components/device/manager.js +95 -0
- package/addon/components/device/pill.hbs +16 -0
- package/addon/components/device/pill.js +3 -0
- package/addon/components/driver/details.hbs +4 -0
- package/addon/components/driver/details.js +19 -1
- package/addon/components/driver/form.hbs +13 -2
- package/addon/components/driver/pill.hbs +17 -0
- package/addon/components/driver/pill.js +3 -0
- package/addon/components/map/drawer/device-event-listing.hbs +6 -0
- package/addon/components/map/drawer/position-listing.hbs +35 -19
- package/addon/components/map/drawer/position-listing.js +230 -64
- package/addon/components/modals/attach-device.hbs +18 -0
- package/addon/components/modals/attach-device.js +3 -0
- package/addon/components/order/details/detail.hbs +2 -54
- package/addon/components/order/details/detail.js +1 -0
- package/addon/components/order/pill.hbs +34 -0
- package/addon/components/order/pill.js +3 -0
- package/addon/components/positions-replay.hbs +26 -20
- package/addon/components/positions-replay.js +100 -63
- package/addon/components/telematic/form.hbs +4 -4
- package/addon/components/vehicle/card.hbs +1 -1
- package/addon/components/vehicle/details.hbs +4 -0
- package/addon/components/vehicle/details.js +19 -1
- package/addon/components/vehicle/form.hbs +4 -0
- package/addon/components/vehicle/pill.hbs +34 -0
- package/addon/components/vehicle/pill.js +3 -0
- package/addon/controllers/management/vehicles/index/details.js +5 -0
- package/addon/routes/management/drivers/index/details/positions.js +3 -0
- package/addon/routes.js +3 -0
- package/addon/services/position-playback.js +486 -0
- package/addon/services/resource-metadata.js +46 -0
- package/addon/templates/management/drivers/index/details/positions.hbs +2 -0
- package/addon/templates/management/vehicles/index/details/devices.hbs +1 -2
- package/app/components/device/card.js +1 -0
- package/app/components/device/manager.js +1 -0
- package/app/components/device/pill.js +1 -0
- package/app/components/driver/pill.js +1 -0
- package/app/components/modals/attach-device.js +1 -0
- package/app/components/order/pill.js +1 -0
- package/app/components/vehicle/pill.js +1 -0
- package/app/routes/management/drivers/index/details/positions.js +1 -0
- package/app/services/position-playback.js +1 -0
- package/app/services/resource-metadata.js +1 -0
- package/app/templates/management/drivers/index/details/positions.js +1 -0
- package/composer.json +1 -1
- package/extension.json +1 -1
- package/package.json +2 -2
- package/server/src/Http/Controllers/{Internal/v1/TelematicWebhookController.php → TelematicWebhookController.php} +1 -2
- package/server/src/Http/Resources/v1/Position.php +1 -1
- package/server/src/Models/Asset.php +10 -8
- package/server/src/Models/Device.php +11 -6
- package/server/src/Models/DeviceEvent.php +26 -3
- package/server/src/Models/Maintenance.php +15 -12
- package/server/src/Models/Part.php +2 -0
- package/server/src/Models/Position.php +10 -0
- package/server/src/Models/TrackingNumber.php +3 -1
- package/server/src/Models/WorkOrder.php +8 -5
- package/server/src/routes.php +12 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import Service, { inject as service } from '@ember/service';
|
|
2
|
+
import { action } from '@ember/object';
|
|
3
|
+
import getModelName from '@fleetbase/ember-core/utils/get-model-name';
|
|
4
|
+
|
|
5
|
+
export default class ResourceMetadataService extends Service {
|
|
6
|
+
@service modalsManager;
|
|
7
|
+
@service notifications;
|
|
8
|
+
@service intl;
|
|
9
|
+
|
|
10
|
+
@action view(resource, options = {}) {
|
|
11
|
+
this.modalsManager.show('modals/view-metadata', {
|
|
12
|
+
title: `${this.intl.t('resource.' + getModelName(resource))} ${this.intl.t('common.metadata')}`,
|
|
13
|
+
acceptButtonText: this.intl.t('common.done'),
|
|
14
|
+
hideDeclineButton: true,
|
|
15
|
+
metadata: resource.meta,
|
|
16
|
+
...options,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
@action edit(resource, options = {}) {
|
|
21
|
+
this.modalsManager.show('modals/edit-metadata', {
|
|
22
|
+
title: `${this.intl.t('common.edit')} ${this.intl.t('resource.' + getModelName(resource))} ${this.intl.t('common.metadata')}`,
|
|
23
|
+
acceptButtonText: this.intl.t('common.save-changes'),
|
|
24
|
+
acceptButtonIcon: 'save',
|
|
25
|
+
actionsWrapperClass: 'px-3',
|
|
26
|
+
metadata: resource.meta,
|
|
27
|
+
onChange: (meta) => {
|
|
28
|
+
resource.set('meta', meta);
|
|
29
|
+
},
|
|
30
|
+
confirm: async (modal) => {
|
|
31
|
+
modal.startLoading();
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
await resource.save();
|
|
35
|
+
this.notifications.success(this.intl.t('common.field-saved', { field: this.intl.t('common.metadata') }));
|
|
36
|
+
modal.done();
|
|
37
|
+
} catch (error) {
|
|
38
|
+
this.notifications.serverError(error);
|
|
39
|
+
} finally {
|
|
40
|
+
modal.stopLoading();
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
...options,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -1,2 +1 @@
|
|
|
1
|
-
|
|
2
|
-
{{outlet}}
|
|
1
|
+
<Device::Manager @resource={{@model}} />
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '@fleetbase/fleetops-engine/components/device/card';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '@fleetbase/fleetops-engine/components/device/manager';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '@fleetbase/fleetops-engine/components/device/pill';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '@fleetbase/fleetops-engine/components/driver/pill';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '@fleetbase/fleetops-engine/components/modals/attach-device';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '@fleetbase/fleetops-engine/components/order/pill';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '@fleetbase/fleetops-engine/components/vehicle/pill';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '@fleetbase/fleetops-engine/routes/management/drivers/index/details/positions';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '@fleetbase/fleetops-engine/services/position-playback';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '@fleetbase/fleetops-engine/services/resource-metadata';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '@fleetbase/fleetops-engine/templates/management/drivers/index/details/positions';
|
package/composer.json
CHANGED
package/extension.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fleetbase/fleetops-engine",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.22",
|
|
4
4
|
"description": "Fleet & Transport Management Extension for Fleetbase",
|
|
5
5
|
"fleetbase": {
|
|
6
6
|
"route": "fleet-ops"
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@babel/core": "^7.23.2",
|
|
45
45
|
"@fleetbase/ember-core": "^0.3.6",
|
|
46
|
-
"@fleetbase/ember-ui": "^0.3.
|
|
46
|
+
"@fleetbase/ember-ui": "^0.3.8",
|
|
47
47
|
"@fleetbase/fleetops-data": "^0.1.21",
|
|
48
48
|
"@fleetbase/leaflet-routing-machine": "^3.2.17",
|
|
49
49
|
"@fortawesome/ember-fontawesome": "^2.0.0",
|
|
@@ -124,8 +124,7 @@ class TelematicWebhookController extends Controller
|
|
|
124
124
|
*/
|
|
125
125
|
public function ingest(Request $request, string $id): JsonResponse
|
|
126
126
|
{
|
|
127
|
-
$telematic
|
|
128
|
-
|
|
127
|
+
$telematic = Telematic::where('uuid', $id)->orWhere('public_id', $id)->firstOrFail();
|
|
129
128
|
$correlationId = Str::uuid()->toString();
|
|
130
129
|
|
|
131
130
|
Log::info('Custom ingest received', [
|
|
@@ -36,7 +36,7 @@ class Position extends FleetbaseResource
|
|
|
36
36
|
'altitude' => $this->altitude ?? 0,
|
|
37
37
|
'latitude' => $this->latitude ?? 0,
|
|
38
38
|
'longitude' => $this->longitude ?? 0,
|
|
39
|
-
'coordinates' => $this->
|
|
39
|
+
'coordinates' => $this->coordinates ?? new Point(0, 0),
|
|
40
40
|
'updated_at' => $this->updated_at,
|
|
41
41
|
'created_at' => $this->created_at,
|
|
42
42
|
];
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
namespace Fleetbase\FleetOps\Models;
|
|
4
4
|
|
|
5
5
|
use Fleetbase\Casts\Json;
|
|
6
|
+
use Fleetbase\Casts\PolymorphicType;
|
|
6
7
|
use Fleetbase\FleetOps\Casts\Point;
|
|
7
8
|
use Fleetbase\Models\Category;
|
|
8
9
|
use Fleetbase\Models\File;
|
|
@@ -157,14 +158,15 @@ class Asset extends Model
|
|
|
157
158
|
* @var array
|
|
158
159
|
*/
|
|
159
160
|
protected $casts = [
|
|
160
|
-
'year'
|
|
161
|
-
'odometer'
|
|
162
|
-
'engine_hours'
|
|
163
|
-
'gvw'
|
|
164
|
-
'capacity'
|
|
165
|
-
'specs'
|
|
166
|
-
'attributes'
|
|
167
|
-
'location'
|
|
161
|
+
'year' => 'integer',
|
|
162
|
+
'odometer' => 'integer',
|
|
163
|
+
'engine_hours' => 'integer',
|
|
164
|
+
'gvw' => 'decimal:2',
|
|
165
|
+
'capacity' => Json::class,
|
|
166
|
+
'specs' => Json::class,
|
|
167
|
+
'attributes' => Json::class,
|
|
168
|
+
'location' => Point::class,
|
|
169
|
+
'assigned_to_type' => PolymorphicType::class,
|
|
168
170
|
];
|
|
169
171
|
|
|
170
172
|
/**
|