@fleetbase/fleetops-engine
Version:
Fleet & Transport Management Extension for Fleetbase
553 lines (467 loc) • 17.4 kB
JavaScript
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { task } from 'ember-concurrency';
import { isArray } from '@ember/array';
import { htmlSafe } from '@ember/template';
import { startOfWeek, endOfWeek, format } from 'date-fns';
import getModelName from '@fleetbase/ember-core/utils/get-model-name';
import ensureLeafletDrawEditNamespace from '../../../utils/leaflet-draw-namespace-guard';
import { buildTrackableOption } from '../../../utils/trackable-option';
const L = window.leaflet || window.L;
export default class MapDrawerPositionListingComponent extends Component {
mapManager;
leafletMapManager;
store;
fetch;
positionPlayback;
hostRouter;
notifications;
intl;
/** Tracked properties - only what's NOT managed by service */
positions = [];
resource = null;
selectedOrder = null;
dateFilter = [format(startOfWeek(new Date(), { weekStartsOn: 1 }), 'yyyy-MM-dd'), format(endOfWeek(new Date(), { weekStartsOn: 1 }), 'yyyy-MM-dd')];
replaySpeed = '1';
positionsLayer = null;
positionOverlayIds = [];
selectedTrackableModelName = null;
/** Computed properties - read state from service */
get isReplaying() {
return this.positionPlayback.isPlaying;
}
get isPaused() {
return this.positionPlayback.isPaused;
}
get currentReplayIndex() {
return this.positionPlayback.currentIndex;
}
get trackables() {
const vehicles = this.mapManager.livemap?.vehicles ?? [];
const drivers = this.mapManager.livemap?.drivers ?? [];
return [...vehicles.map((vehicle) => buildTrackableOption(vehicle, 'vehicle')), ...drivers.map((driver) => buildTrackableOption(driver, 'driver'))];
}
get selectedTrackable() {
if (!this.resource) {
return null;
}
const resourceType = this.resourceType;
return this.trackables.find((trackable) => trackable.resource?.id === this.resource?.id && trackable.modelName === resourceType) ?? null;
}
get replayProgressWidth() {
return htmlSafe(`width: ${this.replayProgress}%;`);
}
get orderFilters() {
const params = {};
if (this.resourceType === 'vehicle') {
params.vehicle_assigned_uuid = this.resource?.id;
}
if (this.resourceType === 'driver') {
params.driver_assigned_uuid = this.resource?.id;
}
return params;
}
get resourceType() {
if (!this.resource) {
return 'resource';
}
return this.selectedTrackableModelName || getModelName(this.resource) || 'resource';
}
get hasPositions() {
return this.positions.length > 0;
}
get totalPositions() {
return this.positions.length;
}
get replayProgress() {
if (this.totalPositions === 0) {
return 0;
}
return Math.round((this.currentReplayIndex / this.totalPositions) * 100);
}
get speedOptions() {
return [
{ label: '0.5x', value: '0.5' },
{ label: '1x', value: '1' },
{ label: '2x', value: '2' },
{ label: '5x', value: '5' },
{ label: '10x', value: '10' },
{ label: '20x', value: '20' },
{ label: '30x', value: '30' },
{ label: '40x', value: '40' },
{ label: '50x', value: '50' },
{ label: '100x', value: '100' },
{ label: '80x', value: '80' },
{ label: '120x', value: '120' },
{ label: '160x', value: '160' },
{ label: '180x', value: '180' },
{ label: '200x', value: '200' },
{ label: '250x', value: '250' },
{ label: '280x', value: '280' },
{ label: '300x', value: '300' },
{ label: '350x', value: '350' },
{ label: '400x', value: '400' },
{ label: '500x', value: '500' },
{ label: '600x', value: '600' },
{ label: '1000x', value: '1000' },
];
}
/** columns */
get columns() {
return [
{
label: '#',
valuePath: 'index',
width: '80px',
cellComponent: 'table/cell/anchor',
onClick: this.onPositionClicked,
},
{
label: 'Timestamp',
valuePath: 'timestamp',
cellComponent: 'table/cell/anchor',
onClick: this.onPositionClicked,
},
{
label: 'Latitude',
valuePath: 'latitude',
cellComponent: 'table/cell/anchor',
onClick: this.onPositionClicked,
},
{
label: 'Longitude',
valuePath: 'longitude',
cellComponent: 'table/cell/anchor',
onClick: this.onPositionClicked,
},
{
label: 'Speed (km/h)',
valuePath: 'speedKmh',
},
{
label: 'Heading',
valuePath: 'heading',
},
{
label: 'Altitude (m)',
valuePath: 'altitude',
},
];
}
constructor() {
super(...arguments);
this.loadPositions.perform();
}
willDestroy() {
super.willDestroy?.();
// Clean up replay tracker
this.positionPlayback.reset();
this.#resumeViewportReload();
// Remove our layer group from the map
this.#clearPositionsLayer(true);
this.positionsLayer = null;
this.positionOverlayIds = [];
}
onResourceSelected(trackable) {
this.resource = trackable?.resource ?? null;
this.selectedTrackableModelName = trackable?.modelName ?? null;
this.loadPositions.perform();
}
onOrderSelected(order) {
this.selectedOrder = order;
this.loadPositions.perform();
}
onDateRangeChanged({ formattedDate }) {
if (isArray(formattedDate) && formattedDate.length === 2) {
this.dateFilter = formattedDate;
this.loadPositions.perform();
}
}
onSpeedChanged(speed) {
this.replaySpeed = speed;
// Update replay speed in real-time if currently playing
if (this.isReplaying) {
this.positionPlayback.setSpeed(parseFloat(speed));
}
}
startReplay() {
if (this.positions.length === 0) {
this.notifications.warning('No positions to replay');
return;
}
if (this.isReplaying && !this.isPaused) {
this.notifications.info('Replay is already running');
return;
}
// If paused, resume
if (this.isPaused) {
this.positionPlayback.play();
return;
}
// Start new replay
this.#suspendViewportReload();
this.#initializeReplay();
this.positionPlayback.play();
}
pauseReplay() {
if (!this.isReplaying) {
return;
}
this.positionPlayback.pause();
}
stopReplay() {
this.positionPlayback.stop();
this.#resumeViewportReload();
}
stepForward() {
this.#suspendViewportReload();
if (this.isReplaying) {
this.pauseReplay();
}
this.positionPlayback.stepForward(1);
}
stepBackward() {
this.#suspendViewportReload();
if (this.isReplaying) {
this.pauseReplay();
}
this.positionPlayback.stepBackward(1);
}
clearFilters() {
this.selectedOrder = null;
this.dateFilter = null;
this.loadPositions.perform();
}
onPositionClicked(position) {
if (position.latitude && position.longitude) {
this.mapManager.setCenter(position.latitude, position.longitude, 16);
}
}
focusResource(resource) {
const hasValidCoordinates = resource?.hasValidCoordinates || (Number.isFinite(resource?.latitude) && Number.isFinite(resource?.longitude));
if (hasValidCoordinates) {
this.mapManager.focusResource(resource, 18);
}
}
*loadPositions() {
if (!this.resource) return;
try {
const params = {
limit: 900,
sort: 'created_at',
subject_uuid: this.resource.id,
};
if (this.selectedOrder) {
params.order_uuid = this.selectedOrder.id;
}
if (isArray(this.dateFilter) && this.dateFilter.length === 2) {
params.created_at = this.dateFilter.join(',');
}
const positions = yield this.store.query('position', params);
this.positions = isArray(positions)
? positions.map((pos, index) => {
pos.set('index', index + 1);
return pos;
})
: [];
if (this.positions.length > 0) {
this.#renderPositionsOnMap({ fitLast: 5 });
} else if (this.resource) {
this.focusResource(this.resource);
}
// Reset replay state when positions change
this.stopReplay();
} catch (error) {
this.notifications.serverError(error);
}
}
/**
* Initialize replay tracker with current positions and settings
* This replaces the socket-based backend replay approach
*
* @private
*/
#initializeReplay() {
if (!this.resource) {
this.notifications.warning('No resource provided for replay');
return;
}
if (this.positions.length === 0) {
this.notifications.warning('No positions to replay');
return;
}
// Initialize replay tracker with positions
this.positionPlayback.initialize({
subject: this.resource,
positions: this.positions,
speed: parseFloat(this.replaySpeed),
map: this.mapManager.isGoogleMaps ? null : this.leafletMapManager.map,
callback: (data) => {
if (data.type === 'complete') {
// Replay completed
this.#resumeViewportReload();
this.notifications.success('Replay completed');
}
},
});
}
#suspendViewportReload() {
this.mapManager.livemap?.suspendViewportReload?.(`position-playback:${this.id}`);
}
#resumeViewportReload() {
this.mapManager.livemap?.resumeViewportReload?.(`position-playback:${this.id}`);
}
/** Position Rendering */
#ensurePositionsLayer() {
if (this.mapManager.isGoogleMaps) {
return true;
}
if (!this.leafletMapManager.map) return null;
if (!this.positionsLayer) {
this.positionsLayer = L.layerGroup();
this.positionsLayer.addTo(this.leafletMapManager.map);
}
return this.positionsLayer;
}
#clearPositionsLayer(removeFromMap = false) {
if (this.mapManager.isGoogleMaps) {
for (const overlayId of this.positionOverlayIds) {
this.mapManager.removeMarker(overlayId);
}
this.positionOverlayIds = [];
return;
}
if (this.positionsLayer) {
if (removeFromMap && this.leafletMapManager.map) {
this.positionsLayer.removeFrom(this.leafletMapManager.map);
} else {
try {
this.positionsLayer.clearLayers();
} catch {
// Ignore teardown race when Leaflet renderer is already gone.
}
}
}
}
#addPositionMarker(pos, index) {
const lat = parseFloat(pos.latitude);
const lng = parseFloat(pos.longitude);
if (!this.#isValidLatLng(lat, lng)) return;
if (this.mapManager.isGoogleMaps) {
const overlayId = `position-history:${this.resource?.id ?? 'resource'}:${index}`;
const dot = document.createElement('div');
dot.className = 'fleetops-position-history-dot';
dot.title = `Position ${index + 1}`;
this.mapManager.addMarker(overlayId, lat, lng, {
content: dot,
title: `Position ${index + 1}`,
onClick: () => this.onPositionClicked(pos),
});
this.positionOverlayIds = [...this.positionOverlayIds, overlayId];
return;
}
ensureLeafletDrawEditNamespace(L);
ensureLeafletDrawEditNamespace();
const marker = L.circleMarker([lat, lng], {
radius: 3,
color: '#3b82f6',
fillColor: '#3b82f6',
fillOpacity: 0.6,
});
// Popup content (mirrors your template)
const html = `<div class="text-xs">
<div><strong>Position ${index + 1}</strong></div>
<div>Time: ${pos.timestamp ?? ''}</div>
<div>Speed: ${pos.speedKmh ?? 'N/A'} km/h</div>
<div>Heading: ${pos.heading ?? 'N/A'}°</div>
<div>Altitude: ${pos.altitude ?? 'N/A'} m</div>
</div>`;
marker.bindPopup(html);
// Click handler -> reuse your action
marker.on('click', () => this.onPositionClicked(pos));
marker.addTo(this.positionsLayer);
}
#isValidLatLng(lat, lng) {
return Number.isFinite(lat) && Number.isFinite(lng) && lat <= 90 && lat >= -90 && lng <= 180 && lng >= -180 && lat !== 0 && lng !== 0;
}
#renderPositionsOnMap({ fitLast = 0, minZoom = 15, maxZoom = 18 } = {}) {
if ((!this.mapManager.isGoogleMaps && !this.leafletMapManager.map) || !this.positions?.length) {
this.#clearPositionsLayer(false);
return;
}
this.#ensurePositionsLayer();
this.#clearPositionsLayer(false);
const latlngs = [];
this.positions.forEach((pos, i) => {
const lat = parseFloat(pos.latitude);
const lng = parseFloat(pos.longitude);
if (this.#isValidLatLng(lat, lng)) {
this.#addPositionMarker(pos, i);
latlngs.push([lat, lng]);
}
});
if (!latlngs.length) return;
// choose subset (e.g., last 5 points) to bias the view local
const slice = fitLast > 0 ? latlngs.slice(-fitLast) : latlngs;
const zoomBounds = this.#getReplayViewportZoomBounds({ minZoom, maxZoom });
// Clamp zoom to neighborhood-level
this.#fitNeighborhood(slice, { zoom: 16, minZoom: zoomBounds.minZoom, maxZoom: zoomBounds.maxZoom, padding: [24, 24], animate: true });
}
#fitNeighborhood(latlngs, { zoom = null, minZoom = 15, maxZoom = 18, padding = [16, 16], animate = true } = {}) {
if (!latlngs?.length) return;
if (this.mapManager.isGoogleMaps) {
if (latlngs.length === 1) {
this.mapManager.setCenter(latlngs[0][0], latlngs[0][1], minZoom);
return;
}
const lats = latlngs.map(([lat]) => lat);
const lngs = latlngs.map(([, lng]) => lng);
this.mapManager.fitBounds(
[
[Math.min(...lats), Math.min(...lngs)],
[Math.max(...lats), Math.max(...lngs)],
],
{ paddingBottomRight: padding, animate, maxZoom, minZoom }
);
return;
}
if (!this.leafletMapManager.map) return;
const map = this.leafletMapManager.map;
if (latlngs.length === 1) {
// Single point → center on it at minZoom
map.setView(latlngs[0], minZoom, { animate });
return;
}
const bounds = L.latLngBounds(latlngs);
// Compute the zoom that would fit the bounds, then clamp it
// Leaflet getBoundsZoom may be (bounds, inside, padding) depending on version
let targetZoom = zoom;
if (!zoom) {
try {
// Try newer signature
targetZoom = map.getBoundsZoom(bounds, true, padding);
} catch {
// Fallback without padding param
targetZoom = map.getBoundsZoom(bounds, true);
}
targetZoom = Math.max(minZoom, Math.min(maxZoom, targetZoom));
}
// Center on bounds center with clamped zoom
const center = bounds.getCenter();
map.setView(center, targetZoom, { animate });
}
#getReplayViewportZoomBounds({ minZoom = 15, maxZoom = 18 } = {}) {
if (this.mapManager.isGoogleMaps) {
return {
minZoom,
maxZoom: Math.min(maxZoom, 16),
};
}
return {
minZoom,
maxZoom,
};
}
}