Spaces:
Sleeping
Sleeping
| 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<number>>} 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<Object|null>} 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 = '<i class="fas fa-download me-2"></i>'; | |
| } else if (data.phase === "routes") { | |
| statusIcon = '<i class="fas fa-route me-2"></i>'; | |
| } else if (data.phase === "complete") { | |
| statusIcon = '<i class="fas fa-check-circle me-2 text-success"></i>'; | |
| } | |
| 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: | |
| '© <a href="https://www.openstreetmap.org/">OpenStreetMap</a> 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('<i class="fas fa-crosshairs"></i> 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(`<i class="fas fa-check text-success"></i> 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: `<div style=" | |
| background-color: #6366f1; | |
| border: 3px solid white; | |
| border-radius: 4px; | |
| width: 28px; | |
| height: 28px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.4); | |
| animation: pulse 1s infinite; | |
| "><i class="fas fa-warehouse" style="color: white; font-size: 12px;"></i></div>`, | |
| 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('<i class="fas fa-mouse-pointer"></i> 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 = $(` | |
| <div class="alert ${alertClass} alert-dismissible fade show" role="alert" style="min-width: 300px;"> | |
| <i class="fas ${icon} me-2"></i>${message} | |
| <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> | |
| </div> | |
| `); | |
| $("#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: `<div style=" | |
| background-color: ${color}; | |
| color: white; | |
| font-weight: bold; | |
| font-size: 12px; | |
| width: 22px; | |
| height: 22px; | |
| border-radius: 50%; | |
| border: 2px solid white; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.4); | |
| margin-left: 16px; | |
| margin-top: -28px; | |
| ">${number}</div>`, | |
| 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 `<div style="min-width: 150px;"> | |
| <h5 style="color: ${color};"><i class="fas fa-truck"></i> ${vehicleName}</h5> | |
| <p class="mb-1"><strong>Depot Location</strong></p> | |
| <p class="mb-1"><i class="fas fa-box"></i> Capacity: ${vehicle.capacity}</p> | |
| <p class="mb-1"><i class="fas fa-route"></i> Visits: ${visitCount}</p> | |
| <p class="mb-0"><i class="fas fa-clock"></i> Departs: ${showTimeOnly(vehicle.departureTime)}</p> | |
| </div>`; | |
| } | |
| function visitPopupContent(visit) { | |
| const customerType = getCustomerType(visit); | |
| const serviceDurationMinutes = Math.round(visit.serviceDuration / 60); | |
| const arrival = visit.arrivalTime | |
| ? `<h6>Arrival at ${showTimeOnly(visit.arrivalTime)}.</h6>` | |
| : ""; | |
| return `<h5><i class="fas ${customerType.icon}" style="color: ${customerType.color}"></i> ${visit.name}</h5> | |
| <h6><span class="badge" style="background-color: ${customerType.color}">${customerType.label}</span></h6> | |
| <h6>Cargo: ${visit.demand} units</h6> | |
| <h6>Service time: ${serviceDurationMinutes} min</h6> | |
| <h6>Window: ${showTimeOnly(visit.minStartTime)} - ${showTimeOnly(visit.maxEndTime)}</h6> | |
| ${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: `<div style=" | |
| background-color: ${color}; | |
| border: ${borderWidth}px solid white; | |
| border-radius: 50%; | |
| width: ${size}px; | |
| height: ${size}px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| box-shadow: ${shadow}; | |
| transition: all 0.2s ease; | |
| "><i class="fas fa-truck" style="color: white; font-size: ${fontSize}px;"></i></div>`, | |
| 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: `<div style=" | |
| background-color: white; | |
| border: ${borderWidth}px solid ${borderColor}; | |
| border-radius: 50%; | |
| width: ${size}px; | |
| height: ${size}px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| box-shadow: ${shadow}; | |
| transition: all 0.2s ease; | |
| "><i class="fas ${customerType.icon}" style="color: ${customerType.color}; font-size: ${fontSize}px;"></i></div>`, | |
| 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(` | |
| <tr id="vehicle-row-${id}" class="vehicle-row ${isHighlighted ? 'table-active' : ''}" style="cursor: pointer;"> | |
| <td onclick="toggleVehicleHighlight('${id}')"> | |
| <div style="background-color: ${color}; width: 1.5rem; height: 1.5rem; border-radius: 50%; display: flex; align-items: center; justify-content: center; ${isHighlighted ? 'box-shadow: 0 0 0 3px ' + color + '40;' : ''}"> | |
| <i class="fas fa-truck" style="color: white; font-size: 0.65rem;"></i> | |
| </div> | |
| </td> | |
| <td onclick="toggleVehicleHighlight('${id}')"> | |
| <strong>${vehicleName}</strong> | |
| <br><small class="text-muted">${visitCount} stops</small> | |
| </td> | |
| <td onclick="toggleVehicleHighlight('${id}')"> | |
| <div class="progress" style="height: 18px;" data-bs-toggle="tooltip" data-bs-placement="left" | |
| title="Cargo: ${totalDemand} / Capacity: ${capacity}${overCapacity ? ' (OVER CAPACITY!)' : ''}"> | |
| <div class="progress-bar ${progressBarColor}" role="progressbar" style="width: ${percentage}%; font-size: 0.7rem; transition: width 0.3s ease;"> | |
| ${totalDemand}/${capacity} | |
| </div> | |
| </div> | |
| </td> | |
| <td onclick="toggleVehicleHighlight('${id}')" style="font-size: 0.85rem;"> | |
| ${formatDrivingTime(totalDrivingTimeSeconds)} | |
| </td> | |
| <td> | |
| ${canRemove ? `<button class="btn btn-sm btn-outline-danger p-0 px-1" onclick="event.stopPropagation(); removeVehicle('${id}')" title="Remove vehicle ${vehicleName}"> | |
| <i class="fas fa-times" style="font-size: 0.7rem;"></i> | |
| </button>` : ''} | |
| </td> | |
| </tr>`); | |
| }); | |
| // 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 | |
| ? `<i class="fas fa-exclamation-triangle timeline-status-late timeline-status-icon" title="${lateCount} late"></i>` | |
| : vehicle.visits.length > 0 | |
| ? `<i class="fas fa-check-circle timeline-status-ontime timeline-status-icon" title="All on-time"></i>` | |
| : ''; | |
| const progressBarClass = overCapacity ? 'bg-danger' : ''; | |
| const vehicleWithLoad = ` | |
| <h5 class="card-title mb-1">${vehicleName}${statusIcon}</h5> | |
| <div class="progress" style="height: 16px;" title="Cargo: ${totalDemand} / ${capacity}"> | |
| <div class="progress-bar ${progressBarClass}" role="progressbar" style="width: ${percentage}%"> | |
| ${totalDemand}/${capacity} | |
| </div> | |
| </div>`; | |
| 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 = $(`<div/>`).append( | |
| $(`<h5 class="card-title mb-1"/>`).html( | |
| `<i class="fas ${customerType.icon}" style="color: ${customerType.color}"></i> ${visit.name}` | |
| ), | |
| ).append( | |
| $(`<small class="text-muted"/>`).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 = $(`<div/>`).append( | |
| $(`<span/>`).html(`<i class="fas fa-exclamation-circle text-danger me-1"></i>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 ? `<span class="timeline-stop-badge">${stopNumber}</span>` : ''; | |
| // Status icon based on timing | |
| const statusIcon = afterDue | |
| ? `<i class="fas fa-exclamation-triangle timeline-status-late timeline-status-icon" title="Late"></i>` | |
| : `<i class="fas fa-check timeline-status-ontime timeline-status-icon" title="On-time"></i>`; | |
| const byVehicleElement = $(`<div/>`) | |
| .append($(`<span/>`).html( | |
| `${stopBadge}<i class="fas ${customerType.icon}" style="color: ${customerType.color}"></i> ${visit.name}${statusIcon}` | |
| )); | |
| const byVisitElement = $(`<div/>`) | |
| .append( | |
| $(`<span/>`).html( | |
| `${stopBadge}${vehicleName}${statusIcon}` | |
| ), | |
| ); | |
| const byVehicleTravelElement = $(`<div/>`).append( | |
| $(`<span/>`).html(`<i class="fas fa-route text-warning me-1"></i>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 = $(`<div/>`).append( | |
| $(`<span/>`).html(`<i class="fas fa-clock timeline-status-early me-1"></i>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: $(`<div/>`) | |
| .append($(`<span/>`).html(`<i class="fas fa-home text-secondary me-1"></i>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( | |
| $( | |
| '<a id="' + | |
| item + | |
| 'TestData" class="dropdown-item" href="#">' + | |
| item + | |
| "</a>", | |
| ), | |
| ); | |
| $("#" + 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( | |
| '<h1><p style="justify-content: center">No test data available</p></h1>', | |
| ); | |
| }); | |
| } | |
| 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( | |
| $(`<div class="container-fluid"> | |
| <nav class="navbar sticky-top navbar-expand-lg shadow-sm mb-3" style="background-color: #ffffff;"> | |
| <a class="navbar-brand" href="https://www.solverforge.org"> | |
| <img src="/webjars/solverforge/img/solverforge-horizontal.svg" alt="SolverForge logo" width="400"> | |
| </a> | |
| <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation"> | |
| <span class="navbar-toggler-icon"></span> | |
| </button> | |
| <div class="collapse navbar-collapse" id="navbarNav"> | |
| <ul class="nav nav-pills"> | |
| <li class="nav-item active" id="navUIItem"> | |
| <button class="nav-link active" id="navUI" data-bs-toggle="pill" data-bs-target="#demo" type="button" style="color: #1f2937;">Demo UI</button> | |
| </li> | |
| <li class="nav-item" id="navRestItem"> | |
| <button class="nav-link" id="navRest" data-bs-toggle="pill" data-bs-target="#rest" type="button" style="color: #1f2937;">Guide</button> | |
| </li> | |
| <li class="nav-item" id="navOpenApiItem"> | |
| <button class="nav-link" id="navOpenApi" data-bs-toggle="pill" data-bs-target="#openapi" type="button" style="color: #1f2937;">REST API</button> | |
| </li> | |
| </ul> | |
| </div> | |
| <div class="ms-auto d-flex align-items-center gap-3"> | |
| <div class="form-check form-switch d-flex align-items-center" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Enable real road routing using OpenStreetMap data. Slower initial load (~5-15s for download), but shows accurate road routes instead of straight lines."> | |
| <input class="form-check-input" type="checkbox" id="realRoadRouting" style="width: 2.5em; height: 1.25em; cursor: pointer;"> | |
| <label class="form-check-label ms-2" for="realRoadRouting" style="white-space: nowrap; cursor: pointer;"> | |
| <i class="fas fa-road"></i> Real Roads | |
| </label> | |
| </div> | |
| <div class="dropdown"> | |
| <button class="btn dropdown-toggle" type="button" id="dropdownMenuButton" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" style="background-color: #10b981; color: #ffffff; border-color: #10b981;"> | |
| Data | |
| </button> | |
| <div id="testDataButton" class="dropdown-menu" aria-labelledby="dropdownMenuButton"></div> | |
| </div> | |
| </div> | |
| </nav> | |
| </div>`), | |
| ); | |
| } | |
| const solverforgeFooter = $("footer#solverforge-auto-footer"); | |
| if (solverforgeFooter != null) { | |
| solverforgeFooter.append( | |
| $(`<footer class="bg-black text-white-50"> | |
| <div class="container"> | |
| <div class="hstack gap-3 p-4"> | |
| <div class="ms-auto"><a class="text-white" href="https://www.solverforge.org">SolverForge</a></div> | |
| <div class="vr"></div> | |
| <div><a class="text-white" href="https://www.solverforge.org/docs">Documentation</a></div> | |
| <div class="vr"></div> | |
| <div><a class="text-white" href="https://github.com/SolverForge/solverforge-legacy">Code</a></div> | |
| <div class="vr"></div> | |
| <div class="me-auto"><a class="text-white" href="mailto:[email protected]">Support</a></div> | |
| </div> | |
| </div> | |
| </footer>`), | |
| ); | |
| } | |
| } | |