Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions addon/components/map/leaflet-live-map.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export default class MapLeafletLiveMapComponent extends Component {
@service intl;
@service universe;
@service('universe/menu-service') menuService;
@service geofenceEventBus;

/** properties */
id = guidFor(this);
Expand Down Expand Up @@ -57,6 +58,12 @@ export default class MapLeafletLiveMapComponent extends Component {

// Ensure we have valid coordinates on initialization
this.#updateCoordinatesFromLocation();

// Subscribe to geofence events so the live map can react to boundary crossings
this._geofenceEnteredHandler = this.#handleGeofenceEntered.bind(this);
this._geofenceExitedHandler = this.#handleGeofenceExited.bind(this);
this.universe.on('fleet-ops.geofence.entered', this._geofenceEnteredHandler);
this.universe.on('fleet-ops.geofence.exited', this._geofenceExitedHandler);
}

willDestroy() {
Expand All @@ -67,6 +74,14 @@ export default class MapLeafletLiveMapComponent extends Component {
this.universe.off('user.located', this._locationUpdateHandler);
this._locationUpdateHandler = null;
}
if (this._geofenceEnteredHandler) {
this.universe.off('fleet-ops.geofence.entered', this._geofenceEnteredHandler);
this._geofenceEnteredHandler = null;
}
if (this._geofenceExitedHandler) {
this.universe.off('fleet-ops.geofence.exited', this._geofenceExitedHandler);
this._geofenceExitedHandler = null;
}
}

@action didLoad({ target: map }) {
Expand Down Expand Up @@ -259,6 +274,62 @@ export default class MapLeafletLiveMapComponent extends Component {
return 14;
}

/**
* Handles a geofence.entered event from the GeofenceEventBus.
* Briefly highlights the geofence layer on the map to provide visual feedback.
*
* @param {Object} event - Normalised geofence event object
*/
#handleGeofenceEntered(event) {
debug(`[LiveMap] geofence.entered — driver: ${event.driverName}, geofence: ${event.geofenceName}`);
this.#flashGeofenceLayer(event.geofenceUuid, '#22c55e'); // green
}

/**
* Handles a geofence.exited event from the GeofenceEventBus.
* Briefly highlights the geofence layer on the map to provide visual feedback.
*
* @param {Object} event - Normalised geofence event object
*/
#handleGeofenceExited(event) {
debug(`[LiveMap] geofence.exited — driver: ${event.driverName}, geofence: ${event.geofenceName}`);
this.#flashGeofenceLayer(event.geofenceUuid, '#ef4444'); // red
}

/**
* Briefly changes the fill colour of a geofence polygon layer on the map
* to provide visual feedback when a driver enters or exits.
*
* @param {string} geofenceUuid - UUID of the zone or service area
* @param {string} flashColor - Hex colour to flash
*/
#flashGeofenceLayer(geofenceUuid, flashColor) {
if (!geofenceUuid || !this.map) {
return;
}

// Iterate over all Leaflet layers to find the matching geofence polygon
this.map.eachLayer((layer) => {
const model = layer._model;
if (model && model.uuid === geofenceUuid && typeof layer.setStyle === 'function') {
const originalStyle = {
color: layer.options.color,
fillColor: layer.options.fillColor,
weight: layer.options.weight,
};

// Flash to the event colour
layer.setStyle({ color: flashColor, fillColor: flashColor, weight: 3 });

// Restore original style after 2 seconds
setTimeout(() => {
if (!layer._map) return; // layer may have been removed
layer.setStyle(originalStyle);
}, 2000);
}
});
}

/**
* Handles location updates from the location service
* @param {Object} coordinates - The new coordinates
Expand Down
80 changes: 80 additions & 0 deletions addon/components/map/toolbar/geofence-events-panel.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<div class="geofence-events-panel flex flex-col h-full" ...attributes>
{{! Panel header }}
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center space-x-2">
<FaIcon @icon="map-pin" class="text-blue-500 w-4 h-4" />
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">Geofence Events</h3>
{{#if this.events.length}}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300">
{{this.events.length}}
</span>
{{/if}}
</div>
<button
type="button"
class="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
{{on "click" this.clearFeed}}
>
Clear
</button>
</div>

{{! Loading state }}
{{#if this.isLoading}}
<div class="flex items-center justify-center py-8">
<Spinner class="w-5 h-5 text-blue-500" />
<span class="ml-2 text-sm text-gray-500">Loading events…</span>
</div>

{{! Empty state }}
{{else if (eq this.events.length 0)}}
<div class="flex flex-col items-center justify-center py-10 px-4 text-center">
<FaIcon @icon="map-pin" class="w-8 h-8 text-gray-300 dark:text-gray-600 mb-3" />
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">No geofence events yet</p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
Events will appear here in real time as drivers cross zone and service area boundaries.
</p>
</div>

{{! Event feed }}
{{else}}
<div class="flex-1 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800">
{{#each this.events as |event|}}
<div class="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors {{if event.isNew 'bg-blue-50 dark:bg-blue-900/20'}}">
<div class="flex items-start justify-between space-x-2">
<div class="flex-1 min-w-0">
{{! Event type badge }}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{this.badgeColorFor event.eventType}}">
{{this.labelFor event.eventType}}
</span>

{{! Driver and geofence names }}
<p class="mt-1 text-sm text-gray-900 dark:text-white truncate">
<span class="font-medium">{{event.driverName}}</span>
<span class="text-gray-500 dark:text-gray-400 mx-1">→</span>
<span class="text-gray-700 dark:text-gray-300">{{event.geofenceName}}</span>
</p>

{{! Dwell duration (only for exited/dwelled events) }}
{{#if event.dwellMinutes}}
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
Dwell: {{event.dwellMinutes}} min
</p>
{{/if}}
</div>

{{! Timestamp }}
<div class="flex-shrink-0 text-right">
<p class="text-xs text-gray-400 dark:text-gray-500">
<TimeAgo @date={{event.occurredAt}} />
</p>
<p class="text-xs text-gray-300 dark:text-gray-600 capitalize">
{{event.geofenceType}}
</p>
</div>
</div>
</div>
{{/each}}
</div>
{{/if}}
</div>
165 changes: 165 additions & 0 deletions addon/components/map/toolbar/geofence-events-panel.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { later } from '@ember/runloop';

/**
* GeofenceEventsPanel
*
* A live map toolbar panel that displays a real-time stream of geofence
* events (entered, exited, dwelled) as they occur. Events are received
* via the WebSocket channel and displayed in a scrollable feed with
* colour-coded badges.
*
* The panel also provides a link to the full geofence event log.
*/
export default class GeofenceEventsPanelComponent extends Component {
@service store;
@service fetch;
@service socket;

/**
* Live event feed — most recent events at the top.
* Capped at 50 entries to avoid unbounded memory growth.
*
* @type {Array}
*/
@tracked events = [];

/**
* Whether the panel is currently loading historical events.
*
* @type {boolean}
*/
@tracked isLoading = false;

/**
* Maximum number of events to keep in the live feed.
*/
MAX_EVENTS = 50;

constructor() {
super(...arguments);
this.loadRecentEvents();
this.subscribeToGeofenceEvents();
}

/**
* Load the most recent geofence events from the API to populate the
* feed on initial render.
*/
@action
async loadRecentEvents() {
this.isLoading = true;
try {
const response = await this.fetch.get('geofences/events', { per_page: 20 });
if (response && response.data) {
this.events = response.data.map(this.normalizeEvent);
}
} catch (error) {
// Silently fail — the panel will populate as live events arrive
} finally {
this.isLoading = false;
}
}

/**
* Subscribe to the geofence.* WebSocket events broadcast by the server.
* Incoming events are prepended to the live feed.
*/
subscribeToGeofenceEvents() {
if (!this.socket) {
return;
}

const channelId = this.args.channelId;
if (!channelId) {
return;
}

// Listen for all three geofence event types
['geofence.entered', 'geofence.exited', 'geofence.dwelled'].forEach((eventType) => {
this.socket.listen(channelId, eventType, (data) => {
this.onGeofenceEvent(data);
});
});
}

/**
* Handle an incoming geofence WebSocket event.
*
* @param {Object} data - The event payload from the server
*/
@action
onGeofenceEvent(data) {
const event = this.normalizeEvent(data);

// Prepend to the feed and cap at MAX_EVENTS
this.events = [event, ...this.events].slice(0, this.MAX_EVENTS);

// Flash the new event row briefly to draw attention
later(() => {
event.isNew = false;
}, 3000);
}

/**
* Normalise a raw event payload into a display-friendly object.
*
* @param {Object} raw
* @returns {Object}
*/
normalizeEvent(raw) {
return {
id: raw.id ?? raw.uuid ?? Math.random().toString(36),
eventType: raw.event_type,
occurredAt: raw.occurred_at,
driverName: raw.driver?.name ?? 'Unknown Driver',
geofenceName: raw.geofence?.name ?? 'Unknown Geofence',
geofenceType: raw.geofence?.type ?? 'zone',
dwellMinutes: raw.dwell_duration_minutes ?? null,
isNew: true,
};
}

/**
* Returns a Tailwind CSS badge colour class for the given event type.
*
* @param {string} eventType
* @returns {string}
*/
@action
badgeColorFor(eventType) {
const colors = {
'geofence.entered': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
'geofence.exited': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300',
'geofence.dwelled': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300',
};
return colors[eventType] ?? 'bg-gray-100 text-gray-800';
}

/**
* Returns a human-readable label for the given event type.
*
* @param {string} eventType
* @returns {string}
*/
@action
labelFor(eventType) {
const labels = {
'geofence.entered': 'Entered',
'geofence.exited': 'Exited',
'geofence.dwelled': 'Dwelled',
};
return labels[eventType] ?? eventType;
}

/**
* Clear the live feed.
*/
@action
clearFeed() {
this.events = [];
}
}
Loading
Loading