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
150 changes: 150 additions & 0 deletions addon/components/map/leaflet-live-map.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import { singularize, pluralize } from 'ember-inflector';
import { all } from 'rsvp';
import { task } from 'ember-concurrency';
import getModelName from '@fleetbase/ember-core/utils/get-model-name';
import { colorForId, darkenColor, routeStyleForStatus } from '../../utils/route-colors';

const L = window.leaflet || window.L;

export default class MapLeafletLiveMapComponent extends Component {
@service leafletMapManager;
Expand Down Expand Up @@ -46,6 +49,9 @@ export default class MapLeafletLiveMapComponent extends Component {
@tracked vehicles = [];
@tracked places = [];

/** Internal map of route id -> L.LayerGroup for live route polylines */
_liveRouteLayerGroups = new Map();

constructor() {
super(...arguments);

Expand All @@ -67,6 +73,9 @@ export default class MapLeafletLiveMapComponent extends Component {
this.universe.off('user.located', this._locationUpdateHandler);
this._locationUpdateHandler = null;
}

// Remove all live route polyline layers from the map
this.#clearLiveRouteLayerGroups();
}

@action didLoad({ target: map }) {
Expand Down Expand Up @@ -183,6 +192,9 @@ export default class MapLeafletLiveMapComponent extends Component {
this.#createMapContextMenu(this.map);
this.trigger('onLoaded', { map: this.map, data });
this.ready = true;

// Render color-coded polylines for all loaded routes
this.#renderLiveRoutes(this.routes);
} catch (err) {
debug('Failed to load live map: ' + err.message);
}
Expand Down Expand Up @@ -545,6 +557,144 @@ export default class MapLeafletLiveMapComponent extends Component {
return 103.8864;
}

/**
* Render color-coded, offset polylines on the live map for all active routes.
*
* Each route is assigned a deterministic color from the palette (derived from
* the order public_id) and drawn as a two-layer cased polyline. When multiple
* routes share the same road segments, alternating pixel offsets are applied
* so that each route remains visually distinct.
*
* Route geometry is sourced from `route.details.coordinates` (OSRM format) or
* `route.details.geometry.coordinates` (GeoJSON LineString format).
*
* @param {Array} routes - Array of route model objects from the live API
*/
#renderLiveRoutes(routes) {
if (!this.map || !isArray(routes) || routes.length === 0) return;

// Clear any previously rendered route layers before re-rendering
this.#clearLiveRouteLayerGroups();

// Pixel offsets cycle through to visually separate overlapping routes.
// Values are in CSS pixels; alternating left/right keeps routes balanced.
const PIXEL_OFFSETS = [0, -4, 4, -8, 8, -12, 12];

routes.forEach((route, index) => {
// Derive a stable color from the order public_id
const orderId = route.get ? route.get('order.public_id') || route.get('public_id') : route.order_public_id || route.public_id || String(index);
const status = route.get ? route.get('order.status') || route.get('status') : route.order_status || route.status || 'dispatched';
const routeColor = colorForId(orderId);
const lineStyles = routeStyleForStatus(status, routeColor);

// Extract geometry coordinates from the route details JSON
const coordinates = this.#extractRouteCoordinates(route);
if (!coordinates || coordinates.length < 2) return;

// Convert [lng, lat] pairs (OSRM/GeoJSON) to Leaflet [lat, lng] pairs
const latLngs = coordinates.map(([lng, lat]) => [lat, lng]);

// Apply a pixel offset to visually separate overlapping routes.
// We use a CSS transform on the SVG path via a custom pane approach,
// or simply shift by varying the weight for a layered visual effect.
const pixelOffset = PIXEL_OFFSETS[index % PIXEL_OFFSETS.length];

const group = L.layerGroup().addTo(this.map);

// Build the tooltip content for this route
const driverName = route.get ? route.get('driver_assigned.name') || route.get('driver_name') : route.driver_name || 'Unassigned';
const orderPublicId = route.get ? route.get('order.public_id') || route.get('public_id') : route.order_public_id || route.public_id || '—';
const tooltipContent = `<div class="fleetops-route-tooltip">
<div class="fleetops-route-tooltip__id">${orderPublicId}</div>
<div class="fleetops-route-tooltip__driver">Driver: ${driverName}</div>
<div class="fleetops-route-tooltip__status">Status: ${status}</div>
</div>`;

// Draw each style layer (casing + main line) as separate polylines
lineStyles.forEach((styleOptions, styleIndex) => {
// For overlapping routes, nudge weight slightly per offset index
// so routes at the same pixel still show through each other
const adjustedOptions = {
...styleOptions,
weight: (styleOptions.weight || 5) + pixelOffset * 0.15,
};

const polyline = L.polyline(latLngs, adjustedOptions);

// Only bind the interactive tooltip to the topmost (last) style layer
if (styleIndex === lineStyles.length - 1) {
polyline.bindTooltip(tooltipContent, {
sticky: true,
className: 'fleetops-route-tooltip-wrapper',
});
}

polyline.addTo(group);
});

// Store the group so we can remove it on refresh or destroy
const routeKey = route.id || orderId || String(index);
this._liveRouteLayerGroups.set(routeKey, group);
});
}

/**
* Extract an array of [lng, lat] coordinate pairs from a route model's
* `details` JSON field. Handles both OSRM and GeoJSON LineString formats.
*
* @param {Object} route - Route model
* @returns {Array|null} Array of [lng, lat] pairs, or null if not available
*/
#extractRouteCoordinates(route) {
let details;
try {
details = route.get ? route.get('details') : route.details;
if (typeof details === 'string') {
details = JSON.parse(details);
}
} catch (_) {
return null;
}

if (!details) return null;

// OSRM table format: details.coordinates = [[lng, lat], ...]
if (isArray(details.coordinates) && details.coordinates.length >= 2) {
return details.coordinates;
}

// GeoJSON LineString format: details.geometry.coordinates = [[lng, lat], ...]
if (details.geometry && isArray(details.geometry.coordinates) && details.geometry.coordinates.length >= 2) {
return details.geometry.coordinates;
}

// OSRM route response format: details.routes[0].geometry.coordinates
if (isArray(details.routes) && details.routes.length > 0) {
const firstRoute = details.routes[0];
if (firstRoute.geometry && isArray(firstRoute.geometry.coordinates)) {
return firstRoute.geometry.coordinates;
}
}

return null;
}

/**
* Remove all live route polyline LayerGroups from the map and clear the registry.
*/
#clearLiveRouteLayerGroups() {
this._liveRouteLayerGroups.forEach((group) => {
try {
if (this.map) {
this.map.removeLayer(group);
} else {
group.remove();
}
} catch (_) {}
});
this._liveRouteLayerGroups.clear();
}

#changeTileSource(source) {
switch (source) {
case 'dark':
Expand Down
9 changes: 9 additions & 0 deletions addon/components/order/form/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { action, get } from '@ember/object';
import { task } from 'ember-concurrency';
import { colorForId } from '../../../utils/route-colors';

export default class OrderFormRouteComponent extends Component {
@service leafletMapManager;
Expand Down Expand Up @@ -131,7 +132,15 @@ export default class OrderFormRouteComponent extends Component {
@action async previewRoute() {
if (!this.coordinates.length) return;

const order = this.args.resource;
const orderId = order.public_id;
const status = order.status || 'pending';

const routingControl = await this.leafletMapManager.replaceRoutingControl(this.coordinates, this.routingControl, {
orderId,
status,
places: this.places,
color: colorForId(orderId || 'new-order'),
onRouteFound: (route) => this.setRoute(route),
removeOptions: {
filter: (layer) => layer.record_id === this.args.resource.driver_assigned?.id,
Expand Down
39 changes: 37 additions & 2 deletions addon/controllers/operations/orders/index/details.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { isArray } from '@ember/array';
import { task } from 'ember-concurrency';
import { colorForId } from '../../../../utils/route-colors';

export default class OperationsOrdersIndexDetailsController extends Controller {
@controller('operations.orders.index') index;
Expand Down Expand Up @@ -106,10 +107,44 @@ export default class OperationsOrdersIndexDetailsController extends Controller {
yield this.hostRouter.refresh();
}

/**
* Build the routing options object for this order, supplying the deterministic
* route color, order status, and place models for enhanced waypoint markers.
*
* The `places` array is parallel to `routeWaypoints` — index 0 is the pickup
* place, the last index is the dropoff, and any intermediate entries are stops.
* This data is passed through to `addRoutingControl` so each waypoint marker
* can display a rich popup with the place name, address, ETA, and status.
*/
get routingOptions() {
const order = this.model;
const orderId = order.public_id;
const status = order.status || 'dispatched';

// Collect Place models from the payload waypoints (each waypoint has a `place`)
let places = [];
try {
const payload = order.get ? order.get('payload') : order.payload;
const waypoints = payload?.get ? payload.get('waypoints') : payload?.waypoints;
if (isArray(waypoints)) {
places = waypoints.map((wp) => (wp.get ? wp.get('place') : wp.place)).filter(Boolean);
}
} catch (_) {
// Gracefully degrade — markers will render without popup content
}

return {
orderId,
status,
places,
color: colorForId(orderId),
};
}

@action async setup() {
// Change to map layout and display order route
this.index.changeLayout('map');
this.routingControl = await this.leafletMapManager.addRoutingControl(this.model.routeWaypoints);
this.routingControl = await this.leafletMapManager.addRoutingControl(this.model.routeWaypoints, this.routingOptions);

// Hide sidebar
this.sidebar.hideNow();
Expand All @@ -125,7 +160,7 @@ export default class OperationsOrdersIndexDetailsController extends Controller {
async (_msg, { reloadable }) => {
if (reloadable) {
await this.hostRouter.refresh();
this.leafletMapManager.replaceRoutingControl(this.model.routeWaypoints, this.routingControl);
this.leafletMapManager.replaceRoutingControl(this.model.routeWaypoints, this.routingControl, this.routingOptions);
}
},
{ debounceMs: 250 }
Expand Down
97 changes: 88 additions & 9 deletions addon/services/leaflet-map-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import { isArray } from '@ember/array';
import { renderCompleted, waitForInsertedAndSized } from '@fleetbase/ember-ui/utils/dom';
import { Control as RoutingControl } from '@fleetbase/leaflet-routing-machine';
import { getLayerById, findLayer, flyToLayer } from '../utils/leaflet';
import { colorForId, routeStyleForStatus, waypointIconHtml } from '../utils/route-colors';
import isUuid from '@fleetbase/ember-core/utils/is-uuid';

const L = window.leaflet || window.L;

export default class LeafletMapManagerService extends Service {
@service leafletRoutingControl;
@service notifications;
Expand Down Expand Up @@ -221,6 +224,19 @@ export default class LeafletMapManagerService extends Service {

/** routing methods */
/* eslint-disable no-empty */

/**
* Add a Leaflet Routing Machine control to the map for the given waypoints.
*
* Enhanced options:
* @param {Array} waypoints - Array of [lat, lng] pairs or L.LatLng objects
* @param {Object} options
* @param {string} options.color - Explicit hex color for the route line
* @param {string} options.orderId - Order public_id used for deterministic color derivation
* @param {string} options.status - Order status for status-based line styling
* @param {Array} options.places - Place models parallel to waypoints for popup content
* @param {Function} options.onRouteFound - Callback invoked with the first found route object
*/
async addRoutingControl(waypoints, options = {}) {
if (!isArray(waypoints) || waypoints.length === 0) return;

Expand All @@ -233,21 +249,84 @@ export default class LeafletMapManagerService extends Service {
const { router, formatter } = this.leafletRoutingControl.get(routingService);
const tag = `routing:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 8)}`;

// Resolve route color: explicit > derived from orderId > derived from tag
const routeColor = options.color || colorForId(options.orderId || tag);

// Resolve line styles based on order status (falls back to active/dispatched style)
const lineStyles = routeStyleForStatus(options.status || 'dispatched', routeColor);

// Capture places array for rich popup content on waypoint markers
const places = isArray(options.places) ? options.places : [];

const routingControl = new RoutingControl({
router,
formatter,
waypoints,
markerOptions: {
icon: L.icon({
iconUrl: '/assets/images/marker-icon.png',
iconRetinaUrl: '/assets/images/marker-icon-2x.png',
shadowUrl: '/assets/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
}),
},
alternativeClassName: 'hidden',
addWaypoints: false,

// ── Color-coded, cased route line ──────────────────────────────────
lineOptions: {
styles: lineStyles,
missingRouteTolerance: 0,
},

// ── Enhanced waypoint markers ──────────────────────────────────────
createMarker: (i, waypoint, n) => {
const isPickup = i === 0;
const isDropoff = i === n - 1;

// Label: "P" for pickup, "D" for dropoff, sequential number for via-stops
const label = isPickup ? 'P' : isDropoff ? 'D' : String(i);

// Color: green pickup, red dropoff, route color for via-stops
const markerColor = isPickup ? '#22C55E' : isDropoff ? '#EF4444' : routeColor;

// Human-readable title for tooltip and accessibility
const title = isPickup ? 'Pickup' : isDropoff ? 'Dropoff' : `Stop ${i}`;

const icon = L.divIcon({
className: 'fleetops-waypoint-marker',
html: waypointIconHtml(label, markerColor),
iconSize: [32, 32],
iconAnchor: [16, 16],
popupAnchor: [0, -20],
});

const marker = L.marker(waypoint.latLng, { icon, title });

// Bind a lightweight tooltip (always visible on hover)
marker.bindTooltip(title, {
direction: 'top',
offset: [0, -20],
className: 'fleetops-waypoint-tooltip',
});

// Bind a rich popup if place data is available
const place = places[i];
if (place) {
const placeName = place.get ? place.get('name') || place.get('address') : place.name || place.address || '';
const placeAddress = place.get ? place.get('address') : place.address || '';
const placeEta = place.get ? place.get('eta') : place.eta || '';
const placeStatus = place.get ? place.get('status') : place.status || '';

marker.bindPopup(
`<div class="fleetops-waypoint-popup">
<div class="fleetops-waypoint-popup__header">
<span class="fleetops-waypoint-popup__badge" style="background:${markerColor}">${label}</span>
<strong>${title}</strong>
</div>
${placeName ? `<div class="fleetops-waypoint-popup__name">${placeName}</div>` : ''}
${placeAddress ? `<div class="fleetops-waypoint-popup__address">${placeAddress}</div>` : ''}
${placeEta ? `<div class="fleetops-waypoint-popup__meta">ETA: <strong>${placeEta}</strong></div>` : ''}
${placeStatus ? `<div class="fleetops-waypoint-popup__meta">Status: <strong>${placeStatus}</strong></div>` : ''}
</div>`,
{ maxWidth: 240, className: 'fleetops-waypoint-popup-wrapper' }
);
}

return marker;
},
}).addTo(map);

// Track routing control
Expand Down
Loading
Loading