diff --git a/addon/components/map/leaflet-live-map.js b/addon/components/map/leaflet-live-map.js
index 7ae2a21b0..b897f99f2 100644
--- a/addon/components/map/leaflet-live-map.js
+++ b/addon/components/map/leaflet-live-map.js
@@ -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;
@@ -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);
@@ -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 }) {
@@ -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);
}
@@ -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 = `
`;
+
+ // 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':
diff --git a/addon/components/order/form/route.js b/addon/components/order/form/route.js
index 7d8dcd021..d49c487b7 100644
--- a/addon/components/order/form/route.js
+++ b/addon/components/order/form/route.js
@@ -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;
@@ -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,
diff --git a/addon/controllers/operations/orders/index/details.js b/addon/controllers/operations/orders/index/details.js
index 92596e3ac..ba1aa7dcd 100644
--- a/addon/controllers/operations/orders/index/details.js
+++ b/addon/controllers/operations/orders/index/details.js
@@ -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;
@@ -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();
@@ -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 }
diff --git a/addon/services/leaflet-map-manager.js b/addon/services/leaflet-map-manager.js
index 4fe067c46..d49eb63f5 100644
--- a/addon/services/leaflet-map-manager.js
+++ b/addon/services/leaflet-map-manager.js
@@ -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;
@@ -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;
@@ -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(
+ ``,
+ { maxWidth: 240, className: 'fleetops-waypoint-popup-wrapper' }
+ );
+ }
+
+ return marker;
+ },
}).addTo(map);
// Track routing control
diff --git a/addon/styles/fleetops-engine.css b/addon/styles/fleetops-engine.css
index e26c6198b..a3985d66b 100644
--- a/addon/styles/fleetops-engine.css
+++ b/addon/styles/fleetops-engine.css
@@ -1851,3 +1851,176 @@ body[data-theme='dark'] .positions-replay-component .metric-card:hover {
height: 40px;
flex-shrink: 0;
}
+
+/* ============================================================
+ Route Visualization – Waypoint Markers
+ ============================================================ */
+
+/**
+ * Base reset for the Leaflet divIcon container.
+ * Leaflet adds its own background/border by default; we clear those
+ * so our custom HTML circle renders cleanly.
+ */
+.fleetops-waypoint-marker {
+ background: transparent !important;
+ border: none !important;
+}
+
+/**
+ * Tooltip shown on hover over a waypoint marker.
+ * Minimal padding, small font, semi-transparent dark background.
+ */
+.fleetops-waypoint-tooltip {
+ background: rgba(15, 23, 42, 0.88) !important;
+ border: none !important;
+ border-radius: 4px !important;
+ color: #f8fafc !important;
+ font-size: 11px !important;
+ font-weight: 600 !important;
+ padding: 3px 7px !important;
+ white-space: nowrap;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35) !important;
+}
+
+.fleetops-waypoint-tooltip::before {
+ border-top-color: rgba(15, 23, 42, 0.88) !important;
+}
+
+/* ============================================================
+ Route Visualization – Waypoint Popup
+ ============================================================ */
+
+/**
+ * Override Leaflet's default popup wrapper to use our design system.
+ */
+.fleetops-waypoint-popup-wrapper .leaflet-popup-content-wrapper {
+ border-radius: 6px !important;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2) !important;
+ padding: 0 !important;
+ overflow: hidden;
+}
+
+.fleetops-waypoint-popup-wrapper .leaflet-popup-content {
+ margin: 0 !important;
+ width: auto !important;
+ min-width: 180px;
+}
+
+.fleetops-waypoint-popup-wrapper .leaflet-popup-tip-container {
+ margin-top: -1px;
+}
+
+/**
+ * Inner popup layout.
+ */
+.fleetops-waypoint-popup {
+ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
+ font-size: 12px;
+ line-height: 1.5;
+ color: #1e293b;
+ padding: 10px 12px;
+}
+
+.fleetops-waypoint-popup__header {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ margin-bottom: 6px;
+ font-size: 13px;
+ font-weight: 700;
+ color: #0f172a;
+}
+
+.fleetops-waypoint-popup__badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ color: #fff;
+ font-size: 11px;
+ font-weight: 700;
+ flex-shrink: 0;
+}
+
+.fleetops-waypoint-popup__name {
+ font-weight: 600;
+ color: #0f172a;
+ margin-bottom: 2px;
+}
+
+.fleetops-waypoint-popup__address {
+ color: #475569;
+ font-size: 11px;
+ margin-bottom: 4px;
+}
+
+.fleetops-waypoint-popup__meta {
+ color: #64748b;
+ font-size: 11px;
+ margin-top: 2px;
+}
+
+.fleetops-waypoint-popup__meta strong {
+ color: #334155;
+}
+
+/* ============================================================
+ Route Visualization – Live Map Route Tooltips
+ ============================================================ */
+
+/**
+ * Tooltip shown when hovering over a live-map route polyline.
+ */
+.fleetops-route-tooltip-wrapper {
+ pointer-events: none;
+}
+
+.fleetops-route-tooltip-wrapper .leaflet-tooltip {
+ background: rgba(15, 23, 42, 0.92) !important;
+ border: none !important;
+ border-radius: 5px !important;
+ box-shadow: 0 3px 10px rgba(0, 0, 0, 0.3) !important;
+ padding: 6px 10px !important;
+ color: #f1f5f9 !important;
+ font-size: 11px !important;
+ white-space: nowrap;
+}
+
+.fleetops-route-tooltip-wrapper .leaflet-tooltip::before {
+ border-top-color: rgba(15, 23, 42, 0.92) !important;
+}
+
+.fleetops-route-tooltip {
+ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
+ line-height: 1.6;
+}
+
+.fleetops-route-tooltip__id {
+ font-weight: 700;
+ font-size: 12px;
+ color: #f8fafc;
+ margin-bottom: 1px;
+}
+
+.fleetops-route-tooltip__driver,
+.fleetops-route-tooltip__status {
+ font-size: 11px;
+ color: #94a3b8;
+}
+
+/* ============================================================
+ Route Visualization – In-Progress Pulse Animation
+ Applies a subtle animated glow to active (in_progress) routes.
+ ============================================================ */
+
+@keyframes fleetops-route-pulse {
+ 0% { opacity: 1; }
+ 50% { opacity: 0.55; }
+ 100% { opacity: 1; }
+}
+
+.fleetops-route-active path {
+ animation: fleetops-route-pulse 2s ease-in-out infinite;
+}
diff --git a/addon/utils/route-colors.js b/addon/utils/route-colors.js
new file mode 100644
index 000000000..9745b2753
--- /dev/null
+++ b/addon/utils/route-colors.js
@@ -0,0 +1,99 @@
+/**
+ * Route Colors Utility
+ *
+ * Provides deterministic color assignment, color manipulation helpers,
+ * and status-based route line styling for the FleetOps map visualization.
+ */
+
+/**
+ * The canonical route color palette used across the live map and order detail views.
+ * Colors are chosen for high contrast against both light and dark CartoDB tile layers.
+ */
+export const ROUTE_COLOR_PALETTE = ['#0EA5E9', '#8B5CF6', '#F59E0B', '#10B981', '#F97316', '#EC4899', '#06B6D4', '#EF4444', '#84CC16', '#6366F1'];
+
+/**
+ * Darken a hex color by a given integer amount (0-255 per channel).
+ *
+ * @param {string} hex - A CSS hex color string, e.g. "#0EA5E9"
+ * @param {number} amount - How much to subtract from each RGB channel
+ * @returns {string} Darkened hex color string
+ */
+export function darkenColor(hex, amount = 40) {
+ const clean = hex.replace('#', '');
+ const num = parseInt(clean, 16);
+ const r = Math.max(0, (num >> 16) - amount);
+ const g = Math.max(0, ((num >> 8) & 0xff) - amount);
+ const b = Math.max(0, (num & 0xff) - amount);
+ return `#${[r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
+}
+
+/**
+ * Derive a deterministic color from a string identifier (e.g. order public_id).
+ * The same ID will always produce the same color from the palette.
+ *
+ * @param {string} id - Any string identifier
+ * @returns {string} A hex color string from ROUTE_COLOR_PALETTE
+ */
+export function colorForId(id = '') {
+ let hash = 0;
+ for (let i = 0; i < id.length; i++) {
+ hash = (hash << 5) - hash + id.charCodeAt(i);
+ hash |= 0; // Convert to 32-bit integer
+ }
+ return ROUTE_COLOR_PALETTE[Math.abs(hash) % ROUTE_COLOR_PALETTE.length];
+}
+
+/**
+ * Return a Leaflet Path options `styles` array for a route polyline
+ * based on the order status. Uses a two-layer "cased" approach:
+ * a darker, heavier outline beneath a brighter, thinner main line.
+ *
+ * @param {string} status - The order status string
+ * @param {string} color - The base hex color for this route
+ * @returns {Array