Skip to content

Commit 3dfea99

Browse files
authored
Merge pull request #219 from fleetbase/feature/active-geofencing
feat: Implement Active Geofencing — Entry, Exit & Dwell Events
2 parents 9d97e90 + 579c654 commit 3dfea99

39 files changed

Lines changed: 3290 additions & 17 deletions

addon/components/map/drawer.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export default class MapDrawerComponent extends Component {
1515
this.universe._createMenuItem('Drivers', null, { icon: 'id-card', component: 'map/drawer/driver-listing' }),
1616
this.universe._createMenuItem('Places', null, { icon: 'building', component: 'map/drawer/place-listing' }),
1717
this.universe._createMenuItem('Positions', null, { icon: 'map-marker', component: 'map/drawer/position-listing' }),
18+
this.universe._createMenuItem('Geofences', null, { icon: 'map-pin', component: 'map/drawer/geofence-event-listing' }),
1819
this.universe._createMenuItem('Events', null, { icon: 'stream', component: 'map/drawer/device-event-listing' }),
1920
...(isArray(registeredTabs) ? registeredTabs : []),
2021
];
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
<div class="geofence-events-panel flex flex-col h-full" ...attributes>
2+
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-200 dark:border-gray-700">
3+
<div class="flex items-center space-x-2">
4+
<FaIcon @icon="map-pin" class="text-blue-500 w-4 h-4" />
5+
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">Geofence Events</h3>
6+
{{#if this.events.length}}
7+
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300">
8+
{{this.events.length}}
9+
</span>
10+
{{/if}}
11+
</div>
12+
<button
13+
type="button"
14+
class="text-[11px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
15+
{{on "click" this.clearFeed}}
16+
>
17+
Clear
18+
</button>
19+
</div>
20+
21+
{{#if this.isLoading}}
22+
<div class="flex items-center justify-center py-8">
23+
<Spinner class="w-5 h-5 text-blue-500" />
24+
<span class="ml-2 text-sm text-gray-500">Loading events…</span>
25+
</div>
26+
{{else if (eq this.events.length 0)}}
27+
<div class="flex flex-col items-center justify-center py-10 px-4 text-center">
28+
<FaIcon @icon="map-pin" class="w-8 h-8 text-gray-300 dark:text-gray-600 mb-3" />
29+
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">No geofence events yet</p>
30+
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
31+
Events will appear here in real time as drivers and vehicles cross zone and service area boundaries.
32+
</p>
33+
</div>
34+
{{else}}
35+
<div class="flex-1 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800">
36+
{{#each this.events as |event|}}
37+
<div class="px-4 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors {{if event.isNew 'bg-blue-50 dark:bg-blue-900/10'}}">
38+
<div class="flex items-start gap-2.5">
39+
<div class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-300">
40+
<FaIcon @icon={{this.iconFor event.eventType}} @size="xs" />
41+
</div>
42+
43+
<div class="min-w-0 flex-1">
44+
<div class="flex items-start justify-between gap-3">
45+
<div class="min-w-0 flex-1">
46+
<div class="flex items-center gap-2 text-[11px] leading-4">
47+
<span class="inline-flex items-center rounded px-1.5 py-0.5 font-medium {{this.badgeColorFor event.eventType}}">
48+
{{this.labelFor event.eventType}}
49+
</span>
50+
<span class="inline-flex items-center gap-1 text-gray-400 dark:text-gray-500">
51+
<FaIcon @icon={{this.subjectIconFor event.subjectType}} @size="xs" />
52+
<span class="capitalize">{{event.subjectType}}</span>
53+
</span>
54+
<span class="text-gray-300 dark:text-gray-600">•</span>
55+
<span class="text-gray-500 dark:text-gray-400">{{this.geofenceTypeLabel event.geofenceType}}</span>
56+
</div>
57+
58+
<div class="mt-1 flex items-center gap-1.5 text-sm leading-5">
59+
<span class="truncate font-medium text-gray-900 dark:text-white">{{event.subjectName}}</span>
60+
<FaIcon @icon="arrow-right" @size="xs" class="text-gray-300 dark:text-gray-600" />
61+
<span class="truncate text-gray-600 dark:text-gray-300">{{event.geofenceName}}</span>
62+
</div>
63+
64+
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] leading-4 text-gray-500 dark:text-gray-400">
65+
{{#if event.driverName}}
66+
{{#unless (eq event.subjectType "driver")}}
67+
<span class="inline-flex items-center gap-1">
68+
<FaIcon @icon="id-card" @size="xs" class="text-gray-400" />
69+
{{event.driverName}}
70+
</span>
71+
{{/unless}}
72+
{{/if}}
73+
74+
{{#if event.vehiclePlate}}
75+
<span class="inline-flex items-center gap-1">
76+
<FaIcon @icon="car" @size="xs" class="text-gray-400" />
77+
{{event.vehiclePlate}}
78+
</span>
79+
{{/if}}
80+
81+
{{#if event.orderPublicId}}
82+
<span class="inline-flex items-center gap-1">
83+
<FaIcon @icon="hashtag" @size="xs" class="text-gray-400" />
84+
{{event.orderPublicId}}
85+
</span>
86+
{{/if}}
87+
88+
{{#if event.dwellMinutes}}
89+
<span class="inline-flex items-center gap-1">
90+
<FaIcon @icon="clock" @size="xs" class="text-gray-400" />
91+
{{event.dwellMinutes}} min
92+
</span>
93+
{{/if}}
94+
95+
{{#if event.latitude}}
96+
{{#if event.longitude}}
97+
<span class="inline-flex items-center gap-1">
98+
<FaIcon @icon="location-dot" @size="xs" class="text-gray-400" />
99+
{{event.latitude}}, {{event.longitude}}
100+
</span>
101+
{{/if}}
102+
{{/if}}
103+
</div>
104+
</div>
105+
106+
<div class="flex-shrink-0 text-right">
107+
<div class="text-[11px] leading-4 text-gray-500 dark:text-gray-400">
108+
{{format-date-fns event.occurredAt "dd MMM, HH:mm"}}
109+
</div>
110+
{{#if event.isNew}}
111+
<div class="mt-1 text-[10px] font-medium uppercase tracking-wide text-blue-500 dark:text-blue-300">
112+
Live
113+
</div>
114+
{{/if}}
115+
</div>
116+
</div>
117+
</div>
118+
</div>
119+
</div>
120+
{{/each}}
121+
</div>
122+
{{/if}}
123+
</div>
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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+
6+
export default class MapDrawerGeofenceEventListingComponent extends Component {
7+
@service fetch;
8+
@service geofenceEventBus;
9+
@service currentUser;
10+
11+
@tracked isLoading = false;
12+
13+
constructor() {
14+
super(...arguments);
15+
this.geofenceEventBus.subscribe(this.currentUser.companyId);
16+
this.loadRecentEvents();
17+
}
18+
19+
get events() {
20+
return this.geofenceEventBus.events;
21+
}
22+
23+
@action
24+
async loadRecentEvents() {
25+
this.isLoading = true;
26+
27+
try {
28+
const response = await this.fetch.get('geofences/events', { per_page: 20 });
29+
30+
if (response?.data) {
31+
this.geofenceEventBus.seedEvents(response.data.map((event) => this.geofenceEventBus.normalizeEvent(event.event_type, event)));
32+
}
33+
} catch (error) {
34+
// The live socket feed will still populate events if history loading fails.
35+
} finally {
36+
this.isLoading = false;
37+
}
38+
}
39+
40+
@action
41+
badgeColorFor(eventType) {
42+
const colors = {
43+
'geofence.entered': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
44+
'geofence.exited': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300',
45+
'geofence.dwelled': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300',
46+
};
47+
48+
return colors[eventType] ?? 'bg-gray-100 text-gray-800';
49+
}
50+
51+
@action
52+
labelFor(eventType) {
53+
const labels = {
54+
'geofence.entered': 'Entered',
55+
'geofence.exited': 'Exited',
56+
'geofence.dwelled': 'Dwelled',
57+
};
58+
59+
return labels[eventType] ?? eventType;
60+
}
61+
62+
@action
63+
iconFor(eventType) {
64+
const icons = {
65+
'geofence.entered': 'arrow-right-to-bracket',
66+
'geofence.exited': 'arrow-right-from-bracket',
67+
'geofence.dwelled': 'clock',
68+
};
69+
70+
return icons[eventType] ?? 'map-pin';
71+
}
72+
73+
@action
74+
subjectIconFor(subjectType) {
75+
return subjectType === 'vehicle' ? 'car' : 'id-card';
76+
}
77+
78+
@action
79+
geofenceTypeLabel(type) {
80+
if (type === 'service_area') {
81+
return 'Service Area';
82+
}
83+
84+
if (type === 'zone') {
85+
return 'Zone';
86+
}
87+
88+
return type ?? 'Geofence';
89+
}
90+
91+
@action
92+
hasSecondaryMeta(event) {
93+
return Boolean(event.vehiclePlate || event.orderPublicId || event.dwellMinutes || event.latitude || event.longitude);
94+
}
95+
96+
@action
97+
clearFeed() {
98+
this.geofenceEventBus.clearFeed();
99+
}
100+
}

addon/components/map/leaflet-live-map.js

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export default class MapLeafletLiveMapComponent extends Component {
2929
@service intl;
3030
@service universe;
3131
@service('universe/menu-service') menuService;
32+
@service geofenceEventBus;
33+
@service currentUser;
3234

3335
/** properties */
3436
id = guidFor(this);
@@ -57,6 +59,19 @@ export default class MapLeafletLiveMapComponent extends Component {
5759

5860
// Ensure we have valid coordinates on initialization
5961
this.#updateCoordinatesFromLocation();
62+
63+
if (this.currentUser.companyId) {
64+
this.geofenceEventBus.subscribe(this.currentUser.companyId);
65+
} else {
66+
this._currentUserLoadedHandler = () => this.geofenceEventBus.subscribe(this.currentUser.companyId);
67+
this.currentUser.on('user.loaded', this._currentUserLoadedHandler);
68+
}
69+
70+
// Subscribe to geofence events so the live map can react to boundary crossings
71+
this._geofenceEnteredHandler = this.#handleGeofenceEntered.bind(this);
72+
this._geofenceExitedHandler = this.#handleGeofenceExited.bind(this);
73+
this.universe.on('fleet-ops.geofence.entered', this._geofenceEnteredHandler);
74+
this.universe.on('fleet-ops.geofence.exited', this._geofenceExitedHandler);
6075
}
6176

6277
willDestroy() {
@@ -67,6 +82,18 @@ export default class MapLeafletLiveMapComponent extends Component {
6782
this.universe.off('user.located', this._locationUpdateHandler);
6883
this._locationUpdateHandler = null;
6984
}
85+
if (this._currentUserLoadedHandler) {
86+
this.currentUser.off('user.loaded', this._currentUserLoadedHandler);
87+
this._currentUserLoadedHandler = null;
88+
}
89+
if (this._geofenceEnteredHandler) {
90+
this.universe.off('fleet-ops.geofence.entered', this._geofenceEnteredHandler);
91+
this._geofenceEnteredHandler = null;
92+
}
93+
if (this._geofenceExitedHandler) {
94+
this.universe.off('fleet-ops.geofence.exited', this._geofenceExitedHandler);
95+
this._geofenceExitedHandler = null;
96+
}
7097
}
7198

7299
@action didLoad({ target: map }) {
@@ -259,6 +286,62 @@ export default class MapLeafletLiveMapComponent extends Component {
259286
return 14;
260287
}
261288

289+
/**
290+
* Handles a geofence.entered event from the GeofenceEventBus.
291+
* Briefly highlights the geofence layer on the map to provide visual feedback.
292+
*
293+
* @param {Object} event - Normalised geofence event object
294+
*/
295+
#handleGeofenceEntered(event) {
296+
debug(`[LiveMap] geofence.entered — driver: ${event.driverName}, geofence: ${event.geofenceName}`);
297+
this.#flashGeofenceLayer(event.geofenceUuid, '#22c55e'); // green
298+
}
299+
300+
/**
301+
* Handles a geofence.exited event from the GeofenceEventBus.
302+
* Briefly highlights the geofence layer on the map to provide visual feedback.
303+
*
304+
* @param {Object} event - Normalised geofence event object
305+
*/
306+
#handleGeofenceExited(event) {
307+
debug(`[LiveMap] geofence.exited — driver: ${event.driverName}, geofence: ${event.geofenceName}`);
308+
this.#flashGeofenceLayer(event.geofenceUuid, '#ef4444'); // red
309+
}
310+
311+
/**
312+
* Briefly changes the fill colour of a geofence polygon layer on the map
313+
* to provide visual feedback when a driver enters or exits.
314+
*
315+
* @param {string} geofenceUuid - UUID of the zone or service area
316+
* @param {string} flashColor - Hex colour to flash
317+
*/
318+
#flashGeofenceLayer(geofenceUuid, flashColor) {
319+
if (!geofenceUuid || !this.map) {
320+
return;
321+
}
322+
323+
// Iterate over all Leaflet layers to find the matching geofence polygon
324+
this.map.eachLayer((layer) => {
325+
const model = layer._model;
326+
if (model && model.uuid === geofenceUuid && typeof layer.setStyle === 'function') {
327+
const originalStyle = {
328+
color: layer.options.color,
329+
fillColor: layer.options.fillColor,
330+
weight: layer.options.weight,
331+
};
332+
333+
// Flash to the event colour
334+
layer.setStyle({ color: flashColor, fillColor: flashColor, weight: 3 });
335+
336+
// Restore original style after 2 seconds
337+
setTimeout(() => {
338+
if (!layer._map) return; // layer may have been removed
339+
layer.setStyle(originalStyle);
340+
}, 2000);
341+
}
342+
});
343+
}
344+
262345
/**
263346
* Handles location updates from the location service
264347
* @param {Object} coordinates - The new coordinates

0 commit comments

Comments
 (0)