@fleetbase/fleetops-engine
Version:
Fleet & Transport Management Extension for Fleetbase
361 lines (312 loc) • 13.5 kB
JavaScript
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { getOwner } from '@ember/application';
import { debug } from '@ember/debug';
import { task } from 'ember-concurrency';
import { renderCompleted, waitForInsertedAndSized } from '@fleetbase/ember-ui/utils/dom';
import LeafletTrackingMarkerComponent from '../leaflet-tracking-marker';
import ensureLeafletDrawEditNamespace from '../../utils/leaflet-draw-namespace-guard';
const DEFAULT_CENTER = [1.31, 103.85];
const DEFAULT_ZOOM = 11;
// Match the rest of fleetops' Leaflet maps (light Carto basemap), which keeps the
// dashboard styling consistent with the operational live map. Falls back gracefully
// in dark mode via the next-leaflet-container-map theming.
const TILE_URL = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png';
const TILE_URL_DARK = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png';
const TILE_ATTRIBUTION = '© OpenStreetMap contributors © CARTO';
const RECONCILE_INTERVAL_MS = 5 * 60_000;
/**
* Real-time fleet map. Initial GET seeds driver positions; the widget then
* subscribes to `company.{uuid}` and applies driver.location_changed deltas
* directly to the markers list. Full reconciliation every 5 minutes.
*/
export default class WidgetLiveFleetComponent extends Component {
static widgetId = 'fleet-ops-live-fleet-widget';
fetch;
socket;
currentUser;
data = null;
error = null;
tileAttribution = TILE_ATTRIBUTION;
get tileUrl() {
const isDark = typeof document !== 'undefined' && document.documentElement?.dataset?.theme === 'dark';
return isDark ? TILE_URL_DARK : TILE_URL;
}
socketChannel = null;
socketActive = false;
reconcileTimer = null;
/** Watches the map container for size changes (gridstack settle, sibling widgets
* loading, theme/font swaps) and re-runs Leaflet's invalidateSize so the tile
* layer always fills the actual painted bounds, not the bounds Leaflet measured
* at first paint. */
#resizeObserver = null;
#resizeRaf = 0;
/**
* Deferred that resolves once <LeafletMap @onLoad> fires. Mirrors the
* leaflet-map-manager service's #mapSetPromise so we can synchronously
* wait for the map instance from anywhere in this component.
*/
#map = null;
#mapSetPromise = null;
#resolveMapSet = null;
constructor() {
super(...arguments);
// Important: register tracking-marker against THIS owner's ember-leaflet
// service before any <LeafletMap> in our template is constructed.
//
// The fleetops `register-leaflet-tracking-marker` instance-initializer
// registers `tracking-marker` on the fleetops ENGINE's ember-leaflet
// service. When this widget is rendered from the host dashboard route
// (e.g. console.home), LazyEngineComponent resolves the component class
// from the engine but renders it under the HOST owner — so the
// <LeafletMap> inside this template injects the host's ember-leaflet
// service, which never received the registration. The yield for
// `layers.tracking-marker` silently resolves to undefined and no marker
// ever paints. Doing the registration on whatever owner this widget
// happens to live under closes that gap.
this.#ensureTrackingMarkerRegistered();
this.#patchLeafletDrawInitHook();
this.#resetMapDeferred();
this.load.perform();
this.subscribe();
this.reconcileTimer = setInterval(() => this.load.perform(), RECONCILE_INTERVAL_MS);
}
/**
* leaflet-draw's `Edit.Marker.js` registers an init hook on `L.Marker` that
* does `if (L.Edit.Marker) { this.editing = new L.Edit.Marker(this); … }`.
* The check is unguarded — it reads `.Marker` off `L.Edit` directly. When
* leaflet-draw is loaded but the dashboard route's window.L doesn't carry
* the `Edit` namespace (load-order race in the host route, or two leaflet
* copies in the runtime caused by engine/host module duplication), every
* marker creation throws a TypeError and our tracking-marker churn during
* dashboard switching repeats it thousands of times.
*
* Stubbing `L.Edit` to `{}` keeps the leaflet-draw hook's check (`if
* (L.Edit.Marker)`) safely falsy when the namespace is missing, so it
* becomes a no-op. If leaflet-draw HAS fully loaded, `L.Edit.Marker` is
* already set and the `||` keeps it.
*
* We don't need draw/editing handlers on dashboard markers anyway — this
* widget is read-only.
*/
#patchLeafletDrawInitHook() {
ensureLeafletDrawEditNamespace();
}
/**
* Idempotent: registers the fleetops `tracking-marker` component on the
* `ember-leaflet` service belonging to this widget's owner if it isn't
* already there. Safe to call multiple times across widget instances
* because we guard on `components.find(...)` before calling
* `registerComponent` (which throws on duplicate registration).
*/
#ensureTrackingMarkerRegistered() {
const owner = getOwner(this);
const emberLeaflet = owner?.lookup?.('service:ember-leaflet');
if (!emberLeaflet) return;
const already = emberLeaflet.components?.find((c) => c.name === 'leaflet-tracking-marker' || c.as === 'tracking-marker');
if (already) return;
emberLeaflet.registerComponent('leaflet-tracking-marker', {
as: 'tracking-marker',
component: LeafletTrackingMarkerComponent,
});
}
willDestroy() {
super.willDestroy(...arguments);
this.unsubscribe();
if (this.reconcileTimer) clearInterval(this.reconcileTimer);
if (this.#resizeRaf) cancelAnimationFrame(this.#resizeRaf);
this.#resizeObserver?.disconnect();
this.#resizeObserver = null;
}
get drivers() {
return this.data?.drivers ?? [];
}
get vehicles() {
return this.data?.vehicles ?? [];
}
get activeOrderCount() {
return this.data?.active_orders?.length ?? 0;
}
/** Both drivers and vehicles are renderable map subjects — total chip count. */
get totalMarkerCount() {
return this.drivers.length + this.vehicles.length;
}
/** Center on the first available marker (driver, then vehicle), else default. */
get center() {
const first = this.drivers[0] ?? this.vehicles[0];
if (first?.lat && first?.lng) return [first.lat, first.lng];
return DEFAULT_CENTER;
}
get zoom() {
return DEFAULT_ZOOM;
}
statusColor(status) {
switch (status) {
case 'in_progress':
case 'enroute':
case 'dispatched':
return '#3485e2';
case 'completed':
return '#22c55e';
default:
return '#9ca3af';
}
}
*load() {
try {
// Wait for the LeafletMap to mount, finish loading, and have a non-zero
// container before we add markers. Without this, markers added to a
// not-yet-sized map silently no-op (the data arrives, count chip ticks
// up, but nothing paints).
yield this.ensureInteractive();
this.data = yield this.fetch.get('fleet-ops/analytics/live-fleet');
this.error = null;
} catch (e) {
this.error = e?.message ?? 'Failed to load live fleet';
}
}
/**
* Mirrors LeafletMapManagerService#ensureInteractive: waits for the
* Leaflet instance to be available + loaded, for the host DOM container
* to be inserted and sized, then forces a re-measure. Returns the map.
*/
async ensureInteractive({ timeoutMs = 8000 } = {}) {
const map = await this.#whenMapLoaded({ timeoutMs });
const getContainer = () => map?.getContainer?.() || map?._container || null;
await renderCompleted();
await waitForInsertedAndSized(getContainer, { timeoutMs });
map.invalidateSize(false);
// Subsequent size changes (gridstack settling, sibling widgets loading,
// theme/font swaps, widget resize handles) need invalidateSize() again
// or the tile layer ends up clipped to the cached bounding box.
this.#attachResizeObserver(map, getContainer());
return map;
}
/**
* Watch the Leaflet container with a ResizeObserver and re-run invalidateSize
* on any dimension change. rAF-debounced so a stream of resize callbacks during
* a gridstack drag/animation collapses into one Leaflet recompute per frame.
*/
#attachResizeObserver(map, container) {
if (!container || typeof ResizeObserver === 'undefined') return;
this.#resizeObserver?.disconnect();
this.#resizeObserver = new ResizeObserver(() => {
if (this.#resizeRaf) cancelAnimationFrame(this.#resizeRaf);
this.#resizeRaf = requestAnimationFrame(() => {
this.#resizeRaf = 0;
try {
map.invalidateSize(false);
} catch (e) {
debug(`[live-fleet] invalidateSize failed: ${e?.message ?? e}`);
}
});
});
this.#resizeObserver.observe(container);
}
/** Hook fired by <LeafletMap @onLoad>. Stores the instance + resolves the deferred. */
onMapLoad({ target: map }) {
this.#map = map;
this.#resolveMapSet?.(map);
}
/** ──────────────────────────────────────────────
* Internal map-readiness deferred (same shape as
* leaflet-map-manager.js).
* ────────────────────────────────────────────── */
#resetMapDeferred() {
this.#mapSetPromise = new Promise((resolve) => {
this.#resolveMapSet = resolve;
});
}
async #waitForMap({ timeoutMs = 8000 } = {}) {
if (this.#map) return this.#map;
let to;
try {
return await Promise.race([
this.#mapSetPromise,
new Promise((_, rej) => {
if (timeoutMs != null) {
to = setTimeout(() => rej(new Error('waitForMap timed out')), timeoutMs);
}
}),
]);
} finally {
if (to) clearTimeout(to);
}
}
async #whenMapLoaded({ timeoutMs } = {}) {
const map = await this.#waitForMap({ timeoutMs });
if (map._loaded) return map;
return new Promise((resolve, reject) => {
let t;
const onLoad = () => {
clearTimeout(t);
map.off('load', onLoad);
resolve(map);
};
map.on('load', onLoad);
if (timeoutMs != null) {
t = setTimeout(() => {
map.off('load', onLoad);
reject(new Error('whenMapLoaded timed out'));
}, timeoutMs);
}
});
}
refresh() {
this.load.perform();
}
async subscribe() {
const companyId = this.currentUser?.companyId;
if (!companyId) return;
try {
const sc = this.socket.instance();
const channel = sc.subscribe(`company.${companyId}`);
this.socketChannel = channel;
this.socketActive = true;
if (channel.state !== 'subscribed') {
await channel.subscribe();
}
for await (const msg of channel) {
if (!this.socketActive) break;
this.applyEvent(msg);
}
} catch (e) {
debug(`[live-fleet] socket error: ${e?.message ?? e}`);
}
}
async unsubscribe() {
this.socketActive = false;
try {
await this.socketChannel?.unsubscribe();
} catch (e) {
debug(`[live-fleet] unsubscribe error: ${e?.message ?? e}`);
}
this.socketChannel = null;
}
applyEvent(msg) {
if (!this.data || !msg) return;
switch (msg.event) {
case 'driver.location_changed':
this.applyLocationDelta(msg.data);
break;
case 'driver.online':
case 'driver.offline':
// Presence flips are reconciled on the next 5-min refetch.
break;
default:
return;
}
}
applyLocationDelta(data) {
if (!data?.uuid && !data?.driver_uuid) return;
const targetUuid = data.uuid ?? data.driver_uuid;
const lat = data.lat ?? data.latitude;
const lng = data.lng ?? data.longitude;
if (typeof lat !== 'number' || typeof lng !== 'number') return;
const drivers = (this.data.drivers ?? []).map((d) => (d.uuid === targetUuid ? { ...d, lat, lng, heading: data.heading ?? d.heading } : d));
this.data = { ...this.data, drivers };
}
}