|
|
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; |
|
|
let useRealRoads = false; |
|
|
const solveButton = $("#solveButton"); |
|
|
const stopSolvingButton = $("#stopSolvingButton"); |
|
|
const vehiclesTable = $("#vehicles"); |
|
|
const analyzeButton = $("#analyzeButton"); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function decodePolyline(encoded) { |
|
|
if (!encoded) return []; |
|
|
|
|
|
const points = []; |
|
|
let index = 0; |
|
|
let lat = 0; |
|
|
let lng = 0; |
|
|
|
|
|
while (index < encoded.length) { |
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
points.push([lat / 1e5, lng / 1e5]); |
|
|
} |
|
|
|
|
|
return points; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
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); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
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")); |
|
|
}; |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
|
|
|
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 }, |
|
|
stack: false, |
|
|
stackSubgroups: false, |
|
|
zoomMin: 1000 * 60 * 60, |
|
|
zoomMax: 1000 * 60 * 60 * 24, |
|
|
}; |
|
|
|
|
|
const byVisitTimelineOptions = { |
|
|
timeAxis: { scale: "hour" }, |
|
|
orientation: { axis: "top" }, |
|
|
verticalScroll: true, |
|
|
xss: { disabled: true }, |
|
|
stack: false, |
|
|
stackSubgroups: false, |
|
|
zoomMin: 1000 * 60 * 60, |
|
|
zoomMax: 1000 * 60 * 60 * 24, |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let addingVehicleMode = false; |
|
|
let pickingVehicleLocation = false; |
|
|
let tempVehicleMarker = null; |
|
|
let vehicleDeparturePicker = null; |
|
|
|
|
|
|
|
|
let highlightedVehicleId = null; |
|
|
let routeNumberMarkers = []; |
|
|
|
|
|
|
|
|
$(document).ready(function () { |
|
|
replaceQuickstartSolverForgeAutoHeaderFooter(); |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
$("#byVehicleTab").on("shown.bs.tab", function (event) { |
|
|
if (byVehicleTimeline) { |
|
|
byVehicleTimeline.redraw(); |
|
|
} |
|
|
}); |
|
|
$("#byVisitTab").on("shown.bs.tab", function (event) { |
|
|
if (byVisitTimeline) { |
|
|
byVisitTimeline.redraw(); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
map.on("click", function (e) { |
|
|
if (addingVehicleMode) { |
|
|
|
|
|
setVehicleHomeLocation(e.latlng.lat, e.latlng.lng); |
|
|
} else if (!optimizing) { |
|
|
|
|
|
visitMarker = L.circleMarker(e.latlng); |
|
|
visitMarker.setStyle({ color: "green" }); |
|
|
visitMarker.addTo(map); |
|
|
openRecommendationModal(e.latlng.lat, e.latlng.lng); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
$("#newVisitModal").on("hidden.bs.modal", function () { |
|
|
if (visitMarker) { |
|
|
map.removeLayer(visitMarker); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
$("#addVehicleBtn").click(openAddVehicleModal); |
|
|
$("#removeVehicleBtn").click(removeLastVehicle); |
|
|
$("#confirmAddVehicle").click(confirmAddVehicle); |
|
|
$("#pickLocationBtn").click(pickVehicleLocationOnMap); |
|
|
|
|
|
|
|
|
$("#addVehicleModal").on("hidden.bs.modal", function () { |
|
|
if (!pickingVehicleLocation) { |
|
|
addingVehicleMode = false; |
|
|
if (tempVehicleMarker) { |
|
|
map.removeLayer(tempVehicleMarker); |
|
|
tempVehicleMarker = null; |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
$(document).on('change', '#realRoadRouting', function() { |
|
|
useRealRoads = $(this).is(':checked'); |
|
|
|
|
|
|
|
|
if (demoDataId && !optimizing) { |
|
|
scheduleId = null; |
|
|
initialized = false; |
|
|
homeLocationGroup.clearLayers(); |
|
|
homeLocationMarkerByIdMap.clear(); |
|
|
visitGroup.clearLayers(); |
|
|
visitMarkerByIdMap.clear(); |
|
|
routeGeometries = null; |
|
|
refreshRoutePlan(); |
|
|
} |
|
|
}); |
|
|
|
|
|
setupAjax(); |
|
|
fetchDemoData(); |
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
$("#vehicleName").val("").attr("placeholder", `e.g., ${getNextVehicleName()}`); |
|
|
|
|
|
|
|
|
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)); |
|
|
} |
|
|
|
|
|
|
|
|
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() { |
|
|
|
|
|
pickingVehicleLocation = true; |
|
|
addingVehicleMode = true; |
|
|
$("#addVehicleModal").modal("hide"); |
|
|
|
|
|
|
|
|
$("#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)}`); |
|
|
|
|
|
|
|
|
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 (pickingVehicleLocation) { |
|
|
pickingVehicleLocation = false; |
|
|
addingVehicleMode = false; |
|
|
$("#addVehicleModal").modal("show"); |
|
|
|
|
|
$("#mapHint").html('<i class="fas fa-mouse-pointer"></i> Click on the map to add a new visit'); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
const maxId = Math.max(...loadedRoutePlan.vehicles.map(v => parseInt(v.id)), 0); |
|
|
const newId = String(maxId + 1); |
|
|
|
|
|
|
|
|
const formattedDeparture = JSJoda.LocalDateTime.parse( |
|
|
departureTime, |
|
|
JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm') |
|
|
).format(JSJoda.DateTimeFormatter.ISO_LOCAL_DATE_TIME); |
|
|
|
|
|
|
|
|
const newVehicle = { |
|
|
id: newId, |
|
|
name: vehicleName, |
|
|
capacity: capacity, |
|
|
homeLocation: [lat, lng], |
|
|
departureTime: formattedDeparture, |
|
|
visits: [], |
|
|
totalDemand: 0, |
|
|
totalDrivingTimeSeconds: 0, |
|
|
arrivalTime: formattedDeparture |
|
|
}; |
|
|
|
|
|
|
|
|
loadedRoutePlan.vehicles.push(newVehicle); |
|
|
|
|
|
|
|
|
$("#addVehicleModal").modal("hide"); |
|
|
addingVehicleMode = false; |
|
|
|
|
|
if (tempVehicleMarker) { |
|
|
map.removeLayer(tempVehicleMarker); |
|
|
tempVehicleMarker = null; |
|
|
} |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
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; |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
loadedRoutePlan.vehicles.pop(); |
|
|
|
|
|
|
|
|
const marker = homeLocationMarkerByIdMap.get(lastVehicle.id); |
|
|
if (marker) { |
|
|
homeLocationGroup.removeLayer(marker); |
|
|
homeLocationMarkerByIdMap.delete(lastVehicle.id); |
|
|
} |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
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; |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
loadedRoutePlan.vehicles.splice(vehicleIndex, 1); |
|
|
|
|
|
|
|
|
const marker = homeLocationMarkerByIdMap.get(vehicleId); |
|
|
if (marker) { |
|
|
homeLocationGroup.removeLayer(marker); |
|
|
homeLocationMarkerByIdMap.delete(vehicleId); |
|
|
} |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
setTimeout(() => { |
|
|
notification.alert('close'); |
|
|
}, 3000); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function toggleVehicleHighlight(vehicleId) { |
|
|
if (highlightedVehicleId === vehicleId) { |
|
|
|
|
|
clearRouteHighlight(); |
|
|
} else { |
|
|
|
|
|
highlightVehicleRoute(vehicleId); |
|
|
} |
|
|
} |
|
|
|
|
|
function clearRouteHighlight() { |
|
|
|
|
|
routeNumberMarkers.forEach(marker => map.removeLayer(marker)); |
|
|
routeNumberMarkers = []; |
|
|
|
|
|
|
|
|
if (loadedRoutePlan) { |
|
|
loadedRoutePlan.vehicles.forEach(vehicle => { |
|
|
const marker = homeLocationMarkerByIdMap.get(vehicle.id); |
|
|
if (marker) { |
|
|
marker.setIcon(createVehicleHomeIcon(vehicle, false)); |
|
|
marker.setOpacity(1); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
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); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
renderRouteLines(); |
|
|
|
|
|
|
|
|
$("#vehicles tr").removeClass("table-active"); |
|
|
|
|
|
highlightedVehicleId = null; |
|
|
} |
|
|
|
|
|
function highlightVehicleRoute(vehicleId) { |
|
|
|
|
|
clearRouteHighlight(); |
|
|
|
|
|
highlightedVehicleId = vehicleId; |
|
|
|
|
|
if (!loadedRoutePlan) return; |
|
|
|
|
|
const vehicle = loadedRoutePlan.vehicles.find(v => v.id === vehicleId); |
|
|
if (!vehicle) return; |
|
|
|
|
|
const vehicleColor = colorByVehicle(vehicle); |
|
|
|
|
|
|
|
|
const homeMarker = homeLocationMarkerByIdMap.get(vehicleId); |
|
|
if (homeMarker) { |
|
|
homeMarker.setIcon(createVehicleHomeIcon(vehicle, true)); |
|
|
} |
|
|
|
|
|
|
|
|
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); |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
const visitByIdMap = new Map(loadedRoutePlan.visits.map(v => [v.id, v])); |
|
|
const vehicleVisits = vehicle.visits.map(visitId => visitByIdMap.get(visitId)).filter(v => v); |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
const numberMarker = L.marker(visit.location, { |
|
|
icon: createRouteNumberIcon(stopNumber, vehicleColor), |
|
|
interactive: false, |
|
|
zIndexOffset: 1000 |
|
|
}); |
|
|
numberMarker.addTo(map); |
|
|
routeNumberMarkers.push(numberMarker); |
|
|
stopNumber++; |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
loadedRoutePlan.visits.forEach(visit => { |
|
|
if (!vehicle.visits.includes(visit.id)) { |
|
|
const marker = visitMarkerByIdMap.get(visit.id); |
|
|
if (marker) { |
|
|
marker.setOpacity(0.25); |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
renderRouteLines(vehicleId); |
|
|
|
|
|
|
|
|
$("#vehicles tr").removeClass("table-active"); |
|
|
$(`#vehicle-row-${vehicleId}`).addClass("table-active"); |
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
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) { |
|
|
|
|
|
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) { |
|
|
|
|
|
L.polyline([homeLocation, ...locations, homeLocation], { |
|
|
color: color, |
|
|
weight: weight, |
|
|
opacity: opacity |
|
|
}).addTo(routeGroup); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
function colorByVehicle(vehicle) { |
|
|
return vehicle === null ? null : pickColor("vehicle" + vehicle.id); |
|
|
} |
|
|
|
|
|
|
|
|
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) { |
|
|
|
|
|
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); |
|
|
} |
|
|
|
|
|
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>`); |
|
|
}); |
|
|
|
|
|
solution.visits.forEach(function (visit) { |
|
|
getVisitMarker(visit).setPopupContent(visitPopupContent(visit)); |
|
|
}); |
|
|
|
|
|
await renderRouteLines(highlightedVehicleId); |
|
|
|
|
|
|
|
|
$("#score").text(solution.score ? `Score: ${solution.score}` : "Score: ?"); |
|
|
$("#drivingTime").text(formatDrivingTime(solution.totalDrivingTimeSeconds)); |
|
|
} |
|
|
|
|
|
function renderTimelines(routePlan) { |
|
|
byVehicleGroupData.clear(); |
|
|
byVisitGroupData.clear(); |
|
|
byVehicleItemData.clear(); |
|
|
byVisitItemData.clear(); |
|
|
|
|
|
|
|
|
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(); |
|
|
|
|
|
|
|
|
routePlan.vehicles.forEach(vehicle => { |
|
|
vehicle.visits.forEach((visitId, index) => { |
|
|
visitOrderMap.set(visitId, index + 1); |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
$.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; |
|
|
|
|
|
|
|
|
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(), |
|
|
}); |
|
|
|
|
|
|
|
|
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`), |
|
|
); |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
const vehicleInfo = vehicleById.get(visit.vehicle); |
|
|
const vehicleName = vehicleInfo ? (vehicleInfo.name || `Vehicle ${visit.vehicle}`) : `Vehicle ${visit.vehicle}`; |
|
|
|
|
|
|
|
|
const stopBadge = stopNumber ? `<span class="timeline-stop-badge">${stopNumber}</span>` : ''; |
|
|
|
|
|
|
|
|
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() { |
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
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, |
|
|
minStartTime: updatedMinStartTime, |
|
|
maxEndTime: updatedMaxEndTime |
|
|
}; |
|
|
|
|
|
let updatedVisitList = [...loadedRoutePlan['visits']]; |
|
|
updatedVisitList.push(updatedVisit); |
|
|
let updatedSolution = {...loadedRoutePlan, visits: updatedVisitList}; |
|
|
|
|
|
|
|
|
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, |
|
|
minStartTime: updatedMinStartTime, |
|
|
maxEndTime: updatedMaxEndTime |
|
|
}; |
|
|
|
|
|
let updatedVisitList = [...loadedRoutePlan['visits']]; |
|
|
updatedVisitList.push(updatedVisit); |
|
|
let updatedSolution = {...loadedRoutePlan, visits: updatedVisitList}; |
|
|
|
|
|
|
|
|
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'); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function setupAjax() { |
|
|
$.ajaxSetup({ |
|
|
headers: { |
|
|
"Content-Type": "application/json", |
|
|
Accept: "application/json,text/plain", |
|
|
}, |
|
|
}); |
|
|
|
|
|
|
|
|
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() { |
|
|
|
|
|
routeGeometries = null; |
|
|
|
|
|
$.ajax({ |
|
|
url: "/route-plans", |
|
|
type: "POST", |
|
|
data: JSON.stringify(loadedRoutePlan), |
|
|
contentType: "application/json", |
|
|
dataType: "text", |
|
|
success: function (data) { |
|
|
scheduleId = data.replace(/"/g, ""); |
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
routeGeometries = null; |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
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) { |
|
|
|
|
|
$("#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>`), |
|
|
); |
|
|
} |
|
|
} |
|
|
|