From 650cfde43b1a6bda699e815a5b09fd1cebd474c8 Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Tue, 7 Apr 2026 23:06:12 -0400 Subject: [PATCH 1/2] feat(map): add color-coded route lines and enhanced waypoint markers - Add addon/utils/route-colors.js with color palette, colorForId(), darkenColor(), routeStyleForStatus(), and waypointIconHtml() utilities - Update leaflet-map-manager.js: addRoutingControl now accepts color, orderId, status, and places options; uses lineOptions.styles for status-based cased polylines and createMarker for divIcon waypoints (green P pickup, red D dropoff, numbered route-colored stops) - Update leaflet-live-map.js: render color-coded polylines for all active routes after load via new renderLiveRoutes private method; handles OSRM and GeoJSON geometry formats; sticky tooltips with order/driver/status - Update order details controller: pass routingOptions (color, status, places) to addRoutingControl and replaceRoutingControl - Update order form route component: pass color, status, places to replaceRoutingControl in previewRoute - Append route visualization CSS to fleetops-engine.css: waypoint marker reset, tooltip, popup, live-map route tooltip, and pulse animation --- addon/components/map/leaflet-live-map.js | 150 +++++++++++++++ addon/components/order/form/route.js | 9 + .../operations/orders/index/details.js | 39 +++- addon/services/leaflet-map-manager.js | 97 +++++++++- addon/styles/fleetops-engine.css | 173 ++++++++++++++++++ addon/utils/route-colors.js | 99 ++++++++++ 6 files changed, 556 insertions(+), 11 deletions(-) create mode 100644 addon/utils/route-colors.js 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 = `
+
${orderPublicId}
+
Driver: ${driverName}
+
Status: ${status}
+
`; + + // 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( + `
+
+ ${label} + ${title} +
+ ${placeName ? `
${placeName}
` : ''} + ${placeAddress ? `
${placeAddress}
` : ''} + ${placeEta ? `
ETA: ${placeEta}
` : ''} + ${placeStatus ? `
Status: ${placeStatus}
` : ''} +
`, + { 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} Array of Leaflet Path options objects + */ +export function routeStyleForStatus(status, color) { + const darker = darkenColor(color, 40); + + switch (status) { + case 'pending': + return [{ color: '#9CA3AF', weight: 5, opacity: 0.65, dashArray: '8 14', lineCap: 'round', lineJoin: 'round' }]; + + case 'canceled': + return [{ color: '#EF4444', weight: 4, opacity: 0.4, dashArray: '6 10', lineCap: 'round', lineJoin: 'round' }]; + + case 'completed': + return [ + { color: darkenColor('#14B8A6', 30), weight: 7, opacity: 0.5, lineCap: 'round', lineJoin: 'round' }, + { color: '#14B8A6', weight: 4, opacity: 0.5, lineCap: 'round', lineJoin: 'round' }, + ]; + + case 'in_progress': + case 'dispatched': + default: + return [ + { color: darker, weight: 9, opacity: 0.9, lineCap: 'round', lineJoin: 'round' }, + { color: color, weight: 5, opacity: 1.0, lineCap: 'round', lineJoin: 'round' }, + ]; + } +} + +/** + * Build a Leaflet `L.divIcon` HTML string for a numbered/typed waypoint marker. + * + * @param {string|number} label - The label to display inside the circle (e.g. "P", "D", "2") + * @param {string} bgColor - CSS background color for the circle + * @returns {string} HTML string suitable for use in `L.divIcon({ html: ... })` + */ +export function waypointIconHtml(label, bgColor) { + return `
${label}
`; +} From c3f058f52b7acf50e9a7cf90d123e3eb8d61c530 Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Tue, 7 Apr 2026 23:40:37 -0400 Subject: [PATCH 2/2] fix: add app/utils/route-colors.js re-export for ember engine resolution --- app/utils/route-colors.js | 1 + 1 file changed, 1 insertion(+) create mode 100644 app/utils/route-colors.js diff --git a/app/utils/route-colors.js b/app/utils/route-colors.js new file mode 100644 index 000000000..11b5e7c9e --- /dev/null +++ b/app/utils/route-colors.js @@ -0,0 +1 @@ +export { default, ROUTE_COLOR_PALETTE, colorForId, darkenColor, routeStyleForStatus, waypointIconHtml } from '@fleetbase/fleetops-engine/utils/route-colors';