@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.
- package/addon/components/custom-entity/form.hbs +14 -14
- package/addon/components/device/card.hbs +1 -0
- package/addon/components/device/card.js +3 -0
- package/addon/components/device/details.hbs +92 -43
- package/addon/components/device/form.hbs +108 -60
- package/addon/components/device/form.js +36 -8
- package/addon/components/device/manager.hbs +29 -0
- package/addon/components/device/manager.js +95 -0
- package/addon/components/device/panel-header.hbs +32 -0
- package/addon/components/device/panel-header.js +3 -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 +14 -3
- package/addon/components/driver/form.js +49 -47
- package/addon/components/driver/pill.hbs +17 -0
- package/addon/components/driver/pill.js +3 -0
- package/addon/components/entity/form.hbs +7 -5
- package/addon/components/layout/fleet-ops-sidebar.js +12 -12
- package/addon/components/map/drawer/device-event-listing.hbs +64 -0
- package/addon/components/map/drawer/device-event-listing.js +181 -0
- package/addon/components/map/drawer/position-listing.hbs +100 -0
- package/addon/components/map/drawer/position-listing.js +455 -0
- package/addon/components/map/drawer.js +2 -0
- package/addon/components/map/leaflet-live-map.hbs +7 -2
- 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/details/payload.hbs +6 -4
- package/addon/components/order/details/payload.js +2 -0
- package/addon/components/order/pill.hbs +34 -0
- package/addon/components/order/pill.js +3 -0
- package/addon/components/order-config-manager/custom-fields.js +1 -1
- package/addon/components/positions-replay.hbs +339 -0
- package/addon/components/positions-replay.js +409 -0
- package/addon/components/sensor/details.hbs +64 -38
- package/addon/components/sensor/form.hbs +112 -63
- package/addon/components/sensor/form.js +36 -24
- package/addon/components/sensor/panel-header.hbs +32 -0
- package/addon/components/sensor/panel-header.js +3 -0
- package/addon/components/telematic/details.hbs +40 -16
- package/addon/components/telematic/form.hbs +63 -64
- package/addon/components/telematic/form.js +73 -4
- package/addon/components/vehicle/card.hbs +2 -2
- 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/analytics/reports/index/edit.js +1 -1
- package/addon/controllers/connectivity/devices/index/details.js +22 -1
- package/addon/controllers/connectivity/devices/index/edit.js +66 -1
- package/addon/controllers/connectivity/devices/index.js +51 -9
- package/addon/controllers/connectivity/events/index.js +65 -16
- package/addon/controllers/connectivity/sensors/index/details.js +22 -1
- package/addon/controllers/connectivity/sensors/index/edit.js +66 -1
- package/addon/controllers/connectivity/sensors/index.js +66 -6
- package/addon/controllers/connectivity/telematics/index/details.js +22 -1
- package/addon/controllers/connectivity/telematics/index/edit.js +66 -1
- package/addon/controllers/connectivity/telematics/index.js +20 -11
- package/addon/controllers/management/fleets/index/details.js +26 -21
- package/addon/controllers/management/fleets/index/edit.js +9 -6
- package/addon/controllers/management/vehicles/index/details.js +26 -13
- package/addon/controllers/settings/custom-fields.js +6 -0
- package/addon/helpers/get-fleet-ops-option-label.js +11 -0
- package/addon/routes/connectivity/devices/index/details.js +27 -1
- package/addon/routes/connectivity/devices/index/edit.js +27 -1
- package/addon/routes/connectivity/sensors/index/details.js +27 -1
- package/addon/routes/connectivity/sensors/index/edit.js +27 -1
- package/addon/routes/connectivity/telematics/index/details.js +27 -1
- package/addon/routes/connectivity/telematics/index/edit.js +27 -1
- package/addon/routes/management/drivers/index/details/positions.js +3 -0
- package/addon/routes/management/vehicles/index/details/positions.js +3 -0
- package/addon/routes.js +4 -0
- package/addon/services/movement-tracker.js +81 -30
- package/addon/services/position-playback.js +486 -0
- package/addon/services/resource-metadata.js +46 -0
- package/addon/styles/fleetops-engine.css +157 -0
- package/addon/templates/connectivity/devices/index/details/index.hbs +2 -2
- package/addon/templates/connectivity/devices/index/details.hbs +15 -2
- package/addon/templates/connectivity/devices/index/edit.hbs +1 -1
- package/addon/templates/connectivity/events/index.hbs +1 -1
- package/addon/templates/connectivity/sensors/index/details/index.hbs +2 -2
- package/addon/templates/connectivity/sensors/index/details.hbs +15 -2
- package/addon/templates/connectivity/sensors/index/edit.hbs +1 -1
- package/addon/templates/connectivity/telematics/index/details/index.hbs +2 -2
- package/addon/templates/connectivity/telematics/index/details.hbs +14 -2
- package/addon/templates/connectivity/telematics/index/edit.hbs +1 -1
- package/addon/templates/management/drivers/index/details/positions.hbs +2 -0
- package/addon/templates/management/vehicles/index/details/devices.hbs +1 -2
- package/addon/templates/management/vehicles/index/details/positions.hbs +1 -0
- package/addon/utils/fleet-ops-options.js +95 -0
- package/app/components/device/card.js +1 -0
- package/app/components/device/manager.js +1 -0
- package/app/components/device/panel-header.js +1 -0
- package/app/components/device/pill.js +1 -0
- package/app/components/driver/pill.js +1 -0
- package/app/components/map/drawer/device-event-listing.js +1 -0
- package/app/components/map/drawer/position-listing.js +1 -0
- package/app/components/modals/attach-device.js +1 -0
- package/app/components/order/pill.js +1 -0
- package/app/components/positions-replay.js +1 -0
- package/app/components/sensor/panel-header.js +1 -0
- package/app/components/vehicle/pill.js +1 -0
- package/app/helpers/get-fleet-ops-option-label.js +1 -0
- package/app/routes/management/drivers/index/details/positions.js +1 -0
- package/app/routes/management/vehicles/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/app/templates/management/vehicles/index/details/positions.js +1 -0
- package/composer.json +1 -1
- package/extension.json +1 -1
- package/package.json +4 -4
- package/server/config/telematics.php +111 -0
- package/server/migrations/2025_10_27_000001_add_telematics_integration_fields.php +70 -0
- package/server/migrations/2025_10_27_171322_fix_device_column_names.php +107 -0
- package/server/migrations/2025_10_27_203023_add_company_uuid_to_device_events_table.php +28 -0
- package/server/src/Console/Commands/ReplayVehicleLocations.php +225 -0
- package/server/src/Contracts/TelematicProviderDescriptor.php +72 -0
- package/server/src/Contracts/TelematicProviderInterface.php +119 -0
- package/server/src/Exceptions/TelematicProviderException.php +14 -0
- package/server/src/Exceptions/TelematicRateLimitExceededException.php +12 -0
- package/server/src/Http/Controllers/Api/v1/DriverController.php +24 -14
- package/server/src/Http/Controllers/Api/v1/VehicleController.php +27 -7
- package/server/src/Http/Controllers/Internal/v1/DeviceController.php +22 -0
- package/server/src/Http/Controllers/Internal/v1/PositionController.php +240 -0
- package/server/src/Http/Controllers/Internal/v1/SensorController.php +11 -0
- package/server/src/Http/Controllers/Internal/v1/TelematicController.php +141 -0
- package/server/src/Http/Controllers/TelematicWebhookController.php +169 -0
- package/server/src/Http/Filter/DeviceEventFilter.php +68 -0
- package/server/src/Http/Filter/PositionFilter.php +35 -0
- package/server/src/Http/Resources/v1/Position.php +44 -0
- package/server/src/Jobs/ReplayPositions.php +64 -0
- package/server/src/Jobs/SendPositionReplay.php +65 -0
- package/server/src/Jobs/SyncTelematicDevicesJob.php +106 -0
- package/server/src/Jobs/TestTelematicConnectionJob.php +102 -0
- package/server/src/Models/Asset.php +10 -8
- package/server/src/Models/Device.php +79 -12
- package/server/src/Models/DeviceEvent.php +33 -3
- package/server/src/Models/Driver.php +28 -1
- package/server/src/Models/Maintenance.php +15 -12
- package/server/src/Models/Part.php +2 -0
- package/server/src/Models/Payload.php +0 -1
- package/server/src/Models/Place.php +4 -1
- package/server/src/Models/Position.php +27 -17
- package/server/src/Models/Sensor.php +78 -13
- package/server/src/Models/Telematic.php +116 -6
- package/server/src/Models/TrackingNumber.php +3 -1
- package/server/src/Models/Vehicle.php +8 -11
- package/server/src/Models/WorkOrder.php +8 -5
- package/server/src/Providers/FleetOpsServiceProvider.php +2 -0
- package/server/src/Support/Telematics/Providers/AbstractProvider.php +151 -0
- package/server/src/Support/Telematics/Providers/FlespiProvider.php +182 -0
- package/server/src/Support/Telematics/Providers/GeotabProvider.php +181 -0
- package/server/src/Support/Telematics/Providers/SamsaraProvider.php +177 -0
- package/server/src/Support/Telematics/TelematicProviderRegistry.php +147 -0
- package/server/src/Support/Telematics/TelematicService.php +223 -0
- package/server/src/Support/Utils.php +1 -1
- 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
|
|
37
|
+
this.events = [];
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
add(event) {
|
|
39
|
-
this.events.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
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(
|
|
102
|
+
marker.slideTo(nextLatLng, { duration: durationMs });
|
|
71
103
|
} else {
|
|
72
|
-
marker.setLatLng(
|
|
104
|
+
marker.setLatLng(nextLatLng);
|
|
73
105
|
}
|
|
74
106
|
|
|
75
|
-
|
|
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
|
-
//
|
|
80
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
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 (
|
|
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
|
+
}
|