blackopsrepl/vehicle-routing-python
2
1let autoRefreshIntervalId = null;2let initialized = false;3let optimizing = false;4let demoDataId = null;5let scheduleId = null;6let loadedRoutePlan = null;7let newVisit = null;8let visitMarker = null;9let routeGeometries = null; // Cache for encoded polyline geometries10let useRealRoads = false; // Routing mode toggle state11const solveButton = $("#solveButton");12const stopSolvingButton = $("#stopSolvingButton");13const vehiclesTable = $("#vehicles");14const analyzeButton = $("#analyzeButton");15 16/**17 * Decode an encoded polyline string into an array of [lat, lng] coordinates.18 * This is the Google polyline encoding algorithm.19 * @param {string} encoded - The encoded polyline string20 * @returns {Array<Array<number>>} Array of [lat, lng] coordinate pairs21 */22function decodePolyline(encoded) {23 if (!encoded) return [];24 25 const points = [];26 let index = 0;27 let lat = 0;28 let lng = 0;29 30 while (index < encoded.length) {31 // Decode latitude32 let shift = 0;33 let result = 0;34 let byte;35 do {36 byte = encoded.charCodeAt(index++) - 63;37 result |= (byte & 0x1f) << shift;38 shift += 5;39 } while (byte >= 0x20);40 const dlat = (result & 1) ? ~(result >> 1) : (result >> 1);41 lat += dlat;42 43 // Decode longitude44 shift = 0;45 result = 0;46 do {47 byte = encoded.charCodeAt(index++) - 63;48 result |= (byte & 0x1f) << shift;49 shift += 5;50 } while (byte >= 0x20);51 const dlng = (result & 1) ? ~(result >> 1) : (result >> 1);52 lng += dlng;53 54 // Polyline encoding uses precision of 5 decimal places55 points.push([lat / 1e5, lng / 1e5]);56 }57 58 return points;59}60 61/**62 * Fetch route geometries for the current schedule from the backend.63 * @returns {Promise<Object|null>} The geometries object or null if unavailable64 */65async function fetchRouteGeometries() {66 if (!scheduleId) return null;67 68 try {69 const response = await fetch(`/route-plans/${scheduleId}/geometry`);70 if (response.ok) {71 const data = await response.json();72 return data.geometries || null;73 }74 } catch (e) {75 console.warn('Could not fetch route geometries:', e);76 }77 return null;78}79 80/*************************************** Loading Overlay Functions **************************************/81 82function showLoadingOverlay(title = "Loading Demo Data", message = "Initializing...") {83 $("#loadingTitle").text(title);84 $("#loadingMessage").text(message);85 $("#loadingProgress").css("width", "0%");86 $("#loadingDetail").text("");87 $("#loadingOverlay").removeClass("hidden");88}89 90function hideLoadingOverlay() {91 $("#loadingOverlay").addClass("hidden");92}93 94function updateLoadingProgress(message, percent, detail = "") {95 $("#loadingMessage").text(message);96 $("#loadingProgress").css("width", `${percent}%`);97 $("#loadingDetail").text(detail);98}99 100/**101 * Load demo data with progress updates via Server-Sent Events.102 * Used when Real Roads mode is enabled.103 */104function loadDemoDataWithProgress(demoId) {105 return new Promise((resolve, reject) => {106 const routingMode = useRealRoads ? "real_roads" : "haversine";107 const url = `/demo-data/${demoId}/stream?routing=${routingMode}`;108 109 showLoadingOverlay(110 useRealRoads ? "Loading Real Road Data" : "Loading Demo Data",111 "Connecting..."112 );113 114 const eventSource = new EventSource(url);115 let solution = null;116 117 eventSource.onmessage = function(event) {118 try {119 const data = JSON.parse(event.data);120 121 if (data.event === "progress") {122 let statusIcon = "";123 if (data.phase === "network") {124 statusIcon = '<i class="fas fa-download me-2"></i>';125 } else if (data.phase === "routes") {126 statusIcon = '<i class="fas fa-route me-2"></i>';127 } else if (data.phase === "complete") {128 statusIcon = '<i class="fas fa-check-circle me-2 text-success"></i>';129 }130 updateLoadingProgress(data.message, data.percent, data.detail || "");131 } else if (data.event === "complete") {132 solution = data.solution;133 // Store geometries from the response if available134 if (data.geometries) {135 routeGeometries = data.geometries;136 }137 eventSource.close();138 hideLoadingOverlay();139 resolve(solution);140 } else if (data.event === "error") {141 eventSource.close();142 hideLoadingOverlay();143 reject(new Error(data.message));144 }145 } catch (e) {146 console.error("Error parsing SSE event:", e);147 }148 };149 150 eventSource.onerror = function(error) {151 eventSource.close();152 hideLoadingOverlay();153 reject(new Error("Connection lost while loading data"));154 };155 });156}157 158/*************************************** Map constants and variable definitions **************************************/159 160const homeLocationMarkerByIdMap = new Map();161const visitMarkerByIdMap = new Map();162 163const map = L.map("map", { doubleClickZoom: false }).setView(164 [51.505, -0.09],165 13,166);167const visitGroup = L.layerGroup().addTo(map);168const homeLocationGroup = L.layerGroup().addTo(map);169const routeGroup = L.layerGroup().addTo(map);170 171/************************************ Time line constants and variable definitions ************************************/172 173let byVehicleTimeline;174let byVisitTimeline;175const byVehicleGroupData = new vis.DataSet();176const byVehicleItemData = new vis.DataSet();177const byVisitGroupData = new vis.DataSet();178const byVisitItemData = new vis.DataSet();179 180const byVehicleTimelineOptions = {181 timeAxis: { scale: "hour" },182 orientation: { axis: "top" },183 xss: { disabled: true }, // Items are XSS safe through JQuery184 stack: false,185 stackSubgroups: false,186 zoomMin: 1000 * 60 * 60, // A single hour in milliseconds187 zoomMax: 1000 * 60 * 60 * 24, // A single day in milliseconds188};189 190const byVisitTimelineOptions = {191 timeAxis: { scale: "hour" },192 orientation: { axis: "top" },193 verticalScroll: true,194 xss: { disabled: true }, // Items are XSS safe through JQuery195 stack: false,196 stackSubgroups: false,197 zoomMin: 1000 * 60 * 60, // A single hour in milliseconds198 zoomMax: 1000 * 60 * 60 * 24, // A single day in milliseconds199};200 201/************************************ Initialize ************************************/202 203// Vehicle management state204let addingVehicleMode = false;205let pickingVehicleLocation = false;206let tempVehicleMarker = null;207let vehicleDeparturePicker = null;208 209// Route highlighting state210let highlightedVehicleId = null;211let routeNumberMarkers = []; // Markers showing 1, 2, 3... on route stops212 213 214$(document).ready(function () {215 replaceQuickstartSolverForgeAutoHeaderFooter();216 217 // Initialize timelines after DOM is ready with a small delay to ensure Bootstrap tabs are rendered218 setTimeout(function () {219 const byVehiclePanel = document.getElementById("byVehiclePanel");220 const byVisitPanel = document.getElementById("byVisitPanel");221 222 if (byVehiclePanel) {223 byVehicleTimeline = new vis.Timeline(224 byVehiclePanel,225 byVehicleItemData,226 byVehicleGroupData,227 byVehicleTimelineOptions,228 );229 }230 231 if (byVisitPanel) {232 byVisitTimeline = new vis.Timeline(233 byVisitPanel,234 byVisitItemData,235 byVisitGroupData,236 byVisitTimelineOptions,237 );238 }239 }, 100);240 241 L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {242 maxZoom: 19,243 attribution:244 '© <a href="https://www.openstreetmap.org/">OpenStreetMap</a> contributors',245 }).addTo(map);246 247 solveButton.click(solve);248 stopSolvingButton.click(stopSolving);249 analyzeButton.click(analyze);250 refreshSolvingButtons(false);251 252 // HACK to allow vis-timeline to work within Bootstrap tabs253 $("#byVehicleTab").on("shown.bs.tab", function (event) {254 if (byVehicleTimeline) {255 byVehicleTimeline.redraw();256 }257 });258 $("#byVisitTab").on("shown.bs.tab", function (event) {259 if (byVisitTimeline) {260 byVisitTimeline.redraw();261 }262 });263 264 // Map click handler - context aware265 map.on("click", function (e) {266 if (addingVehicleMode) {267 // Set vehicle home location268 setVehicleHomeLocation(e.latlng.lat, e.latlng.lng);269 } else if (!optimizing) {270 // Add new visit271 visitMarker = L.circleMarker(e.latlng);272 visitMarker.setStyle({ color: "green" });273 visitMarker.addTo(map);274 openRecommendationModal(e.latlng.lat, e.latlng.lng);275 }276 });277 278 // Remove visit marker when modal closes279 $("#newVisitModal").on("hidden.bs.modal", function () {280 if (visitMarker) {281 map.removeLayer(visitMarker);282 }283 });284 285 // Vehicle management286 $("#addVehicleBtn").click(openAddVehicleModal);287 $("#removeVehicleBtn").click(removeLastVehicle);288 $("#confirmAddVehicle").click(confirmAddVehicle);289 $("#pickLocationBtn").click(pickVehicleLocationOnMap);290 291 // Clean up when add vehicle modal closes (only if not picking location)292 $("#addVehicleModal").on("hidden.bs.modal", function () {293 if (!pickingVehicleLocation) {294 addingVehicleMode = false;295 if (tempVehicleMarker) {296 map.removeLayer(tempVehicleMarker);297 tempVehicleMarker = null;298 }299 }300 });301 302 // Real Roads toggle handler303 $(document).on('change', '#realRoadRouting', function() {304 useRealRoads = $(this).is(':checked');305 306 // If we have a demo dataset loaded, reload it with the new routing mode307 if (demoDataId && !optimizing) {308 scheduleId = null;309 initialized = false;310 homeLocationGroup.clearLayers();311 homeLocationMarkerByIdMap.clear();312 visitGroup.clearLayers();313 visitMarkerByIdMap.clear();314 routeGeometries = null;315 refreshRoutePlan();316 }317 });318 319 setupAjax();320 fetchDemoData();321});322 323/*************************************** Vehicle Management **************************************/324 325function openAddVehicleModal() {326 if (optimizing) {327 alert("Cannot add vehicles while solving. Please stop solving first.");328 return;329 }330 if (!loadedRoutePlan) {331 alert("Please load a dataset first.");332 return;333 }334 335 addingVehicleMode = true;336 337 // Suggest next vehicle name338 $("#vehicleName").val("").attr("placeholder", `e.g., ${getNextVehicleName()}`);339 340 // Set default values based on existing vehicles341 const existingVehicle = loadedRoutePlan.vehicles[0];342 if (existingVehicle) {343 $("#vehicleCapacity").val(existingVehicle.capacity || 25);344 const defaultLat = existingVehicle.homeLocation[0];345 const defaultLng = existingVehicle.homeLocation[1];346 $("#vehicleHomeLat").val(defaultLat.toFixed(6));347 $("#vehicleHomeLng").val(defaultLng.toFixed(6));348 }349 350 // Initialize departure time picker351 const tomorrow = JSJoda.LocalDate.now().plusDays(1);352 const defaultDeparture = tomorrow.atTime(JSJoda.LocalTime.of(6, 0));353 354 if (vehicleDeparturePicker) {355 vehicleDeparturePicker.destroy();356 }357 vehicleDeparturePicker = flatpickr("#vehicleDepartureTime", {358 enableTime: true,359 dateFormat: "Y-m-d H:i",360 defaultDate: defaultDeparture.format(JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm'))361 });362 363 $("#addVehicleModal").modal("show");364}365 366function pickVehicleLocationOnMap() {367 // Hide modal temporarily while user picks location368 pickingVehicleLocation = true;369 addingVehicleMode = true;370 $("#addVehicleModal").modal("hide");371 372 // Show hint on map373 $("#mapHint").html('<i class="fas fa-crosshairs"></i> Click on the map to set vehicle depot location').removeClass("hidden");374}375 376function setVehicleHomeLocation(lat, lng) {377 $("#vehicleHomeLat").val(lat.toFixed(6));378 $("#vehicleHomeLng").val(lng.toFixed(6));379 $("#vehicleLocationPreview").html(`<i class="fas fa-check text-success"></i> Location set: ${lat.toFixed(4)}, ${lng.toFixed(4)}`);380 381 // Show temporary marker382 if (tempVehicleMarker) {383 map.removeLayer(tempVehicleMarker);384 }385 tempVehicleMarker = L.marker([lat, lng], {386 icon: L.divIcon({387 className: 'temp-vehicle-marker',388 html: `<div style="389 background-color: #6366f1;390 border: 3px solid white;391 border-radius: 4px;392 width: 28px;393 height: 28px;394 display: flex;395 align-items: center;396 justify-content: center;397 box-shadow: 0 2px 4px rgba(0,0,0,0.4);398 animation: pulse 1s infinite;399 "><i class="fas fa-warehouse" style="color: white; font-size: 12px;"></i></div>`,400 iconSize: [28, 28],401 iconAnchor: [14, 14]402 })403 });404 tempVehicleMarker.addTo(map);405 406 // If we were picking location, re-open the modal407 if (pickingVehicleLocation) {408 pickingVehicleLocation = false;409 addingVehicleMode = false;410 $("#addVehicleModal").modal("show");411 // Restore normal map hint412 $("#mapHint").html('<i class="fas fa-mouse-pointer"></i> Click on the map to add a new visit');413 }414}415 416// Extended phonetic alphabet for generating vehicle names417const 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"];418 419function getNextVehicleName() {420 if (!loadedRoutePlan) return "Alpha";421 const usedNames = new Set(loadedRoutePlan.vehicles.map(v => v.name));422 for (const name of PHONETIC_NAMES) {423 if (!usedNames.has(name)) return name;424 }425 // Fallback if all names used426 return `Vehicle ${loadedRoutePlan.vehicles.length + 1}`;427}428 429async function confirmAddVehicle() {430 const vehicleName = $("#vehicleName").val().trim() || getNextVehicleName();431 const capacity = parseInt($("#vehicleCapacity").val());432 const lat = parseFloat($("#vehicleHomeLat").val());433 const lng = parseFloat($("#vehicleHomeLng").val());434 const departureTime = $("#vehicleDepartureTime").val();435 436 if (!capacity || capacity < 1) {437 alert("Please enter a valid capacity (minimum 1).");438 return;439 }440 if (isNaN(lat) || isNaN(lng)) {441 alert("Please set a valid home location by clicking on the map or entering coordinates.");442 return;443 }444 if (!departureTime) {445 alert("Please set a departure time.");446 return;447 }448 449 // Generate new vehicle ID450 const maxId = Math.max(...loadedRoutePlan.vehicles.map(v => parseInt(v.id)), 0);451 const newId = String(maxId + 1);452 453 // Format departure time454 const formattedDeparture = JSJoda.LocalDateTime.parse(455 departureTime,456 JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm')457 ).format(JSJoda.DateTimeFormatter.ISO_LOCAL_DATE_TIME);458 459 // Create new vehicle460 const newVehicle = {461 id: newId,462 name: vehicleName,463 capacity: capacity,464 homeLocation: [lat, lng],465 departureTime: formattedDeparture,466 visits: [],467 totalDemand: 0,468 totalDrivingTimeSeconds: 0,469 arrivalTime: formattedDeparture470 };471 472 // Add to solution473 loadedRoutePlan.vehicles.push(newVehicle);474 475 // Close modal and refresh476 $("#addVehicleModal").modal("hide");477 addingVehicleMode = false;478 479 if (tempVehicleMarker) {480 map.removeLayer(tempVehicleMarker);481 tempVehicleMarker = null;482 }483 484 // Refresh display485 await renderRoutes(loadedRoutePlan);486 renderTimelines(loadedRoutePlan);487 488 showNotification(`Vehicle "${vehicleName}" added successfully!`, "success");489}490 491async function removeLastVehicle() {492 if (optimizing) {493 alert("Cannot remove vehicles while solving. Please stop solving first.");494 return;495 }496 if (!loadedRoutePlan || loadedRoutePlan.vehicles.length <= 1) {497 alert("Cannot remove the last vehicle. At least one vehicle is required.");498 return;499 }500 501 const lastVehicle = loadedRoutePlan.vehicles[loadedRoutePlan.vehicles.length - 1];502 503 if (lastVehicle.visits && lastVehicle.visits.length > 0) {504 if (!confirm(`Vehicle ${lastVehicle.id} has ${lastVehicle.visits.length} assigned visits. These will become unassigned. Continue?`)) {505 return;506 }507 // Unassign visits from the vehicle508 lastVehicle.visits.forEach(visitId => {509 const visit = loadedRoutePlan.visits.find(v => v.id === visitId);510 if (visit) {511 visit.vehicle = null;512 visit.previousVisit = null;513 visit.nextVisit = null;514 visit.arrivalTime = null;515 visit.departureTime = null;516 }517 });518 }519 520 // Remove vehicle521 loadedRoutePlan.vehicles.pop();522 523 // Remove marker524 const marker = homeLocationMarkerByIdMap.get(lastVehicle.id);525 if (marker) {526 homeLocationGroup.removeLayer(marker);527 homeLocationMarkerByIdMap.delete(lastVehicle.id);528 }529 530 // Refresh display531 await renderRoutes(loadedRoutePlan);532 renderTimelines(loadedRoutePlan);533 534 showNotification(`Vehicle "${lastVehicle.name || lastVehicle.id}" removed.`, "info");535}536 537async function removeVehicle(vehicleId) {538 if (optimizing) {539 alert("Cannot remove vehicles while solving. Please stop solving first.");540 return;541 }542 543 const vehicleIndex = loadedRoutePlan.vehicles.findIndex(v => v.id === vehicleId);544 if (vehicleIndex === -1) return;545 546 if (loadedRoutePlan.vehicles.length <= 1) {547 alert("Cannot remove the last vehicle. At least one vehicle is required.");548 return;549 }550 551 const vehicle = loadedRoutePlan.vehicles[vehicleIndex];552 553 if (vehicle.visits && vehicle.visits.length > 0) {554 if (!confirm(`Vehicle ${vehicle.id} has ${vehicle.visits.length} assigned visits. These will become unassigned. Continue?`)) {555 return;556 }557 // Unassign visits558 vehicle.visits.forEach(visitId => {559 const visit = loadedRoutePlan.visits.find(v => v.id === visitId);560 if (visit) {561 visit.vehicle = null;562 visit.previousVisit = null;563 visit.nextVisit = null;564 visit.arrivalTime = null;565 visit.departureTime = null;566 }567 });568 }569 570 // Remove vehicle571 loadedRoutePlan.vehicles.splice(vehicleIndex, 1);572 573 // Remove marker574 const marker = homeLocationMarkerByIdMap.get(vehicleId);575 if (marker) {576 homeLocationGroup.removeLayer(marker);577 homeLocationMarkerByIdMap.delete(vehicleId);578 }579 580 // Refresh display581 await renderRoutes(loadedRoutePlan);582 renderTimelines(loadedRoutePlan);583 584 showNotification(`Vehicle "${vehicle.name || vehicleId}" removed.`, "info");585}586 587function showNotification(message, type = "info") {588 const alertClass = type === "success" ? "alert-success" : type === "error" ? "alert-danger" : "alert-info";589 const icon = type === "success" ? "fa-check-circle" : type === "error" ? "fa-exclamation-circle" : "fa-info-circle";590 591 const notification = $(`592 <div class="alert ${alertClass} alert-dismissible fade show" role="alert" style="min-width: 300px;">593 <i class="fas ${icon} me-2"></i>${message}594 <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>595 </div>596 `);597 598 $("#notificationPanel").append(notification);599 600 // Auto-dismiss after 3 seconds601 setTimeout(() => {602 notification.alert('close');603 }, 3000);604}605 606/*************************************** Route Highlighting **************************************/607 608function toggleVehicleHighlight(vehicleId) {609 if (highlightedVehicleId === vehicleId) {610 // Already highlighted - clear it611 clearRouteHighlight();612 } else {613 // Highlight this vehicle's route614 highlightVehicleRoute(vehicleId);615 }616}617 618function clearRouteHighlight() {619 // Remove number markers620 routeNumberMarkers.forEach(marker => map.removeLayer(marker));621 routeNumberMarkers = [];622 623 // Reset all vehicle icons to normal and restore opacity624 if (loadedRoutePlan) {625 loadedRoutePlan.vehicles.forEach(vehicle => {626 const marker = homeLocationMarkerByIdMap.get(vehicle.id);627 if (marker) {628 marker.setIcon(createVehicleHomeIcon(vehicle, false));629 marker.setOpacity(1);630 }631 });632 633 // Reset all visit markers to normal and restore opacity634 loadedRoutePlan.visits.forEach(visit => {635 const marker = visitMarkerByIdMap.get(visit.id);636 if (marker) {637 const customerType = getCustomerType(visit);638 const isAssigned = visit.vehicle != null;639 marker.setIcon(createCustomerTypeIcon(customerType, isAssigned, false));640 marker.setOpacity(1);641 }642 });643 }644 645 // Reset route lines646 renderRouteLines();647 648 // Update vehicle table highlighting649 $("#vehicles tr").removeClass("table-active");650 651 highlightedVehicleId = null;652}653 654function highlightVehicleRoute(vehicleId) {655 // Clear any existing highlight first656 clearRouteHighlight();657 658 highlightedVehicleId = vehicleId;659 660 if (!loadedRoutePlan) return;661 662 const vehicle = loadedRoutePlan.vehicles.find(v => v.id === vehicleId);663 if (!vehicle) return;664 665 const vehicleColor = colorByVehicle(vehicle);666 667 // Highlight the vehicle's home marker668 const homeMarker = homeLocationMarkerByIdMap.get(vehicleId);669 if (homeMarker) {670 homeMarker.setIcon(createVehicleHomeIcon(vehicle, true));671 }672 673 // Dim other vehicles674 loadedRoutePlan.vehicles.forEach(v => {675 if (v.id !== vehicleId) {676 const marker = homeLocationMarkerByIdMap.get(v.id);677 if (marker) {678 marker.setIcon(createVehicleHomeIcon(v, false));679 marker.setOpacity(0.3);680 }681 }682 });683 684 // Get visit order for this vehicle685 const visitByIdMap = new Map(loadedRoutePlan.visits.map(v => [v.id, v]));686 const vehicleVisits = vehicle.visits.map(visitId => visitByIdMap.get(visitId)).filter(v => v);687 688 // Highlight and number the visits on this route689 let stopNumber = 1;690 vehicleVisits.forEach(visit => {691 const marker = visitMarkerByIdMap.get(visit.id);692 if (marker) {693 const customerType = getCustomerType(visit);694 marker.setIcon(createCustomerTypeIcon(customerType, true, true, vehicleColor));695 marker.setOpacity(1);696 697 // Add number marker698 const numberMarker = L.marker(visit.location, {699 icon: createRouteNumberIcon(stopNumber, vehicleColor),700 interactive: false,701 zIndexOffset: 1000702 });703 numberMarker.addTo(map);704 routeNumberMarkers.push(numberMarker);705 stopNumber++;706 }707 });708 709 // Dim visits not on this route710 loadedRoutePlan.visits.forEach(visit => {711 if (!vehicle.visits.includes(visit.id)) {712 const marker = visitMarkerByIdMap.get(visit.id);713 if (marker) {714 marker.setOpacity(0.25);715 }716 }717 });718 719 // Highlight just this route, dim others720 renderRouteLines(vehicleId);721 722 // Highlight the row in the vehicle table723 $("#vehicles tr").removeClass("table-active");724 $(`#vehicle-row-${vehicleId}`).addClass("table-active");725 726 // Add start marker (S) at depot727 const startMarker = L.marker(vehicle.homeLocation, {728 icon: createRouteNumberIcon("S", vehicleColor),729 interactive: false,730 zIndexOffset: 1000731 });732 startMarker.addTo(map);733 routeNumberMarkers.push(startMarker);734}735 736function createRouteNumberIcon(number, color) {737 return L.divIcon({738 className: 'route-number-marker',739 html: `<div style="740 background-color: ${color};741 color: white;742 font-weight: bold;743 font-size: 12px;744 width: 22px;745 height: 22px;746 border-radius: 50%;747 border: 2px solid white;748 display: flex;749 align-items: center;750 justify-content: center;751 box-shadow: 0 2px 4px rgba(0,0,0,0.4);752 margin-left: 16px;753 margin-top: -28px;754 ">${number}</div>`,755 iconSize: [22, 22],756 iconAnchor: [0, 0]757 });758}759 760async function renderRouteLines(highlightedId = null) {761 routeGroup.clearLayers();762 763 if (!loadedRoutePlan) return;764 765 // Fetch geometries during solving (routes change)766 if (scheduleId) {767 routeGeometries = await fetchRouteGeometries();768 }769 770 const visitByIdMap = new Map(loadedRoutePlan.visits.map(visit => [visit.id, visit]));771 772 for (let vehicle of loadedRoutePlan.vehicles) {773 const homeLocation = vehicle.homeLocation;774 const locations = vehicle.visits.map(visitId => visitByIdMap.get(visitId)?.location).filter(l => l);775 776 const isHighlighted = highlightedId === null || vehicle.id === highlightedId;777 const color = colorByVehicle(vehicle);778 const weight = isHighlighted && highlightedId !== null ? 5 : 3;779 const opacity = isHighlighted ? 1 : 0.2;780 781 const vehicleGeometry = routeGeometries?.[vehicle.id];782 783 if (vehicleGeometry && vehicleGeometry.length > 0) {784 // Draw real road routes using decoded polylines785 for (const encodedSegment of vehicleGeometry) {786 if (encodedSegment) {787 const points = decodePolyline(encodedSegment);788 if (points.length > 0) {789 L.polyline(points, {790 color: color,791 weight: weight,792 opacity: opacity793 }).addTo(routeGroup);794 }795 }796 }797 } else if (locations.length > 0) {798 // Fallback to straight lines if no geometry available799 L.polyline([homeLocation, ...locations, homeLocation], {800 color: color,801 weight: weight,802 opacity: opacity803 }).addTo(routeGroup);804 }805 }806}807 808function colorByVehicle(vehicle) {809 return vehicle === null ? null : pickColor("vehicle" + vehicle.id);810}811 812// Customer type definitions matching demo_data.py813const CUSTOMER_TYPES = {814 RESTAURANT: { label: "Restaurant", icon: "fa-utensils", color: "#f59e0b", windowStart: "06:00", windowEnd: "10:00", minService: 20, maxService: 40 },815 BUSINESS: { label: "Business", icon: "fa-building", color: "#3b82f6", windowStart: "09:00", windowEnd: "17:00", minService: 15, maxService: 30 },816 RESIDENTIAL: { label: "Residential", icon: "fa-home", color: "#10b981", windowStart: "17:00", windowEnd: "20:00", minService: 5, maxService: 10 },817};818 819function getCustomerType(visit) {820 const startTime = showTimeOnly(visit.minStartTime).toString();821 const endTime = showTimeOnly(visit.maxEndTime).toString();822 823 for (const [type, config] of Object.entries(CUSTOMER_TYPES)) {824 if (startTime === config.windowStart && endTime === config.windowEnd) {825 return { type, ...config };826 }827 }828 return { type: "UNKNOWN", label: "Custom", icon: "fa-question", color: "#6b7280", windowStart: startTime, windowEnd: endTime };829}830 831function formatDrivingTime(drivingTimeInSeconds) {832 return `${Math.floor(drivingTimeInSeconds / 3600)}h ${Math.round((drivingTimeInSeconds % 3600) / 60)}m`;833}834 835function homeLocationPopupContent(vehicle) {836 const color = colorByVehicle(vehicle);837 const visitCount = vehicle.visits ? vehicle.visits.length : 0;838 const vehicleName = vehicle.name || `Vehicle ${vehicle.id}`;839 return `<div style="min-width: 150px;">840 <h5 style="color: ${color};"><i class="fas fa-truck"></i> ${vehicleName}</h5>841 <p class="mb-1"><strong>Depot Location</strong></p>842 <p class="mb-1"><i class="fas fa-box"></i> Capacity: ${vehicle.capacity}</p>843 <p class="mb-1"><i class="fas fa-route"></i> Visits: ${visitCount}</p>844 <p class="mb-0"><i class="fas fa-clock"></i> Departs: ${showTimeOnly(vehicle.departureTime)}</p>845 </div>`;846}847 848function visitPopupContent(visit) {849 const customerType = getCustomerType(visit);850 const serviceDurationMinutes = Math.round(visit.serviceDuration / 60);851 const arrival = visit.arrivalTime852 ? `<h6>Arrival at ${showTimeOnly(visit.arrivalTime)}.</h6>`853 : "";854 return `<h5><i class="fas ${customerType.icon}" style="color: ${customerType.color}"></i> ${visit.name}</h5>855 <h6><span class="badge" style="background-color: ${customerType.color}">${customerType.label}</span></h6>856 <h6>Cargo: ${visit.demand} units</h6>857 <h6>Service time: ${serviceDurationMinutes} min</h6>858 <h6>Window: ${showTimeOnly(visit.minStartTime)} - ${showTimeOnly(visit.maxEndTime)}</h6>859 ${arrival}`;860}861 862function showTimeOnly(localDateTimeString) {863 return JSJoda.LocalDateTime.parse(localDateTimeString).toLocalTime();864}865 866function createVehicleHomeIcon(vehicle, isHighlighted = false) {867 const color = colorByVehicle(vehicle);868 const size = isHighlighted ? 36 : 28;869 const fontSize = isHighlighted ? 14 : 11;870 const borderWidth = isHighlighted ? 4 : 3;871 const shadow = isHighlighted872 ? `0 0 0 4px ${color}40, 0 4px 8px rgba(0,0,0,0.5)`873 : '0 2px 4px rgba(0,0,0,0.4)';874 875 return L.divIcon({876 className: 'vehicle-home-marker',877 html: `<div style="878 background-color: ${color};879 border: ${borderWidth}px solid white;880 border-radius: 50%;881 width: ${size}px;882 height: ${size}px;883 display: flex;884 align-items: center;885 justify-content: center;886 box-shadow: ${shadow};887 transition: all 0.2s ease;888 "><i class="fas fa-truck" style="color: white; font-size: ${fontSize}px;"></i></div>`,889 iconSize: [size, size],890 iconAnchor: [size/2, size/2],891 popupAnchor: [0, -size/2]892 });893}894 895function getHomeLocationMarker(vehicle) {896 let marker = homeLocationMarkerByIdMap.get(vehicle.id);897 if (marker) {898 marker.setIcon(createVehicleHomeIcon(vehicle));899 return marker;900 }901 marker = L.marker(vehicle.homeLocation, {902 icon: createVehicleHomeIcon(vehicle)903 });904 marker.addTo(homeLocationGroup).bindPopup();905 homeLocationMarkerByIdMap.set(vehicle.id, marker);906 return marker;907}908 909function createCustomerTypeIcon(customerType, isAssigned = false, isHighlighted = false, highlightColor = null) {910 const borderColor = isHighlighted && highlightColor911 ? highlightColor912 : (isAssigned ? customerType.color : '#6b7280');913 const size = isHighlighted ? 38 : 32;914 const fontSize = isHighlighted ? 16 : 14;915 const borderWidth = isHighlighted ? 4 : 3;916 const shadow = isHighlighted917 ? `0 0 0 4px ${highlightColor}40, 0 4px 8px rgba(0,0,0,0.4)`918 : '0 2px 4px rgba(0,0,0,0.3)';919 920 return L.divIcon({921 className: 'customer-marker',922 html: `<div style="923 background-color: white;924 border: ${borderWidth}px solid ${borderColor};925 border-radius: 50%;926 width: ${size}px;927 height: ${size}px;928 display: flex;929 align-items: center;930 justify-content: center;931 box-shadow: ${shadow};932 transition: all 0.2s ease;933 "><i class="fas ${customerType.icon}" style="color: ${customerType.color}; font-size: ${fontSize}px;"></i></div>`,934 iconSize: [size, size],935 iconAnchor: [size/2, size/2],936 popupAnchor: [0, -size/2]937 });938}939 940function getVisitMarker(visit) {941 let marker = visitMarkerByIdMap.get(visit.id);942 const customerType = getCustomerType(visit);943 const isAssigned = visit.vehicle != null;944 945 if (marker) {946 // Update icon if assignment status changed947 marker.setIcon(createCustomerTypeIcon(customerType, isAssigned));948 return marker;949 }950 951 marker = L.marker(visit.location, {952 icon: createCustomerTypeIcon(customerType, isAssigned)953 });954 marker.addTo(visitGroup).bindPopup();955 visitMarkerByIdMap.set(visit.id, marker);956 return marker;957}958 959async function renderRoutes(solution) {960 if (!initialized) {961 const bounds = [solution.southWestCorner, solution.northEastCorner];962 map.fitBounds(bounds);963 }964 // Vehicles965 vehiclesTable.children().remove();966 const canRemove = solution.vehicles.length > 1;967 solution.vehicles.forEach(function (vehicle) {968 getHomeLocationMarker(vehicle).setPopupContent(969 homeLocationPopupContent(vehicle),970 );971 const { id, capacity, totalDemand, totalDrivingTimeSeconds } = vehicle;972 const percentage = Math.min((totalDemand / capacity) * 100, 100);973 const overCapacity = totalDemand > capacity;974 const color = colorByVehicle(vehicle);975 const progressBarColor = overCapacity ? 'bg-danger' : '';976 const isHighlighted = highlightedVehicleId === id;977 const visitCount = vehicle.visits ? vehicle.visits.length : 0;978 const vehicleName = vehicle.name || `Vehicle ${id}`;979 980 vehiclesTable.append(`981 <tr id="vehicle-row-${id}" class="vehicle-row ${isHighlighted ? 'table-active' : ''}" style="cursor: pointer;">982 <td onclick="toggleVehicleHighlight('${id}')">983 <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;' : ''}">984 <i class="fas fa-truck" style="color: white; font-size: 0.65rem;"></i>985 </div>986 </td>987 <td onclick="toggleVehicleHighlight('${id}')">988 <strong>${vehicleName}</strong>989 <br><small class="text-muted">${visitCount} stops</small>990 </td>991 <td onclick="toggleVehicleHighlight('${id}')">992 <div class="progress" style="height: 18px;" data-bs-toggle="tooltip" data-bs-placement="left"993 title="Cargo: ${totalDemand} / Capacity: ${capacity}${overCapacity ? ' (OVER CAPACITY!)' : ''}">994 <div class="progress-bar ${progressBarColor}" role="progressbar" style="width: ${percentage}%; font-size: 0.7rem; transition: width 0.3s ease;">995 ${totalDemand}/${capacity}996 </div>997 </div>998 </td>999 <td onclick="toggleVehicleHighlight('${id}')" style="font-size: 0.85rem;">1000 ${formatDrivingTime(totalDrivingTimeSeconds)}1001 </td>1002 <td>1003 ${canRemove ? `<button class="btn btn-sm btn-outline-danger p-0 px-1" onclick="event.stopPropagation(); removeVehicle('${id}')" title="Remove vehicle ${vehicleName}">1004 <i class="fas fa-times" style="font-size: 0.7rem;"></i>1005 </button>` : ''}1006 </td>1007 </tr>`);1008 });1009 // Visits1010 solution.visits.forEach(function (visit) {1011 getVisitMarker(visit).setPopupContent(visitPopupContent(visit));1012 });1013 // Route - use the dedicated function which handles highlighting (await to ensure geometries load)1014 await renderRouteLines(highlightedVehicleId);1015 1016 // Summary1017 $("#score").text(solution.score ? `Score: ${solution.score}` : "Score: ?");1018 $("#drivingTime").text(formatDrivingTime(solution.totalDrivingTimeSeconds));1019}1020 1021function renderTimelines(routePlan) {1022 byVehicleGroupData.clear();1023 byVisitGroupData.clear();1024 byVehicleItemData.clear();1025 byVisitItemData.clear();1026 1027 // Build lookup maps for O(1) access1028 const vehicleById = new Map(routePlan.vehicles.map(v => [v.id, v]));1029 const visitById = new Map(routePlan.visits.map(v => [v.id, v]));1030 const visitOrderMap = new Map();1031 1032 // Build stop order for each visit1033 routePlan.vehicles.forEach(vehicle => {1034 vehicle.visits.forEach((visitId, index) => {1035 visitOrderMap.set(visitId, index + 1);1036 });1037 });1038 1039 // Vehicle groups with names and status summary1040 $.each(routePlan.vehicles, function (index, vehicle) {1041 const vehicleName = vehicle.name || `Vehicle ${vehicle.id}`;1042 const { totalDemand, capacity } = vehicle;1043 const percentage = Math.min((totalDemand / capacity) * 100, 100);1044 const overCapacity = totalDemand > capacity;1045 1046 // Count late visits for this vehicle1047 const vehicleVisits = vehicle.visits.map(id => visitById.get(id)).filter(v => v);1048 const lateCount = vehicleVisits.filter(v => {1049 if (!v.departureTime) return false;1050 const departure = JSJoda.LocalDateTime.parse(v.departureTime);1051 const maxEnd = JSJoda.LocalDateTime.parse(v.maxEndTime);1052 return departure.isAfter(maxEnd);1053 }).length;1054 1055 const statusIcon = lateCount > 01056 ? `<i class="fas fa-exclamation-triangle timeline-status-late timeline-status-icon" title="${lateCount} late"></i>`1057 : vehicle.visits.length > 01058 ? `<i class="fas fa-check-circle timeline-status-ontime timeline-status-icon" title="All on-time"></i>`1059 : '';1060 1061 const progressBarClass = overCapacity ? 'bg-danger' : '';1062 1063 const vehicleWithLoad = `1064 <h5 class="card-title mb-1">${vehicleName}${statusIcon}</h5>1065 <div class="progress" style="height: 16px;" title="Cargo: ${totalDemand} / ${capacity}">1066 <div class="progress-bar ${progressBarClass}" role="progressbar" style="width: ${percentage}%">1067 ${totalDemand}/${capacity}1068 </div>1069 </div>`;1070 byVehicleGroupData.add({ id: vehicle.id, content: vehicleWithLoad });1071 });1072 1073 $.each(routePlan.visits, function (index, visit) {1074 const minStartTime = JSJoda.LocalDateTime.parse(visit.minStartTime);1075 const maxEndTime = JSJoda.LocalDateTime.parse(visit.maxEndTime);1076 const serviceDuration = JSJoda.Duration.ofSeconds(visit.serviceDuration);1077 const customerType = getCustomerType(visit);1078 const stopNumber = visitOrderMap.get(visit.id);1079 1080 const visitGroupElement = $(`<div/>`).append(1081 $(`<h5 class="card-title mb-1"/>`).html(1082 `<i class="fas ${customerType.icon}" style="color: ${customerType.color}"></i> ${visit.name}`1083 ),1084 ).append(1085 $(`<small class="text-muted"/>`).text(customerType.label)1086 );1087 byVisitGroupData.add({1088 id: visit.id,1089 content: visitGroupElement.html(),1090 });1091 1092 // Time window per visit.1093 byVisitItemData.add({1094 id: visit.id + "_readyToDue",1095 group: visit.id,1096 start: visit.minStartTime,1097 end: visit.maxEndTime,1098 type: "background",1099 style: "background-color: #8AE23433",1100 });1101 1102 if (visit.vehicle == null) {1103 const byJobJobElement = $(`<div/>`).append(1104 $(`<span/>`).html(`<i class="fas fa-exclamation-circle text-danger me-1"></i>Unassigned`),1105 );1106 1107 // Unassigned are shown at the beginning of the visit's time window; the length is the service duration.1108 byVisitItemData.add({1109 id: visit.id + "_unassigned",1110 group: visit.id,1111 content: byJobJobElement.html(),1112 start: minStartTime.toString(),1113 end: minStartTime.plus(serviceDuration).toString(),1114 style: "background-color: #EF292999",1115 });1116 } else {1117 const arrivalTime = JSJoda.LocalDateTime.parse(visit.arrivalTime);1118 const beforeReady = arrivalTime.isBefore(minStartTime);1119 const departureTime = JSJoda.LocalDateTime.parse(visit.departureTime);1120 const afterDue = departureTime.isAfter(maxEndTime);1121 1122 // Get vehicle info for display1123 const vehicleInfo = vehicleById.get(visit.vehicle);1124 const vehicleName = vehicleInfo ? (vehicleInfo.name || `Vehicle ${visit.vehicle}`) : `Vehicle ${visit.vehicle}`;1125 1126 // Stop badge for service segment1127 const stopBadge = stopNumber ? `<span class="timeline-stop-badge">${stopNumber}</span>` : '';1128 1129 // Status icon based on timing1130 const statusIcon = afterDue1131 ? `<i class="fas fa-exclamation-triangle timeline-status-late timeline-status-icon" title="Late"></i>`1132 : `<i class="fas fa-check timeline-status-ontime timeline-status-icon" title="On-time"></i>`;1133 1134 const byVehicleElement = $(`<div/>`)1135 .append($(`<span/>`).html(1136 `${stopBadge}<i class="fas ${customerType.icon}" style="color: ${customerType.color}"></i> ${visit.name}${statusIcon}`1137 ));1138 1139 const byVisitElement = $(`<div/>`)1140 .append(1141 $(`<span/>`).html(1142 `${stopBadge}${vehicleName}${statusIcon}`1143 ),1144 );1145 1146 const byVehicleTravelElement = $(`<div/>`).append(1147 $(`<span/>`).html(`<i class="fas fa-route text-warning me-1"></i>Travel`),1148 );1149 1150 const previousDeparture = arrivalTime.minusSeconds(1151 visit.drivingTimeSecondsFromPreviousStandstill,1152 );1153 byVehicleItemData.add({1154 id: visit.id + "_travel",1155 group: visit.vehicle,1156 subgroup: visit.vehicle,1157 content: byVehicleTravelElement.html(),1158 start: previousDeparture.toString(),1159 end: visit.arrivalTime,1160 style: "background-color: #f7dd8f90",1161 });1162 1163 if (beforeReady) {1164 const byVehicleWaitElement = $(`<div/>`).append(1165 $(`<span/>`).html(`<i class="fas fa-clock timeline-status-early me-1"></i>Wait`),1166 );1167 1168 byVehicleItemData.add({1169 id: visit.id + "_wait",1170 group: visit.vehicle,1171 subgroup: visit.vehicle,1172 content: byVehicleWaitElement.html(),1173 start: visit.arrivalTime,1174 end: visit.minStartTime,1175 style: "background-color: #93c5fd80",1176 });1177 }1178 1179 let serviceElementBackground = afterDue ? "#EF292999" : "#83C15955";1180 1181 byVehicleItemData.add({1182 id: visit.id + "_service",1183 group: visit.vehicle,1184 subgroup: visit.vehicle,1185 content: byVehicleElement.html(),1186 start: visit.startServiceTime,1187 end: visit.departureTime,1188 style: "background-color: " + serviceElementBackground,1189 });1190 byVisitItemData.add({1191 id: visit.id,1192 group: visit.id,1193 content: byVisitElement.html(),1194 start: visit.startServiceTime,1195 end: visit.departureTime,1196 style: "background-color: " + serviceElementBackground,1197 });1198 }1199 });1200 