@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
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
<div class="positions-replay-component flex flex-row items-center justify-between w-full px-5 py-2 border-b border-gray-200 dark:border-gray-700">
|
|
2
|
+
<div class="flex flex-row items-center space-x-2">
|
|
3
|
+
<div>
|
|
4
|
+
<div class="fleetbase-model-select fleetbase-power-select ember-model-select">
|
|
5
|
+
<PowerSelect
|
|
6
|
+
@options={{this.trackables}}
|
|
7
|
+
@selected={{this.resource}}
|
|
8
|
+
@onChange={{this.onResourceSelected}}
|
|
9
|
+
@placeholder={{t "common.select-field" field="trackable"}}
|
|
10
|
+
@triggerClass="form-select form-input form-input-sm w-48"
|
|
11
|
+
as |option|
|
|
12
|
+
>
|
|
13
|
+
<div class="flex flex-row items-start space-x-2">
|
|
14
|
+
<div class="inline-block">
|
|
15
|
+
{{#if (eq (get-model-name option) "driver")}}
|
|
16
|
+
<Image src={{option.photo_url}} @fallbackSrc={{config "defaultValues.driverAvatar"}} class="w-4 h-4" />
|
|
17
|
+
{{else}}
|
|
18
|
+
<Image src={{option.avatar_url}} @fallbackSrc={{config "defaultValues.vehicleAvatar"}} class="w-4 h-4" />
|
|
19
|
+
{{/if}}
|
|
20
|
+
</div>
|
|
21
|
+
<div class="inline-block text-sm truncate">
|
|
22
|
+
<div class="font-semibold text-sm normalize-in-trigger">{{or option.name option.displayName option.public_id}}</div>
|
|
23
|
+
<div class="hide-from-trigger text-xs">{{or option.email option.serial_number option.plate_number option.internal_id}}</div>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
</PowerSelect>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<div class="w-48 {{if this.isReplaying 'hidden' ''}}">
|
|
31
|
+
<DatePicker
|
|
32
|
+
@value={{this.dateFilter}}
|
|
33
|
+
@range={{true}}
|
|
34
|
+
@onSelect={{this.onDateRangeChanged}}
|
|
35
|
+
@autoClose={{true}}
|
|
36
|
+
@placeholder="Select date range"
|
|
37
|
+
class="w-full form-input form-input-sm"
|
|
38
|
+
/>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
{{#if this.loadPositions.isRunning}}
|
|
42
|
+
<div>
|
|
43
|
+
<Spinner />
|
|
44
|
+
</div>
|
|
45
|
+
{{/if}}
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<div class="replay-controls">
|
|
49
|
+
<div class="flex items-center justify-between space-x-2">
|
|
50
|
+
<div class="flex items-center space-x-2">
|
|
51
|
+
<Button @type="danger" @text="Stop" @icon="stop" @size="xs" @disabled={{not (or this.isReplaying this.isPaused)}} @onClick={{this.stopReplay}} />
|
|
52
|
+
|
|
53
|
+
{{#if (or this.isReplaying this.isPaused)}}
|
|
54
|
+
{{#if this.isReplaying}}
|
|
55
|
+
<Button @type="warning" @text="Pause" @icon="pause" @size="xs" @onClick={{this.pauseReplay}} />
|
|
56
|
+
{{else}}
|
|
57
|
+
<Button @type="success" @text="Resume" @icon="play" @size="xs" @onClick={{this.startReplay}} />
|
|
58
|
+
{{/if}}
|
|
59
|
+
{{else}}
|
|
60
|
+
<Button @type="success" @text="Play" @icon="play" @size="xs" @disabled={{not this.hasPositions}} @onClick={{this.startReplay}} />
|
|
61
|
+
{{/if}}
|
|
62
|
+
|
|
63
|
+
<div class="step-controls flex items-center space-x-1 border-l border-gray-300 dark:border-gray-700 pl-2 ml-2">
|
|
64
|
+
<Button @type="default" @icon="backward-step" @size="xs" @disabled={{not this.hasPositions}} @onClick={{this.stepBackward}} title="Step backward" />
|
|
65
|
+
<Button @type="default" @icon="forward-step" @size="xs" @disabled={{not this.hasPositions}} @onClick={{this.stepForward}} title="Step forward" />
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<div class="speed-control border-l border-gray-300 dark:border-gray-700 pl-2 ml-2">
|
|
69
|
+
<label class="text-xs uppercase tracking-wide font-medium text-gray-700 dark:text-gray-400 mr-1">
|
|
70
|
+
Speed:
|
|
71
|
+
</label>
|
|
72
|
+
<Select
|
|
73
|
+
@value={{this.replaySpeed}}
|
|
74
|
+
@options={{this.speedOptions}}
|
|
75
|
+
@optionValue="value"
|
|
76
|
+
@optionLabel="label"
|
|
77
|
+
@onSelect={{this.onSpeedChanged}}
|
|
78
|
+
class="form-select speed-select"
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
{{#if (or this.isReplaying this.isPaused)}}
|
|
84
|
+
<div class="replay-progress flex items-center space-x-1">
|
|
85
|
+
<span class="text-sm text-gray-600 dark:text-gray-400">
|
|
86
|
+
{{this.currentReplayIndex}}/{{this.totalPositions}}
|
|
87
|
+
</span>
|
|
88
|
+
<div class="progress-bar w-32 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
|
89
|
+
<div class="progress-fill h-full bg-blue-600 transition-all duration-300" style={{this.replayProgressWidth}}></div>
|
|
90
|
+
</div>
|
|
91
|
+
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
92
|
+
{{this.replayProgress}}%
|
|
93
|
+
</span>
|
|
94
|
+
</div>
|
|
95
|
+
{{/if}}
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
<Table @rows={{this.positions}} @columns={{this.columns}} />
|
|
100
|
+
<Spacer @height="400px" />
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import Component from '@glimmer/component';
|
|
2
|
+
import { tracked } from '@glimmer/tracking';
|
|
3
|
+
import { inject as service } from '@ember/service';
|
|
4
|
+
import { action } from '@ember/object';
|
|
5
|
+
import { task } from 'ember-concurrency';
|
|
6
|
+
import { isArray } from '@ember/array';
|
|
7
|
+
import { htmlSafe } from '@ember/template';
|
|
8
|
+
import { startOfWeek, endOfWeek, format } from 'date-fns';
|
|
9
|
+
import getModelName from '@fleetbase/ember-core/utils/get-model-name';
|
|
10
|
+
|
|
11
|
+
const L = window.leaflet || window.L;
|
|
12
|
+
|
|
13
|
+
export default class MapDrawerPositionListingComponent extends Component {
|
|
14
|
+
@service leafletMapManager;
|
|
15
|
+
@service store;
|
|
16
|
+
@service fetch;
|
|
17
|
+
@service positionPlayback;
|
|
18
|
+
@service hostRouter;
|
|
19
|
+
@service notifications;
|
|
20
|
+
@service intl;
|
|
21
|
+
|
|
22
|
+
/** Tracked properties - only what's NOT managed by service */
|
|
23
|
+
@tracked positions = [];
|
|
24
|
+
@tracked resource = null;
|
|
25
|
+
@tracked selectedOrder = null;
|
|
26
|
+
@tracked dateFilter = [format(startOfWeek(new Date(), { weekStartsOn: 1 }), 'yyyy-MM-dd'), format(endOfWeek(new Date(), { weekStartsOn: 1 }), 'yyyy-MM-dd')];
|
|
27
|
+
@tracked replaySpeed = '1';
|
|
28
|
+
@tracked positionsLayer = null;
|
|
29
|
+
|
|
30
|
+
/** Computed properties - read state from service */
|
|
31
|
+
get isReplaying() {
|
|
32
|
+
return this.positionPlayback.isPlaying;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get isPaused() {
|
|
36
|
+
return this.positionPlayback.isPaused;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get currentReplayIndex() {
|
|
40
|
+
return this.positionPlayback.currentIndex;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get trackables() {
|
|
44
|
+
const vehicles = this.leafletMapManager._livemap?.vehicles ?? [];
|
|
45
|
+
const drivers = this.leafletMapManager._livemap?.drivers ?? [];
|
|
46
|
+
|
|
47
|
+
return [...vehicles, ...drivers];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get replayProgressWidth() {
|
|
51
|
+
return htmlSafe(`width: ${this.replayProgress}%;`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get orderFilters() {
|
|
55
|
+
const params = {};
|
|
56
|
+
|
|
57
|
+
if (this.resourceType === 'vehicle') {
|
|
58
|
+
params.vehicle_assigned_uuid = this.resource?.id;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (this.resourceType === 'driver') {
|
|
62
|
+
params.driver_assigned_uuid = this.resource?.id;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return params;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get resourceType() {
|
|
69
|
+
if (!this.resource) {
|
|
70
|
+
return 'resource';
|
|
71
|
+
}
|
|
72
|
+
return getModelName(this.resource) || 'resource';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
get hasPositions() {
|
|
76
|
+
return this.positions.length > 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
get totalPositions() {
|
|
80
|
+
return this.positions.length;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
get replayProgress() {
|
|
84
|
+
if (this.totalPositions === 0) {
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
return Math.round((this.currentReplayIndex / this.totalPositions) * 100);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
get speedOptions() {
|
|
91
|
+
return [
|
|
92
|
+
{ label: '0.5x', value: '0.5' },
|
|
93
|
+
{ label: '1x', value: '1' },
|
|
94
|
+
{ label: '2x', value: '2' },
|
|
95
|
+
{ label: '5x', value: '5' },
|
|
96
|
+
{ label: '10x', value: '10' },
|
|
97
|
+
{ label: '20x', value: '20' },
|
|
98
|
+
{ label: '30x', value: '30' },
|
|
99
|
+
{ label: '40x', value: '40' },
|
|
100
|
+
{ label: '50x', value: '50' },
|
|
101
|
+
{ label: '100x', value: '100' },
|
|
102
|
+
{ label: '80x', value: '80' },
|
|
103
|
+
{ label: '120x', value: '120' },
|
|
104
|
+
{ label: '160x', value: '160' },
|
|
105
|
+
{ label: '180x', value: '180' },
|
|
106
|
+
{ label: '200x', value: '200' },
|
|
107
|
+
{ label: '250x', value: '250' },
|
|
108
|
+
{ label: '280x', value: '280' },
|
|
109
|
+
{ label: '300x', value: '300' },
|
|
110
|
+
{ label: '350x', value: '350' },
|
|
111
|
+
{ label: '400x', value: '400' },
|
|
112
|
+
{ label: '500x', value: '500' },
|
|
113
|
+
{ label: '600x', value: '600' },
|
|
114
|
+
{ label: '1000x', value: '1000' },
|
|
115
|
+
];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** columns */
|
|
119
|
+
get columns() {
|
|
120
|
+
return [
|
|
121
|
+
{
|
|
122
|
+
label: '#',
|
|
123
|
+
valuePath: 'index',
|
|
124
|
+
width: '80px',
|
|
125
|
+
cellComponent: 'table/cell/anchor',
|
|
126
|
+
onClick: this.onPositionClicked,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
label: 'Timestamp',
|
|
130
|
+
valuePath: 'timestamp',
|
|
131
|
+
cellComponent: 'table/cell/anchor',
|
|
132
|
+
onClick: this.onPositionClicked,
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
label: 'Latitude',
|
|
136
|
+
valuePath: 'latitude',
|
|
137
|
+
cellComponent: 'table/cell/anchor',
|
|
138
|
+
onClick: this.onPositionClicked,
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
label: 'Longitude',
|
|
142
|
+
valuePath: 'longitude',
|
|
143
|
+
cellComponent: 'table/cell/anchor',
|
|
144
|
+
onClick: this.onPositionClicked,
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
label: 'Speed (km/h)',
|
|
148
|
+
valuePath: 'speedKmh',
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
label: 'Heading',
|
|
152
|
+
valuePath: 'heading',
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
label: 'Altitude (m)',
|
|
156
|
+
valuePath: 'altitude',
|
|
157
|
+
},
|
|
158
|
+
];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
constructor() {
|
|
162
|
+
super(...arguments);
|
|
163
|
+
this.loadPositions.perform();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
willDestroy() {
|
|
167
|
+
super.willDestroy?.();
|
|
168
|
+
|
|
169
|
+
// Clean up replay tracker
|
|
170
|
+
this.positionPlayback.reset();
|
|
171
|
+
|
|
172
|
+
// Remove our layer group from the map
|
|
173
|
+
this.#clearPositionsLayer(true);
|
|
174
|
+
this.positionsLayer = null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
@action onResourceSelected(resource) {
|
|
178
|
+
this.resource = resource;
|
|
179
|
+
this.focusResource(resource);
|
|
180
|
+
this.loadPositions.perform();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
@action onOrderSelected(order) {
|
|
184
|
+
this.selectedOrder = order;
|
|
185
|
+
this.loadPositions.perform();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
@action onDateRangeChanged({ formattedDate }) {
|
|
189
|
+
if (isArray(formattedDate) && formattedDate.length === 2) {
|
|
190
|
+
this.dateFilter = formattedDate;
|
|
191
|
+
this.loadPositions.perform();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
@action onSpeedChanged(speed) {
|
|
196
|
+
this.replaySpeed = speed;
|
|
197
|
+
|
|
198
|
+
// Update replay speed in real-time if currently playing
|
|
199
|
+
if (this.isReplaying) {
|
|
200
|
+
this.positionPlayback.setSpeed(parseFloat(speed));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
@action startReplay() {
|
|
205
|
+
if (this.positions.length === 0) {
|
|
206
|
+
this.notifications.warning('No positions to replay');
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (this.isReplaying && !this.isPaused) {
|
|
211
|
+
this.notifications.info('Replay is already running');
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// If paused, resume
|
|
216
|
+
if (this.isPaused) {
|
|
217
|
+
this.positionPlayback.play();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Start new replay
|
|
222
|
+
this.#initializeReplay();
|
|
223
|
+
this.positionPlayback.play();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
@action pauseReplay() {
|
|
227
|
+
if (!this.isReplaying) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
this.positionPlayback.pause();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
@action stopReplay() {
|
|
235
|
+
this.positionPlayback.stop();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
@action stepForward() {
|
|
239
|
+
if (this.isReplaying) {
|
|
240
|
+
this.pauseReplay();
|
|
241
|
+
}
|
|
242
|
+
this.positionPlayback.stepForward(1);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
@action stepBackward() {
|
|
246
|
+
if (this.isReplaying) {
|
|
247
|
+
this.pauseReplay();
|
|
248
|
+
}
|
|
249
|
+
this.positionPlayback.stepBackward(1);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
@action clearFilters() {
|
|
253
|
+
this.selectedOrder = null;
|
|
254
|
+
this.dateFilter = null;
|
|
255
|
+
this.loadPositions.perform();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
@action onPositionClicked(position) {
|
|
259
|
+
if (this.leafletMapManager.map && position.latitude && position.longitude) {
|
|
260
|
+
this.leafletMapManager.map.setView([position.latitude, position.longitude], 16);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
@action focusResource(resource) {
|
|
265
|
+
const hasValidCoordinates = resource?.hasValidCoordinates || (Number.isFinite(resource?.latitude) && Number.isFinite(resource?.longitude));
|
|
266
|
+
if (hasValidCoordinates) {
|
|
267
|
+
const coordinates = [resource.latitude, resource.longitude];
|
|
268
|
+
|
|
269
|
+
// Use flyTo with a zoom level of 18 for a smooth animation
|
|
270
|
+
this.leafletMapManager.map.flyTo(coordinates, 18, {
|
|
271
|
+
animate: true,
|
|
272
|
+
duration: 0.8,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
@task *loadPositions() {
|
|
278
|
+
if (!this.resource) return;
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
const params = {
|
|
282
|
+
limit: 900,
|
|
283
|
+
sort: 'created_at',
|
|
284
|
+
subject_uuid: this.resource.id,
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
if (this.selectedOrder) {
|
|
288
|
+
params.order_uuid = this.selectedOrder.id;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (isArray(this.dateFilter) && this.dateFilter.length === 2) {
|
|
292
|
+
params.created_at = this.dateFilter.join(',');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const positions = yield this.store.query('position', params);
|
|
296
|
+
this.positions = isArray(positions)
|
|
297
|
+
? positions.map((pos, index) => {
|
|
298
|
+
pos.set('index', index + 1);
|
|
299
|
+
return pos;
|
|
300
|
+
})
|
|
301
|
+
: [];
|
|
302
|
+
|
|
303
|
+
this.#renderPositionsOnMap({ fitLast: 5 });
|
|
304
|
+
|
|
305
|
+
// Reset replay state when positions change
|
|
306
|
+
this.stopReplay();
|
|
307
|
+
} catch (error) {
|
|
308
|
+
this.notifications.serverError(error);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Initialize replay tracker with current positions and settings
|
|
314
|
+
* This replaces the socket-based backend replay approach
|
|
315
|
+
*
|
|
316
|
+
* @private
|
|
317
|
+
*/
|
|
318
|
+
#initializeReplay() {
|
|
319
|
+
if (!this.resource) {
|
|
320
|
+
this.notifications.warning('No resource provided for replay');
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (this.positions.length === 0) {
|
|
325
|
+
this.notifications.warning('No positions to replay');
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Initialize replay tracker with positions
|
|
330
|
+
this.positionPlayback.initialize({
|
|
331
|
+
subject: this.resource,
|
|
332
|
+
positions: this.positions,
|
|
333
|
+
speed: parseFloat(this.replaySpeed),
|
|
334
|
+
map: this.leafletMapManager.map,
|
|
335
|
+
callback: (data) => {
|
|
336
|
+
if (data.type === 'complete') {
|
|
337
|
+
// Replay completed
|
|
338
|
+
this.notifications.success('Replay completed');
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Position Rendering */
|
|
345
|
+
#ensurePositionsLayer() {
|
|
346
|
+
if (!this.leafletMapManager.map) return null;
|
|
347
|
+
|
|
348
|
+
if (!this.positionsLayer) {
|
|
349
|
+
this.positionsLayer = L.layerGroup();
|
|
350
|
+
this.positionsLayer.addTo(this.leafletMapManager.map);
|
|
351
|
+
}
|
|
352
|
+
return this.positionsLayer;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
#clearPositionsLayer(removeFromMap = false) {
|
|
356
|
+
if (this.positionsLayer) {
|
|
357
|
+
this.positionsLayer.clearLayers();
|
|
358
|
+
if (removeFromMap && this.leafletMapManager.map) {
|
|
359
|
+
this.positionsLayer.removeFrom(this.leafletMapManager.map);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
#addPositionMarker(pos, index) {
|
|
365
|
+
const lat = parseFloat(pos.latitude);
|
|
366
|
+
const lng = parseFloat(pos.longitude);
|
|
367
|
+
if (!this.#isValidLatLng(lat, lng)) return;
|
|
368
|
+
|
|
369
|
+
const marker = L.circleMarker([lat, lng], {
|
|
370
|
+
radius: 3,
|
|
371
|
+
color: '#3b82f6',
|
|
372
|
+
fillColor: '#3b82f6',
|
|
373
|
+
fillOpacity: 0.6,
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// Popup content (mirrors your template)
|
|
377
|
+
const html = htmlSafe(`<div class="text-xs">
|
|
378
|
+
<div><strong>Position ${index + 1}</strong></div>
|
|
379
|
+
<div>Time: ${pos.timestamp ?? ''}</div>
|
|
380
|
+
<div>Speed: ${pos.speedKmh ?? 'N/A'} km/h</div>
|
|
381
|
+
<div>Heading: ${pos.heading ?? 'N/A'}°</div>
|
|
382
|
+
<div>Altitude: ${pos.altitude ?? 'N/A'} m</div>
|
|
383
|
+
</div>`);
|
|
384
|
+
|
|
385
|
+
marker.bindPopup(html);
|
|
386
|
+
|
|
387
|
+
// Click handler -> reuse your action
|
|
388
|
+
marker.on('click', () => this.onPositionClicked(pos));
|
|
389
|
+
|
|
390
|
+
marker.addTo(this.positionsLayer);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
#isValidLatLng(lat, lng) {
|
|
394
|
+
return Number.isFinite(lat) && Number.isFinite(lng) && lat <= 90 && lat >= -90 && lng <= 180 && lng >= -180 && lat !== 0 && lng !== 0;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
#renderPositionsOnMap({ fitLast = 0, minZoom = 15, maxZoom = 18 } = {}) {
|
|
398
|
+
if (!this.leafletMapManager.map || !this.positions?.length) {
|
|
399
|
+
this.#clearPositionsLayer(false);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
this.#ensurePositionsLayer();
|
|
404
|
+
this.positionsLayer.clearLayers();
|
|
405
|
+
|
|
406
|
+
const latlngs = [];
|
|
407
|
+
this.positions.forEach((pos, i) => {
|
|
408
|
+
const lat = parseFloat(pos.latitude);
|
|
409
|
+
const lng = parseFloat(pos.longitude);
|
|
410
|
+
if (this.#isValidLatLng(lat, lng)) {
|
|
411
|
+
this.#addPositionMarker(pos, i);
|
|
412
|
+
latlngs.push([lat, lng]);
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
if (!latlngs.length) return;
|
|
417
|
+
|
|
418
|
+
// choose subset (e.g., last 5 points) to bias the view local
|
|
419
|
+
const slice = fitLast > 0 ? latlngs.slice(-fitLast) : latlngs;
|
|
420
|
+
|
|
421
|
+
// Clamp zoom to neighborhood-level
|
|
422
|
+
this.#fitNeighborhood(slice, { zoom: 16, minZoom, maxZoom, padding: [24, 24], animate: true });
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
#fitNeighborhood(latlngs, { zoom = null, minZoom = 15, maxZoom = 18, padding = [16, 16], animate = true } = {}) {
|
|
426
|
+
if (!this.leafletMapManager.map || !latlngs?.length) return;
|
|
427
|
+
|
|
428
|
+
const map = this.leafletMapManager.map;
|
|
429
|
+
|
|
430
|
+
if (latlngs.length === 1) {
|
|
431
|
+
// Single point → center on it at minZoom
|
|
432
|
+
map.setView(latlngs[0], minZoom, { animate });
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const bounds = L.latLngBounds(latlngs);
|
|
437
|
+
// Compute the zoom that would fit the bounds, then clamp it
|
|
438
|
+
// Leaflet getBoundsZoom may be (bounds, inside, padding) depending on version
|
|
439
|
+
let targetZoom = zoom;
|
|
440
|
+
if (!zoom) {
|
|
441
|
+
try {
|
|
442
|
+
// Try newer signature
|
|
443
|
+
targetZoom = map.getBoundsZoom(bounds, true, padding);
|
|
444
|
+
} catch {
|
|
445
|
+
// Fallback without padding param
|
|
446
|
+
targetZoom = map.getBoundsZoom(bounds, true);
|
|
447
|
+
}
|
|
448
|
+
targetZoom = Math.max(minZoom, Math.min(maxZoom, targetZoom));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Center on bounds center with clamped zoom
|
|
452
|
+
const center = bounds.getCenter();
|
|
453
|
+
map.setView(center, targetZoom, { animate });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
@@ -13,6 +13,8 @@ export default class MapDrawerComponent extends Component {
|
|
|
13
13
|
this.universe._createMenuItem('Vehicles', null, { icon: 'car', component: 'map/drawer/vehicle-listing' }),
|
|
14
14
|
this.universe._createMenuItem('Drivers', null, { icon: 'id-card', component: 'map/drawer/driver-listing' }),
|
|
15
15
|
this.universe._createMenuItem('Places', null, { icon: 'building', component: 'map/drawer/place-listing' }),
|
|
16
|
+
this.universe._createMenuItem('Positions', null, { icon: 'map-marker', component: 'map/drawer/position-listing' }),
|
|
17
|
+
this.universe._createMenuItem('Events', null, { icon: 'stream', component: 'map/drawer/device-event-listing' }),
|
|
16
18
|
...(isArray(registeredTabs) ? registeredTabs : []),
|
|
17
19
|
];
|
|
18
20
|
}
|
|
@@ -66,10 +66,11 @@
|
|
|
66
66
|
{{/each}}
|
|
67
67
|
|
|
68
68
|
{{#each this.vehicles as |vehicle|}}
|
|
69
|
-
<layers.marker
|
|
69
|
+
<layers.tracking-marker
|
|
70
70
|
@id={{vehicle.id}}
|
|
71
71
|
@publicId={{vehicle.public_id}}
|
|
72
72
|
@location={{point-to-coordinates vehicle.location}}
|
|
73
|
+
@rotationAngle={{vehicle.heading}}
|
|
73
74
|
@icon={{icon iconUrl=vehicle.avatar_url iconSize=(array 24 24)}}
|
|
74
75
|
@onAdd={{fn this.trigger "onVehicleAdded" vehicle}}
|
|
75
76
|
@onClick={{fn this.trigger "onVehicleClicked" vehicle}}
|
|
@@ -83,6 +84,8 @@
|
|
|
83
84
|
</div>
|
|
84
85
|
<div class="flex-1">
|
|
85
86
|
<div class="text-xs font-semibold">{{vehicle.displayName}}</div>
|
|
87
|
+
<div class="text-xs">ID: {{n-a vehicle.public_id}}</div>
|
|
88
|
+
<div class="text-xs">Serial No: {{n-a vehicle.serial_number vehicle.vin vehicle.internal_id vehicle.id}}</div>
|
|
86
89
|
<div class="text-xs">Driver: {{n-a vehicle.driver_name}}</div>
|
|
87
90
|
<div class="text-xs">Status: <span class="{{if vehicle.online 'text-green-500' 'text-red-400'}}">{{if vehicle.online "Online" "Offline"}}</span></div>
|
|
88
91
|
<div class="text-xs truncate">Pos: {{point-coordinates vehicle.location}}</div>
|
|
@@ -95,9 +98,11 @@
|
|
|
95
98
|
<div>•</div>
|
|
96
99
|
<div class="text-xs {{if vehicle.online 'text-green-500' 'text-red-400'}}">{{if vehicle.online "Online" "Offline"}}</div>
|
|
97
100
|
</div>
|
|
101
|
+
<div class="text-xs">ID: {{n-a vehicle.public_id}}</div>
|
|
102
|
+
<div class="text-xs">Serial No: {{or vehicle.serial_number vehicle.vin vehicle.internal_id vehicle.id "-"}}</div>
|
|
98
103
|
<div class="text-xs truncate"><FaIcon @icon="location-dot" @size="xs" class="mr-0.5" />{{point-coordinates vehicle.location}}</div>
|
|
99
104
|
</marker.tooltip>
|
|
100
|
-
</layers.marker>
|
|
105
|
+
</layers.tracking-marker>
|
|
101
106
|
{{/each}}
|
|
102
107
|
|
|
103
108
|
{{#each this.places as |place|}}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<Modal::Default @modalIsOpened={{@modalIsOpened}} @options={{@options}} @confirm={{@onConfirm}} @decline={{@onDecline}}>
|
|
2
|
+
<div class="modal-body-container">
|
|
3
|
+
<InputGroup @name={{t "common.select-field" field=(t "resource.device")}} @value={{@options.driver.vehicle_uuid}}>
|
|
4
|
+
<ModelSelect
|
|
5
|
+
@modelName="device"
|
|
6
|
+
@selectedModel={{@options.selectedDevice}}
|
|
7
|
+
@placeholder={{t "common.select-field" field=(t "resource.device")}}
|
|
8
|
+
@triggerClass="form-select form-input"
|
|
9
|
+
@infiniteScroll={{false}}
|
|
10
|
+
@renderInPlace={{true}}
|
|
11
|
+
@onChange={{fn (mut @options.selectedDevice)}}
|
|
12
|
+
as |model|
|
|
13
|
+
>
|
|
14
|
+
<Device::Pill @device={{model}} />
|
|
15
|
+
</ModelSelect>
|
|
16
|
+
</InputGroup>
|
|
17
|
+
</div>
|
|
18
|
+
</Modal::Default>
|