let autoRefreshIntervalId = null; let initialized = false; let optimizing = false; let demoDataId = null; let scheduleId = null; let loadedRoutePlan = null; let newVisit = null; let visitMarker = null; let routeGeometries = null; // Cache for encoded polyline geometries let useRealRoads = false; // Routing mode toggle state const solveButton = $("#solveButton"); const stopSolvingButton = $("#stopSolvingButton"); const vehiclesTable = $("#vehicles"); const analyzeButton = $("#analyzeButton"); /** * Decode an encoded polyline string into an array of [lat, lng] coordinates. * This is the Google polyline encoding algorithm. * @param {string} encoded - The encoded polyline string * @returns {Array>} Array of [lat, lng] coordinate pairs */ function decodePolyline(encoded) { if (!encoded) return []; const points = []; let index = 0; let lat = 0; let lng = 0; while (index < encoded.length) { // Decode latitude let shift = 0; let result = 0; let byte; do { byte = encoded.charCodeAt(index++) - 63; result |= (byte & 0x1f) << shift; shift += 5; } while (byte >= 0x20); const dlat = (result & 1) ? ~(result >> 1) : (result >> 1); lat += dlat; // Decode longitude shift = 0; result = 0; do { byte = encoded.charCodeAt(index++) - 63; result |= (byte & 0x1f) << shift; shift += 5; } while (byte >= 0x20); const dlng = (result & 1) ? ~(result >> 1) : (result >> 1); lng += dlng; // Polyline encoding uses precision of 5 decimal places points.push([lat / 1e5, lng / 1e5]); } return points; } /** * Fetch route geometries for the current schedule from the backend. * @returns {Promise} The geometries object or null if unavailable */ async function fetchRouteGeometries() { if (!scheduleId) return null; try { const response = await fetch(`/route-plans/${scheduleId}/geometry`); if (response.ok) { const data = await response.json(); return data.geometries || null; } } catch (e) { console.warn('Could not fetch route geometries:', e); } return null; } /*************************************** Loading Overlay Functions **************************************/ function showLoadingOverlay(title = "Loading Demo Data", message = "Initializing...") { $("#loadingTitle").text(title); $("#loadingMessage").text(message); $("#loadingProgress").css("width", "0%"); $("#loadingDetail").text(""); $("#loadingOverlay").removeClass("hidden"); } function hideLoadingOverlay() { $("#loadingOverlay").addClass("hidden"); } function updateLoadingProgress(message, percent, detail = "") { $("#loadingMessage").text(message); $("#loadingProgress").css("width", `${percent}%`); $("#loadingDetail").text(detail); } /** * Load demo data with progress updates via Server-Sent Events. * Used when Real Roads mode is enabled. */ function loadDemoDataWithProgress(demoId) { return new Promise((resolve, reject) => { const routingMode = useRealRoads ? "real_roads" : "haversine"; const url = `/demo-data/${demoId}/stream?routing=${routingMode}`; showLoadingOverlay( useRealRoads ? "Loading Real Road Data" : "Loading Demo Data", "Connecting..." ); const eventSource = new EventSource(url); let solution = null; eventSource.onmessage = function(event) { try { const data = JSON.parse(event.data); if (data.event === "progress") { let statusIcon = ""; if (data.phase === "network") { statusIcon = ''; } else if (data.phase === "routes") { statusIcon = ''; } else if (data.phase === "complete") { statusIcon = ''; } updateLoadingProgress(data.message, data.percent, data.detail || ""); } else if (data.event === "complete") { solution = data.solution; // Store geometries from the response if available if (data.geometries) { routeGeometries = data.geometries; } eventSource.close(); hideLoadingOverlay(); resolve(solution); } else if (data.event === "error") { eventSource.close(); hideLoadingOverlay(); reject(new Error(data.message)); } } catch (e) { console.error("Error parsing SSE event:", e); } }; eventSource.onerror = function(error) { eventSource.close(); hideLoadingOverlay(); reject(new Error("Connection lost while loading data")); }; }); } /*************************************** Map constants and variable definitions **************************************/ const homeLocationMarkerByIdMap = new Map(); const visitMarkerByIdMap = new Map(); const map = L.map("map", { doubleClickZoom: false }).setView( [51.505, -0.09], 13, ); const visitGroup = L.layerGroup().addTo(map); const homeLocationGroup = L.layerGroup().addTo(map); const routeGroup = L.layerGroup().addTo(map); /************************************ Time line constants and variable definitions ************************************/ let byVehicleTimeline; let byVisitTimeline; const byVehicleGroupData = new vis.DataSet(); const byVehicleItemData = new vis.DataSet(); const byVisitGroupData = new vis.DataSet(); const byVisitItemData = new vis.DataSet(); const byVehicleTimelineOptions = { timeAxis: { scale: "hour" }, orientation: { axis: "top" }, xss: { disabled: true }, // Items are XSS safe through JQuery stack: false, stackSubgroups: false, zoomMin: 1000 * 60 * 60, // A single hour in milliseconds zoomMax: 1000 * 60 * 60 * 24, // A single day in milliseconds }; const byVisitTimelineOptions = { timeAxis: { scale: "hour" }, orientation: { axis: "top" }, verticalScroll: true, xss: { disabled: true }, // Items are XSS safe through JQuery stack: false, stackSubgroups: false, zoomMin: 1000 * 60 * 60, // A single hour in milliseconds zoomMax: 1000 * 60 * 60 * 24, // A single day in milliseconds }; /************************************ Initialize ************************************/ // Vehicle management state let addingVehicleMode = false; let pickingVehicleLocation = false; let tempVehicleMarker = null; let vehicleDeparturePicker = null; // Route highlighting state let highlightedVehicleId = null; let routeNumberMarkers = []; // Markers showing 1, 2, 3... on route stops $(document).ready(function () { replaceQuickstartSolverForgeAutoHeaderFooter(); // Initialize timelines after DOM is ready with a small delay to ensure Bootstrap tabs are rendered setTimeout(function () { const byVehiclePanel = document.getElementById("byVehiclePanel"); const byVisitPanel = document.getElementById("byVisitPanel"); if (byVehiclePanel) { byVehicleTimeline = new vis.Timeline( byVehiclePanel, byVehicleItemData, byVehicleGroupData, byVehicleTimelineOptions, ); } if (byVisitPanel) { byVisitTimeline = new vis.Timeline( byVisitPanel, byVisitItemData, byVisitGroupData, byVisitTimelineOptions, ); } }, 100); L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19, attribution: '© OpenStreetMap contributors', }).addTo(map); solveButton.click(solve); stopSolvingButton.click(stopSolving); analyzeButton.click(analyze); refreshSolvingButtons(false); // HACK to allow vis-timeline to work within Bootstrap tabs $("#byVehicleTab").on("shown.bs.tab", function (event) { if (byVehicleTimeline) { byVehicleTimeline.redraw(); } }); $("#byVisitTab").on("shown.bs.tab", function (event) { if (byVisitTimeline) { byVisitTimeline.redraw(); } }); // Map click handler - context aware map.on("click", function (e) { if (addingVehicleMode) { // Set vehicle home location setVehicleHomeLocation(e.latlng.lat, e.latlng.lng); } else if (!optimizing) { // Add new visit visitMarker = L.circleMarker(e.latlng); visitMarker.setStyle({ color: "green" }); visitMarker.addTo(map); openRecommendationModal(e.latlng.lat, e.latlng.lng); } }); // Remove visit marker when modal closes $("#newVisitModal").on("hidden.bs.modal", function () { if (visitMarker) { map.removeLayer(visitMarker); } }); // Vehicle management $("#addVehicleBtn").click(openAddVehicleModal); $("#removeVehicleBtn").click(removeLastVehicle); $("#confirmAddVehicle").click(confirmAddVehicle); $("#pickLocationBtn").click(pickVehicleLocationOnMap); // Clean up when add vehicle modal closes (only if not picking location) $("#addVehicleModal").on("hidden.bs.modal", function () { if (!pickingVehicleLocation) { addingVehicleMode = false; if (tempVehicleMarker) { map.removeLayer(tempVehicleMarker); tempVehicleMarker = null; } } }); // Real Roads toggle handler $(document).on('change', '#realRoadRouting', function() { useRealRoads = $(this).is(':checked'); // If we have a demo dataset loaded, reload it with the new routing mode if (demoDataId && !optimizing) { scheduleId = null; initialized = false; homeLocationGroup.clearLayers(); homeLocationMarkerByIdMap.clear(); visitGroup.clearLayers(); visitMarkerByIdMap.clear(); routeGeometries = null; refreshRoutePlan(); } }); setupAjax(); fetchDemoData(); }); /*************************************** Vehicle Management **************************************/ function openAddVehicleModal() { if (optimizing) { alert("Cannot add vehicles while solving. Please stop solving first."); return; } if (!loadedRoutePlan) { alert("Please load a dataset first."); return; } addingVehicleMode = true; // Suggest next vehicle name $("#vehicleName").val("").attr("placeholder", `e.g., ${getNextVehicleName()}`); // Set default values based on existing vehicles const existingVehicle = loadedRoutePlan.vehicles[0]; if (existingVehicle) { $("#vehicleCapacity").val(existingVehicle.capacity || 25); const defaultLat = existingVehicle.homeLocation[0]; const defaultLng = existingVehicle.homeLocation[1]; $("#vehicleHomeLat").val(defaultLat.toFixed(6)); $("#vehicleHomeLng").val(defaultLng.toFixed(6)); } // Initialize departure time picker const tomorrow = JSJoda.LocalDate.now().plusDays(1); const defaultDeparture = tomorrow.atTime(JSJoda.LocalTime.of(6, 0)); if (vehicleDeparturePicker) { vehicleDeparturePicker.destroy(); } vehicleDeparturePicker = flatpickr("#vehicleDepartureTime", { enableTime: true, dateFormat: "Y-m-d H:i", defaultDate: defaultDeparture.format(JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm')) }); $("#addVehicleModal").modal("show"); } function pickVehicleLocationOnMap() { // Hide modal temporarily while user picks location pickingVehicleLocation = true; addingVehicleMode = true; $("#addVehicleModal").modal("hide"); // Show hint on map $("#mapHint").html(' Click on the map to set vehicle depot location').removeClass("hidden"); } function setVehicleHomeLocation(lat, lng) { $("#vehicleHomeLat").val(lat.toFixed(6)); $("#vehicleHomeLng").val(lng.toFixed(6)); $("#vehicleLocationPreview").html(` Location set: ${lat.toFixed(4)}, ${lng.toFixed(4)}`); // Show temporary marker if (tempVehicleMarker) { map.removeLayer(tempVehicleMarker); } tempVehicleMarker = L.marker([lat, lng], { icon: L.divIcon({ className: 'temp-vehicle-marker', html: `
`, iconSize: [28, 28], iconAnchor: [14, 14] }) }); tempVehicleMarker.addTo(map); // If we were picking location, re-open the modal if (pickingVehicleLocation) { pickingVehicleLocation = false; addingVehicleMode = false; $("#addVehicleModal").modal("show"); // Restore normal map hint $("#mapHint").html(' Click on the map to add a new visit'); } } // Extended phonetic alphabet for generating vehicle names const PHONETIC_NAMES = ["Alpha", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot", "Golf", "Hotel", "India", "Juliet", "Kilo", "Lima", "Mike", "November", "Oscar", "Papa", "Quebec", "Romeo", "Sierra", "Tango", "Uniform", "Victor", "Whiskey", "X-ray", "Yankee", "Zulu"]; function getNextVehicleName() { if (!loadedRoutePlan) return "Alpha"; const usedNames = new Set(loadedRoutePlan.vehicles.map(v => v.name)); for (const name of PHONETIC_NAMES) { if (!usedNames.has(name)) return name; } // Fallback if all names used return `Vehicle ${loadedRoutePlan.vehicles.length + 1}`; } async function confirmAddVehicle() { const vehicleName = $("#vehicleName").val().trim() || getNextVehicleName(); const capacity = parseInt($("#vehicleCapacity").val()); const lat = parseFloat($("#vehicleHomeLat").val()); const lng = parseFloat($("#vehicleHomeLng").val()); const departureTime = $("#vehicleDepartureTime").val(); if (!capacity || capacity < 1) { alert("Please enter a valid capacity (minimum 1)."); return; } if (isNaN(lat) || isNaN(lng)) { alert("Please set a valid home location by clicking on the map or entering coordinates."); return; } if (!departureTime) { alert("Please set a departure time."); return; } // Generate new vehicle ID const maxId = Math.max(...loadedRoutePlan.vehicles.map(v => parseInt(v.id)), 0); const newId = String(maxId + 1); // Format departure time const formattedDeparture = JSJoda.LocalDateTime.parse( departureTime, JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm') ).format(JSJoda.DateTimeFormatter.ISO_LOCAL_DATE_TIME); // Create new vehicle const newVehicle = { id: newId, name: vehicleName, capacity: capacity, homeLocation: [lat, lng], departureTime: formattedDeparture, visits: [], totalDemand: 0, totalDrivingTimeSeconds: 0, arrivalTime: formattedDeparture }; // Add to solution loadedRoutePlan.vehicles.push(newVehicle); // Close modal and refresh $("#addVehicleModal").modal("hide"); addingVehicleMode = false; if (tempVehicleMarker) { map.removeLayer(tempVehicleMarker); tempVehicleMarker = null; } // Refresh display await renderRoutes(loadedRoutePlan); renderTimelines(loadedRoutePlan); showNotification(`Vehicle "${vehicleName}" added successfully!`, "success"); } async function removeLastVehicle() { if (optimizing) { alert("Cannot remove vehicles while solving. Please stop solving first."); return; } if (!loadedRoutePlan || loadedRoutePlan.vehicles.length <= 1) { alert("Cannot remove the last vehicle. At least one vehicle is required."); return; } const lastVehicle = loadedRoutePlan.vehicles[loadedRoutePlan.vehicles.length - 1]; if (lastVehicle.visits && lastVehicle.visits.length > 0) { if (!confirm(`Vehicle ${lastVehicle.id} has ${lastVehicle.visits.length} assigned visits. These will become unassigned. Continue?`)) { return; } // Unassign visits from the vehicle lastVehicle.visits.forEach(visitId => { const visit = loadedRoutePlan.visits.find(v => v.id === visitId); if (visit) { visit.vehicle = null; visit.previousVisit = null; visit.nextVisit = null; visit.arrivalTime = null; visit.departureTime = null; } }); } // Remove vehicle loadedRoutePlan.vehicles.pop(); // Remove marker const marker = homeLocationMarkerByIdMap.get(lastVehicle.id); if (marker) { homeLocationGroup.removeLayer(marker); homeLocationMarkerByIdMap.delete(lastVehicle.id); } // Refresh display await renderRoutes(loadedRoutePlan); renderTimelines(loadedRoutePlan); showNotification(`Vehicle "${lastVehicle.name || lastVehicle.id}" removed.`, "info"); } async function removeVehicle(vehicleId) { if (optimizing) { alert("Cannot remove vehicles while solving. Please stop solving first."); return; } const vehicleIndex = loadedRoutePlan.vehicles.findIndex(v => v.id === vehicleId); if (vehicleIndex === -1) return; if (loadedRoutePlan.vehicles.length <= 1) { alert("Cannot remove the last vehicle. At least one vehicle is required."); return; } const vehicle = loadedRoutePlan.vehicles[vehicleIndex]; if (vehicle.visits && vehicle.visits.length > 0) { if (!confirm(`Vehicle ${vehicle.id} has ${vehicle.visits.length} assigned visits. These will become unassigned. Continue?`)) { return; } // Unassign visits vehicle.visits.forEach(visitId => { const visit = loadedRoutePlan.visits.find(v => v.id === visitId); if (visit) { visit.vehicle = null; visit.previousVisit = null; visit.nextVisit = null; visit.arrivalTime = null; visit.departureTime = null; } }); } // Remove vehicle loadedRoutePlan.vehicles.splice(vehicleIndex, 1); // Remove marker const marker = homeLocationMarkerByIdMap.get(vehicleId); if (marker) { homeLocationGroup.removeLayer(marker); homeLocationMarkerByIdMap.delete(vehicleId); } // Refresh display await renderRoutes(loadedRoutePlan); renderTimelines(loadedRoutePlan); showNotification(`Vehicle "${vehicle.name || vehicleId}" removed.`, "info"); } function showNotification(message, type = "info") { const alertClass = type === "success" ? "alert-success" : type === "error" ? "alert-danger" : "alert-info"; const icon = type === "success" ? "fa-check-circle" : type === "error" ? "fa-exclamation-circle" : "fa-info-circle"; const notification = $(` `); $("#notificationPanel").append(notification); // Auto-dismiss after 3 seconds setTimeout(() => { notification.alert('close'); }, 3000); } /*************************************** Route Highlighting **************************************/ function toggleVehicleHighlight(vehicleId) { if (highlightedVehicleId === vehicleId) { // Already highlighted - clear it clearRouteHighlight(); } else { // Highlight this vehicle's route highlightVehicleRoute(vehicleId); } } function clearRouteHighlight() { // Remove number markers routeNumberMarkers.forEach(marker => map.removeLayer(marker)); routeNumberMarkers = []; // Reset all vehicle icons to normal and restore opacity if (loadedRoutePlan) { loadedRoutePlan.vehicles.forEach(vehicle => { const marker = homeLocationMarkerByIdMap.get(vehicle.id); if (marker) { marker.setIcon(createVehicleHomeIcon(vehicle, false)); marker.setOpacity(1); } }); // Reset all visit markers to normal and restore opacity loadedRoutePlan.visits.forEach(visit => { const marker = visitMarkerByIdMap.get(visit.id); if (marker) { const customerType = getCustomerType(visit); const isAssigned = visit.vehicle != null; marker.setIcon(createCustomerTypeIcon(customerType, isAssigned, false)); marker.setOpacity(1); } }); } // Reset route lines renderRouteLines(); // Update vehicle table highlighting $("#vehicles tr").removeClass("table-active"); highlightedVehicleId = null; } function highlightVehicleRoute(vehicleId) { // Clear any existing highlight first clearRouteHighlight(); highlightedVehicleId = vehicleId; if (!loadedRoutePlan) return; const vehicle = loadedRoutePlan.vehicles.find(v => v.id === vehicleId); if (!vehicle) return; const vehicleColor = colorByVehicle(vehicle); // Highlight the vehicle's home marker const homeMarker = homeLocationMarkerByIdMap.get(vehicleId); if (homeMarker) { homeMarker.setIcon(createVehicleHomeIcon(vehicle, true)); } // Dim other vehicles loadedRoutePlan.vehicles.forEach(v => { if (v.id !== vehicleId) { const marker = homeLocationMarkerByIdMap.get(v.id); if (marker) { marker.setIcon(createVehicleHomeIcon(v, false)); marker.setOpacity(0.3); } } }); // Get visit order for this vehicle const visitByIdMap = new Map(loadedRoutePlan.visits.map(v => [v.id, v])); const vehicleVisits = vehicle.visits.map(visitId => visitByIdMap.get(visitId)).filter(v => v); // Highlight and number the visits on this route let stopNumber = 1; vehicleVisits.forEach(visit => { const marker = visitMarkerByIdMap.get(visit.id); if (marker) { const customerType = getCustomerType(visit); marker.setIcon(createCustomerTypeIcon(customerType, true, true, vehicleColor)); marker.setOpacity(1); // Add number marker const numberMarker = L.marker(visit.location, { icon: createRouteNumberIcon(stopNumber, vehicleColor), interactive: false, zIndexOffset: 1000 }); numberMarker.addTo(map); routeNumberMarkers.push(numberMarker); stopNumber++; } }); // Dim visits not on this route loadedRoutePlan.visits.forEach(visit => { if (!vehicle.visits.includes(visit.id)) { const marker = visitMarkerByIdMap.get(visit.id); if (marker) { marker.setOpacity(0.25); } } }); // Highlight just this route, dim others renderRouteLines(vehicleId); // Highlight the row in the vehicle table $("#vehicles tr").removeClass("table-active"); $(`#vehicle-row-${vehicleId}`).addClass("table-active"); // Add start marker (S) at depot const startMarker = L.marker(vehicle.homeLocation, { icon: createRouteNumberIcon("S", vehicleColor), interactive: false, zIndexOffset: 1000 }); startMarker.addTo(map); routeNumberMarkers.push(startMarker); } function createRouteNumberIcon(number, color) { return L.divIcon({ className: 'route-number-marker', html: `
${number}
`, iconSize: [22, 22], iconAnchor: [0, 0] }); } async function renderRouteLines(highlightedId = null) { routeGroup.clearLayers(); if (!loadedRoutePlan) return; // Fetch geometries during solving (routes change) if (scheduleId) { routeGeometries = await fetchRouteGeometries(); } const visitByIdMap = new Map(loadedRoutePlan.visits.map(visit => [visit.id, visit])); for (let vehicle of loadedRoutePlan.vehicles) { const homeLocation = vehicle.homeLocation; const locations = vehicle.visits.map(visitId => visitByIdMap.get(visitId)?.location).filter(l => l); const isHighlighted = highlightedId === null || vehicle.id === highlightedId; const color = colorByVehicle(vehicle); const weight = isHighlighted && highlightedId !== null ? 5 : 3; const opacity = isHighlighted ? 1 : 0.2; const vehicleGeometry = routeGeometries?.[vehicle.id]; if (vehicleGeometry && vehicleGeometry.length > 0) { // Draw real road routes using decoded polylines for (const encodedSegment of vehicleGeometry) { if (encodedSegment) { const points = decodePolyline(encodedSegment); if (points.length > 0) { L.polyline(points, { color: color, weight: weight, opacity: opacity }).addTo(routeGroup); } } } } else if (locations.length > 0) { // Fallback to straight lines if no geometry available L.polyline([homeLocation, ...locations, homeLocation], { color: color, weight: weight, opacity: opacity }).addTo(routeGroup); } } } function colorByVehicle(vehicle) { return vehicle === null ? null : pickColor("vehicle" + vehicle.id); } // Customer type definitions matching demo_data.py const CUSTOMER_TYPES = { RESTAURANT: { label: "Restaurant", icon: "fa-utensils", color: "#f59e0b", windowStart: "06:00", windowEnd: "10:00", minService: 20, maxService: 40 }, BUSINESS: { label: "Business", icon: "fa-building", color: "#3b82f6", windowStart: "09:00", windowEnd: "17:00", minService: 15, maxService: 30 }, RESIDENTIAL: { label: "Residential", icon: "fa-home", color: "#10b981", windowStart: "17:00", windowEnd: "20:00", minService: 5, maxService: 10 }, }; function getCustomerType(visit) { const startTime = showTimeOnly(visit.minStartTime).toString(); const endTime = showTimeOnly(visit.maxEndTime).toString(); for (const [type, config] of Object.entries(CUSTOMER_TYPES)) { if (startTime === config.windowStart && endTime === config.windowEnd) { return { type, ...config }; } } return { type: "UNKNOWN", label: "Custom", icon: "fa-question", color: "#6b7280", windowStart: startTime, windowEnd: endTime }; } function formatDrivingTime(drivingTimeInSeconds) { return `${Math.floor(drivingTimeInSeconds / 3600)}h ${Math.round((drivingTimeInSeconds % 3600) / 60)}m`; } function homeLocationPopupContent(vehicle) { const color = colorByVehicle(vehicle); const visitCount = vehicle.visits ? vehicle.visits.length : 0; const vehicleName = vehicle.name || `Vehicle ${vehicle.id}`; return `
${vehicleName}

Depot Location

Capacity: ${vehicle.capacity}

Visits: ${visitCount}

Departs: ${showTimeOnly(vehicle.departureTime)}

`; } function visitPopupContent(visit) { const customerType = getCustomerType(visit); const serviceDurationMinutes = Math.round(visit.serviceDuration / 60); const arrival = visit.arrivalTime ? `
Arrival at ${showTimeOnly(visit.arrivalTime)}.
` : ""; return `
${visit.name}
${customerType.label}
Cargo: ${visit.demand} units
Service time: ${serviceDurationMinutes} min
Window: ${showTimeOnly(visit.minStartTime)} - ${showTimeOnly(visit.maxEndTime)}
${arrival}`; } function showTimeOnly(localDateTimeString) { return JSJoda.LocalDateTime.parse(localDateTimeString).toLocalTime(); } function createVehicleHomeIcon(vehicle, isHighlighted = false) { const color = colorByVehicle(vehicle); const size = isHighlighted ? 36 : 28; const fontSize = isHighlighted ? 14 : 11; const borderWidth = isHighlighted ? 4 : 3; const shadow = isHighlighted ? `0 0 0 4px ${color}40, 0 4px 8px rgba(0,0,0,0.5)` : '0 2px 4px rgba(0,0,0,0.4)'; return L.divIcon({ className: 'vehicle-home-marker', html: `
`, iconSize: [size, size], iconAnchor: [size/2, size/2], popupAnchor: [0, -size/2] }); } function getHomeLocationMarker(vehicle) { let marker = homeLocationMarkerByIdMap.get(vehicle.id); if (marker) { marker.setIcon(createVehicleHomeIcon(vehicle)); return marker; } marker = L.marker(vehicle.homeLocation, { icon: createVehicleHomeIcon(vehicle) }); marker.addTo(homeLocationGroup).bindPopup(); homeLocationMarkerByIdMap.set(vehicle.id, marker); return marker; } function createCustomerTypeIcon(customerType, isAssigned = false, isHighlighted = false, highlightColor = null) { const borderColor = isHighlighted && highlightColor ? highlightColor : (isAssigned ? customerType.color : '#6b7280'); const size = isHighlighted ? 38 : 32; const fontSize = isHighlighted ? 16 : 14; const borderWidth = isHighlighted ? 4 : 3; const shadow = isHighlighted ? `0 0 0 4px ${highlightColor}40, 0 4px 8px rgba(0,0,0,0.4)` : '0 2px 4px rgba(0,0,0,0.3)'; return L.divIcon({ className: 'customer-marker', html: `
`, iconSize: [size, size], iconAnchor: [size/2, size/2], popupAnchor: [0, -size/2] }); } function getVisitMarker(visit) { let marker = visitMarkerByIdMap.get(visit.id); const customerType = getCustomerType(visit); const isAssigned = visit.vehicle != null; if (marker) { // Update icon if assignment status changed marker.setIcon(createCustomerTypeIcon(customerType, isAssigned)); return marker; } marker = L.marker(visit.location, { icon: createCustomerTypeIcon(customerType, isAssigned) }); marker.addTo(visitGroup).bindPopup(); visitMarkerByIdMap.set(visit.id, marker); return marker; } async function renderRoutes(solution) { if (!initialized) { const bounds = [solution.southWestCorner, solution.northEastCorner]; map.fitBounds(bounds); } // Vehicles vehiclesTable.children().remove(); const canRemove = solution.vehicles.length > 1; solution.vehicles.forEach(function (vehicle) { getHomeLocationMarker(vehicle).setPopupContent( homeLocationPopupContent(vehicle), ); const { id, capacity, totalDemand, totalDrivingTimeSeconds } = vehicle; const percentage = Math.min((totalDemand / capacity) * 100, 100); const overCapacity = totalDemand > capacity; const color = colorByVehicle(vehicle); const progressBarColor = overCapacity ? 'bg-danger' : ''; const isHighlighted = highlightedVehicleId === id; const visitCount = vehicle.visits ? vehicle.visits.length : 0; const vehicleName = vehicle.name || `Vehicle ${id}`; vehiclesTable.append(`
${vehicleName}
${visitCount} stops
${totalDemand}/${capacity}
${formatDrivingTime(totalDrivingTimeSeconds)} ${canRemove ? `` : ''} `); }); // Visits solution.visits.forEach(function (visit) { getVisitMarker(visit).setPopupContent(visitPopupContent(visit)); }); // Route - use the dedicated function which handles highlighting (await to ensure geometries load) await renderRouteLines(highlightedVehicleId); // Summary $("#score").text(solution.score ? `Score: ${solution.score}` : "Score: ?"); $("#drivingTime").text(formatDrivingTime(solution.totalDrivingTimeSeconds)); } function renderTimelines(routePlan) { byVehicleGroupData.clear(); byVisitGroupData.clear(); byVehicleItemData.clear(); byVisitItemData.clear(); // Build lookup maps for O(1) access const vehicleById = new Map(routePlan.vehicles.map(v => [v.id, v])); const visitById = new Map(routePlan.visits.map(v => [v.id, v])); const visitOrderMap = new Map(); // Build stop order for each visit routePlan.vehicles.forEach(vehicle => { vehicle.visits.forEach((visitId, index) => { visitOrderMap.set(visitId, index + 1); }); }); // Vehicle groups with names and status summary $.each(routePlan.vehicles, function (index, vehicle) { const vehicleName = vehicle.name || `Vehicle ${vehicle.id}`; const { totalDemand, capacity } = vehicle; const percentage = Math.min((totalDemand / capacity) * 100, 100); const overCapacity = totalDemand > capacity; // Count late visits for this vehicle const vehicleVisits = vehicle.visits.map(id => visitById.get(id)).filter(v => v); const lateCount = vehicleVisits.filter(v => { if (!v.departureTime) return false; const departure = JSJoda.LocalDateTime.parse(v.departureTime); const maxEnd = JSJoda.LocalDateTime.parse(v.maxEndTime); return departure.isAfter(maxEnd); }).length; const statusIcon = lateCount > 0 ? `` : vehicle.visits.length > 0 ? `` : ''; const progressBarClass = overCapacity ? 'bg-danger' : ''; const vehicleWithLoad = `
${vehicleName}${statusIcon}
${totalDemand}/${capacity}
`; byVehicleGroupData.add({ id: vehicle.id, content: vehicleWithLoad }); }); $.each(routePlan.visits, function (index, visit) { const minStartTime = JSJoda.LocalDateTime.parse(visit.minStartTime); const maxEndTime = JSJoda.LocalDateTime.parse(visit.maxEndTime); const serviceDuration = JSJoda.Duration.ofSeconds(visit.serviceDuration); const customerType = getCustomerType(visit); const stopNumber = visitOrderMap.get(visit.id); const visitGroupElement = $(`
`).append( $(`
`).html( ` ${visit.name}` ), ).append( $(``).text(customerType.label) ); byVisitGroupData.add({ id: visit.id, content: visitGroupElement.html(), }); // Time window per visit. byVisitItemData.add({ id: visit.id + "_readyToDue", group: visit.id, start: visit.minStartTime, end: visit.maxEndTime, type: "background", style: "background-color: #8AE23433", }); if (visit.vehicle == null) { const byJobJobElement = $(`
`).append( $(``).html(`Unassigned`), ); // Unassigned are shown at the beginning of the visit's time window; the length is the service duration. byVisitItemData.add({ id: visit.id + "_unassigned", group: visit.id, content: byJobJobElement.html(), start: minStartTime.toString(), end: minStartTime.plus(serviceDuration).toString(), style: "background-color: #EF292999", }); } else { const arrivalTime = JSJoda.LocalDateTime.parse(visit.arrivalTime); const beforeReady = arrivalTime.isBefore(minStartTime); const departureTime = JSJoda.LocalDateTime.parse(visit.departureTime); const afterDue = departureTime.isAfter(maxEndTime); // Get vehicle info for display const vehicleInfo = vehicleById.get(visit.vehicle); const vehicleName = vehicleInfo ? (vehicleInfo.name || `Vehicle ${visit.vehicle}`) : `Vehicle ${visit.vehicle}`; // Stop badge for service segment const stopBadge = stopNumber ? `${stopNumber}` : ''; // Status icon based on timing const statusIcon = afterDue ? `` : ``; const byVehicleElement = $(`
`) .append($(``).html( `${stopBadge} ${visit.name}${statusIcon}` )); const byVisitElement = $(`
`) .append( $(``).html( `${stopBadge}${vehicleName}${statusIcon}` ), ); const byVehicleTravelElement = $(`
`).append( $(``).html(`Travel`), ); const previousDeparture = arrivalTime.minusSeconds( visit.drivingTimeSecondsFromPreviousStandstill, ); byVehicleItemData.add({ id: visit.id + "_travel", group: visit.vehicle, subgroup: visit.vehicle, content: byVehicleTravelElement.html(), start: previousDeparture.toString(), end: visit.arrivalTime, style: "background-color: #f7dd8f90", }); if (beforeReady) { const byVehicleWaitElement = $(`
`).append( $(``).html(`Wait`), ); byVehicleItemData.add({ id: visit.id + "_wait", group: visit.vehicle, subgroup: visit.vehicle, content: byVehicleWaitElement.html(), start: visit.arrivalTime, end: visit.minStartTime, style: "background-color: #93c5fd80", }); } let serviceElementBackground = afterDue ? "#EF292999" : "#83C15955"; byVehicleItemData.add({ id: visit.id + "_service", group: visit.vehicle, subgroup: visit.vehicle, content: byVehicleElement.html(), start: visit.startServiceTime, end: visit.departureTime, style: "background-color: " + serviceElementBackground, }); byVisitItemData.add({ id: visit.id, group: visit.id, content: byVisitElement.html(), start: visit.startServiceTime, end: visit.departureTime, style: "background-color: " + serviceElementBackground, }); } }); $.each(routePlan.vehicles, function (index, vehicle) { if (vehicle.visits.length > 0) { let lastVisit = routePlan.visits .filter( (visit) => visit.id == vehicle.visits[vehicle.visits.length - 1], ) .pop(); if (lastVisit) { byVehicleItemData.add({ id: vehicle.id + "_travelBackToHomeLocation", group: vehicle.id, subgroup: vehicle.id, content: $(`
`) .append($(``).html(`Return`)) .html(), start: lastVisit.departureTime, end: vehicle.arrivalTime, style: "background-color: #f7dd8f90", }); } } }); if (!initialized) { if (byVehicleTimeline) { byVehicleTimeline.setWindow( routePlan.startDateTime, routePlan.endDateTime, ); } if (byVisitTimeline) { byVisitTimeline.setWindow(routePlan.startDateTime, routePlan.endDateTime); } } } function analyze() { // see score-analysis.js analyzeScore(loadedRoutePlan, "/route-plans/analyze"); } function openRecommendationModal(lat, lng) { if (!('score' in loadedRoutePlan) || optimizing) { map.removeLayer(visitMarker); visitMarker = null; let message = "Please click the Solve button before adding new visits."; if (optimizing) { message = "Please wait for the solving process to finish."; } alert(message); return; } // see recommended-fit.js const visitId = Math.max(...loadedRoutePlan.visits.map(c => parseInt(c.id))) + 1; newVisit = {id: visitId, location: [lat, lng]}; addNewVisit(visitId, lat, lng, map, visitMarker); } function getRecommendationsModal() { let formValid = true; formValid = validateFormField(newVisit, 'name', '#inputName') && formValid; formValid = validateFormField(newVisit, 'demand', '#inputDemand') && formValid; formValid = validateFormField(newVisit, 'minStartTime', '#inputMinStartTime') && formValid; formValid = validateFormField(newVisit, 'maxEndTime', '#inputMaxStartTime') && formValid; formValid = validateFormField(newVisit, 'serviceDuration', '#inputDuration') && formValid; if (formValid) { const updatedMinStartTime = JSJoda.LocalDateTime.parse( newVisit['minStartTime'], JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm') ).format(JSJoda.DateTimeFormatter.ISO_LOCAL_DATE_TIME); const updatedMaxEndTime = JSJoda.LocalDateTime.parse( newVisit['maxEndTime'], JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm') ).format(JSJoda.DateTimeFormatter.ISO_LOCAL_DATE_TIME); const updatedVisit = { ...newVisit, serviceDuration: parseInt(newVisit['serviceDuration']) * 60, // Convert minutes to seconds minStartTime: updatedMinStartTime, maxEndTime: updatedMaxEndTime }; let updatedVisitList = [...loadedRoutePlan['visits']]; updatedVisitList.push(updatedVisit); let updatedSolution = {...loadedRoutePlan, visits: updatedVisitList}; // see recommended-fit.js requestRecommendations(updatedVisit.id, updatedSolution, "/route-plans/recommendation"); } } function validateFormField(target, fieldName, inputName) { target[fieldName] = $(inputName).val(); if ($(inputName).val() == "") { $(inputName).addClass("is-invalid"); } else { $(inputName).removeClass("is-invalid"); } return $(inputName).val() != ""; } function applyRecommendationModal(recommendations) { let checkedRecommendation = null; recommendations.forEach((recommendation, index) => { if ($('#option' + index).is(":checked")) { checkedRecommendation = recommendations[index]; } }); if (!checkedRecommendation) { alert("Please select a recommendation."); return; } const updatedMinStartTime = JSJoda.LocalDateTime.parse( newVisit['minStartTime'], JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm') ).format(JSJoda.DateTimeFormatter.ISO_LOCAL_DATE_TIME); const updatedMaxEndTime = JSJoda.LocalDateTime.parse( newVisit['maxEndTime'], JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm') ).format(JSJoda.DateTimeFormatter.ISO_LOCAL_DATE_TIME); const updatedVisit = { ...newVisit, serviceDuration: parseInt(newVisit['serviceDuration']) * 60, // Convert minutes to seconds minStartTime: updatedMinStartTime, maxEndTime: updatedMaxEndTime }; let updatedVisitList = [...loadedRoutePlan['visits']]; updatedVisitList.push(updatedVisit); let updatedSolution = {...loadedRoutePlan, visits: updatedVisitList}; // see recommended-fit.js applyRecommendation( updatedSolution, newVisit.id, checkedRecommendation.proposition.vehicleId, checkedRecommendation.proposition.index, "/route-plans/recommendation/apply" ); } async function updateSolutionWithNewVisit(newSolution) { loadedRoutePlan = newSolution; await renderRoutes(newSolution); renderTimelines(newSolution); $('#newVisitModal').modal('hide'); } // TODO: move the general functionality to the webjar. function setupAjax() { $.ajaxSetup({ headers: { "Content-Type": "application/json", Accept: "application/json,text/plain", // plain text is required by solve() returning UUID of the solver job }, }); // Extend jQuery to support $.put() and $.delete() jQuery.each(["put", "delete"], function (i, method) { jQuery[method] = function (url, data, callback, type) { if (jQuery.isFunction(data)) { type = type || callback; callback = data; data = undefined; } return jQuery.ajax({ url: url, type: method, dataType: type, data: data, success: callback, }); }; }); } function solve() { // Clear geometry cache - will be refreshed when solution updates routeGeometries = null; $.ajax({ url: "/route-plans", type: "POST", data: JSON.stringify(loadedRoutePlan), contentType: "application/json", dataType: "text", success: function (data) { scheduleId = data.replace(/"/g, ""); // Remove quotes from UUID refreshSolvingButtons(true); }, error: function (xhr, ajaxOptions, thrownError) { showError("Start solving failed.", xhr); refreshSolvingButtons(false); }, }); } function refreshSolvingButtons(solving) { optimizing = solving; if (solving) { $("#solveButton").hide(); $("#visitButton").hide(); $("#stopSolvingButton").show(); $("#solvingSpinner").addClass("active"); $("#mapHint").addClass("hidden"); if (autoRefreshIntervalId == null) { autoRefreshIntervalId = setInterval(refreshRoutePlan, 2000); } } else { $("#solveButton").show(); $("#visitButton").show(); $("#stopSolvingButton").hide(); $("#solvingSpinner").removeClass("active"); $("#mapHint").removeClass("hidden"); if (autoRefreshIntervalId != null) { clearInterval(autoRefreshIntervalId); autoRefreshIntervalId = null; } } } async function refreshRoutePlan() { let path = "/route-plans/" + scheduleId; let isLoadingDemoData = scheduleId === null; if (isLoadingDemoData) { if (demoDataId === null) { alert("Please select a test data set."); return; } // Clear geometry cache when loading new demo data routeGeometries = null; // Use SSE streaming for demo data loading to show progress try { const routePlan = await loadDemoDataWithProgress(demoDataId); loadedRoutePlan = routePlan; refreshSolvingButtons( routePlan.solverStatus != null && routePlan.solverStatus !== "NOT_SOLVING", ); await renderRoutes(routePlan); renderTimelines(routePlan); initialized = true; } catch (error) { showError("Getting demo data has failed: " + error.message, {}); refreshSolvingButtons(false); } return; } // Loading existing route plan (during solving) try { const routePlan = await $.getJSON(path); loadedRoutePlan = routePlan; refreshSolvingButtons( routePlan.solverStatus != null && routePlan.solverStatus !== "NOT_SOLVING", ); await renderRoutes(routePlan); renderTimelines(routePlan); initialized = true; } catch (error) { showError("Getting route plan has failed.", error); refreshSolvingButtons(false); } } function stopSolving() { $.delete("/route-plans/" + scheduleId, function () { refreshSolvingButtons(false); refreshRoutePlan(); }).fail(function (xhr, ajaxOptions, thrownError) { showError("Stop solving failed.", xhr); }); } function fetchDemoData() { $.get("/demo-data", function (data) { data.forEach(function (item) { $("#testDataButton").append( $( '' + item + "", ), ); $("#" + item + "TestData").click(function () { switchDataDropDownItemActive(item); scheduleId = null; demoDataId = item; initialized = false; homeLocationGroup.clearLayers(); homeLocationMarkerByIdMap.clear(); visitGroup.clearLayers(); visitMarkerByIdMap.clear(); refreshRoutePlan(); }); }); demoDataId = data[0]; switchDataDropDownItemActive(demoDataId); refreshRoutePlan(); }).fail(function (xhr, ajaxOptions, thrownError) { // disable this page as there is no data $("#demo").empty(); $("#demo").html( '

No test data available

', ); }); } function switchDataDropDownItemActive(newItem) { activeCssClass = "active"; $("#testDataButton > a." + activeCssClass).removeClass(activeCssClass); $("#" + newItem + "TestData").addClass(activeCssClass); } function copyTextToClipboard(id) { var text = $("#" + id) .text() .trim(); var dummy = document.createElement("textarea"); document.body.appendChild(dummy); dummy.value = text; dummy.select(); document.execCommand("copy"); document.body.removeChild(dummy); } function replaceQuickstartSolverForgeAutoHeaderFooter() { const solverforgeHeader = $("header#solverforge-auto-header"); if (solverforgeHeader != null) { solverforgeHeader.css("background-color", "#ffffff"); solverforgeHeader.append( $(`
`), ); } const solverforgeFooter = $("footer#solverforge-auto-footer"); if (solverforgeFooter != null) { solverforgeFooter.append( $(``), ); } }