From 00af87f7cb1f90cf2e0b8994da3b6da2c470bc3b Mon Sep 17 00:00:00 2001 From: Rosas Behoundja Date: Mon, 9 Feb 2026 13:03:05 +0100 Subject: [PATCH 1/2] feat: Enhance visualization tooltips and add exchange tracking functionality --- pages/static/css/visualisation.css | 123 ++++++++++++- pages/static/js/visualisation.js | 272 ++++++++++++++++++++++++++++- pages/visualisation.html | 4 + 3 files changed, 385 insertions(+), 14 deletions(-) diff --git a/pages/static/css/visualisation.css b/pages/static/css/visualisation.css index 83b3b97..96047fd 100644 --- a/pages/static/css/visualisation.css +++ b/pages/static/css/visualisation.css @@ -527,17 +527,130 @@ input[type="range"]::-moz-range-thumb { /* Tooltip */ .tooltip { - position: absolute; + position: fixed; background: var(--surface); border: 1px solid var(--border); - border-radius: 8px; - padding: 10px 14px; + border-radius: 10px; + padding: 0; font-size: 12px; pointer-events: none; opacity: 0; transition: opacity 0.15s; - z-index: 100; - box-shadow: var(--shadow); + z-index: 1000; + box-shadow: var(--shadow-lg); + min-width: 180px; + max-width: 260px; +} + +.tooltip-header { + padding: 10px 14px; + font-weight: 600; + font-size: 13px; + border-bottom: 1px solid var(--border); + background: var(--surface-hover); + border-radius: 10px 10px 0 0; +} + +.tooltip-content { + padding: 10px 14px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.tooltip-product { + display: flex; + align-items: center; + gap: 8px; +} + +.tooltip-product-label { + font-size: 10px; + font-weight: 600; + padding: 2px 6px; + border-radius: 4px; + min-width: 28px; + text-align: center; +} + +.tooltip-bar-bg { + flex: 1; + height: 6px; + background: var(--input-bg); + border-radius: 3px; + overflow: hidden; +} + +.tooltip-bar { + height: 100%; + border-radius: 3px; + transition: width 0.2s; +} + +.tooltip-qty { + font-size: 11px; + font-weight: 500; + color: var(--text-muted); + min-width: 60px; + text-align: right; +} + +.tooltip-qty.excess { + color: #f59e0b; +} + +.tooltip-qty.shortage { + color: #ef4444; +} + +.tooltip-product.excess { + background: rgba(245, 158, 11, 0.1); + margin: -4px -8px; + padding: 4px 8px; + border-radius: 4px; +} + +.tooltip-product.shortage { + background: rgba(239, 68, 68, 0.1); + margin: -4px -8px; + padding: 4px 8px; + border-radius: 4px; +} + +.tooltip-summary { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 8px; + border-top: 1px solid var(--border); + margin-top: 4px; + font-size: 11px; + color: var(--text-muted); +} + +.tooltip-excess-badge { + background: rgba(245, 158, 11, 0.2); + color: #f59e0b; + padding: 2px 6px; + border-radius: 4px; + font-weight: 600; + font-size: 10px; +} + +.tooltip-shortage-badge { + background: rgba(239, 68, 68, 0.2); + color: #ef4444; + padding: 2px 6px; + border-radius: 4px; + font-weight: 600; + font-size: 10px; +} + +.tooltip-empty { + color: var(--text-dim); + font-style: italic; + text-align: center; + padding: 4px 0; } /* ═══════════════════════════════════════════════════════════════ diff --git a/pages/static/js/visualisation.js b/pages/static/js/visualisation.js index 71d675d..477e2fe 100644 --- a/pages/static/js/visualisation.js +++ b/pages/static/js/visualisation.js @@ -30,6 +30,17 @@ let stationVisits = {}; let depotWithdrawals = {}; // Track withdrawals from depots per product let dataLoaded = false; +// Exchange tracking +let totalExchanges = 0; +let currentExchanges = 0; + +// Station deliveries tracking (per product) +let stationDeliveriesPerProduct = {}; // { stationId: { productIdx: delivered } } +let stationDemandsPerProduct = {}; // { stationId: { productIdx: demand } } + +// Tooltip state +let hoveredNode = null; + const TRUCK_COLORS = [ '#6366f1', '#22d3ee', '#f472b6', '#34d399', '#fbbf24', '#f87171', '#a78bfa', '#2dd4bf', '#fb923c', '#e879f9' @@ -195,6 +206,7 @@ function parseDatInstance(text) { } // Parse Stations + const stationDemandsPerProductLocal = {}; // Track per-product demands for (let i = 0; i < numStations; i++) { const parts = lines[lineIdx++].split(/\s+/); const id = parts[0]; @@ -202,11 +214,15 @@ function parseDatInstance(text) { const y = parseFloat(parts[2]); locations[`S${id}`] = [x, y]; - // Sum demands for visualization + // Store per-product demands + const productDemands = []; let totalDemand = 0; for (let p = 0; p < numProducts; p++) { - totalDemand += parseFloat(parts[3 + p] || 0); + const demand = parseFloat(parts[3 + p] || 0); + productDemands.push(demand); + totalDemand += demand; } + stationDemandsPerProductLocal[`S${id}`] = productDemands; demands.push({ station: `S${id}`, quantity: totalDemand }); } @@ -214,6 +230,7 @@ function parseDatInstance(text) { locations, demands, depotSupplies, + stationDemandsPerProduct: stationDemandsPerProductLocal, num_vehicles: numVehicles, num_depots: numDepots, num_products: numProducts, @@ -410,6 +427,19 @@ function initData() { stationVisits = {}; depotWithdrawals = {}; + // Reset exchange tracking + totalExchanges = solution.metrics?.product_changes || 0; + currentExchanges = 0; + + // Reset station per-product tracking + stationDemandsPerProduct = instance.stationDemandsPerProduct || {}; + stationDeliveriesPerProduct = {}; + + // Initialize station deliveries + Object.keys(stationDemandsPerProduct).forEach(stationId => { + stationDeliveriesPerProduct[stationId] = stationDemandsPerProduct[stationId].map(() => 0); + }); + // Reset pan/zoom panOffset = { x: 0, y: 0 }; zoomLevel = 1; @@ -456,6 +486,7 @@ function initData() { // Update Stats const metrics = solution.metrics || {}; document.getElementById('stat-dist').textContent = (metrics.total_cost || solution.objective || 0).toFixed(2); + document.getElementById('stat-exchanges').textContent = `0/${totalExchanges}`; document.getElementById('stat-trucks').textContent = metrics.vehicles_used || trucks.length; let totalSegs = trucks.reduce((sum, t) => sum + t.segments.length, 0); @@ -921,10 +952,84 @@ function updateUI() { document.getElementById('progressText').textContent = `${Math.floor(progress)}/${maxProgress}`; + // Calculate current exchanges and station deliveries based on progress + calculateCurrentExchangesAndDeliveries(); + + // Update exchanges display + document.getElementById('stat-exchanges').textContent = `${currentExchanges}/${totalExchanges}`; + // Update depot inventory panel updateDepotInventoryPanel(); } +// Calculate exchanges and station deliveries based on current progress +function calculateCurrentExchangesAndDeliveries() { + currentExchanges = 0; + + // Reset station deliveries + Object.keys(stationDemandsPerProduct).forEach(stationId => { + stationDeliveriesPerProduct[stationId] = stationDemandsPerProduct[stationId].map(() => 0); + }); + + const numProducts = instance.num_products || 1; + const numGarages = instance.num_garages || 1; + const numDepots = instance.num_depots || 2; + const numStations = instance.num_stations || 5; + + // Parse product lines from solution if available + const productLines = solution.productLines || {}; + + trucks.forEach((t, truckIdx) => { + const vehicleKey = t.id; + const completedSegs = Math.floor(Math.min(progress, t.segments.length)); + + let lastProduct = null; + + for (let i = 0; i < completedSegs; i++) { + const toNode = t.segments[i][1]; + + // Check for product changes (exchanges) + // In absence of detailed product tracking, estimate based on segment transitions + // A product change typically happens when visiting different types of stations + + // Track deliveries to stations + if (toNode && toNode.startsWith('S')) { + const stationId = toNode; + // Simulate delivery - distribute evenly across products for now + // In a real implementation, this would use the solution's product line + if (stationDeliveriesPerProduct[stationId]) { + const stationDemand = stationDemandsPerProduct[stationId] || []; + stationDemand.forEach((demand, pIdx) => { + if (demand > 0) { + // Calculate how much should be delivered per visit + const totalVisits = stationVisits[stationId] || 1; + const deliveryPerVisit = demand / totalVisits; + stationDeliveriesPerProduct[stationId][pIdx] += deliveryPerVisit; + } + }); + } + } + } + + // Estimate exchanges: count transitions between depot visits + // (simplified - actual exchanges depend on product line in solution) + let depotVisitCount = 0; + for (let i = 0; i < completedSegs; i++) { + const toNode = t.segments[i][1]; + if (toNode && toNode.startsWith('D')) { + depotVisitCount++; + if (depotVisitCount > 1) { + // Potential product change + currentExchanges++; + } + } + } + }); + + // Cap exchanges at total (estimation may overshoot) + currentExchanges = Math.min(currentExchanges, totalExchanges); +} + // ═══════════════════════════════════════════════════════════════ // SIDEBAR TOGGLE // ═══════════════════════════════════════════════════════════════ @@ -959,16 +1064,25 @@ canvas.addEventListener('mousedown', (e) => { }); canvas.addEventListener('mousemove', (e) => { - if (!isPanning) return; + const rect = canvas.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; - const dx = e.clientX - lastMousePos.x; - const dy = e.clientY - lastMousePos.y; + // Handle panning + if (isPanning) { + const dx = e.clientX - lastMousePos.x; + const dy = e.clientY - lastMousePos.y; - panOffset.x += dx; - panOffset.y += dy; + panOffset.x += dx; + panOffset.y += dy; - lastMousePos = { x: e.clientX, y: e.clientY }; - draw(); + lastMousePos = { x: e.clientX, y: e.clientY }; + draw(); + return; + } + + // Handle tooltip for station hover + handleNodeHover(mouseX, mouseY, e.clientX, e.clientY); }); canvas.addEventListener('mouseup', () => { @@ -979,6 +1093,7 @@ canvas.addEventListener('mouseup', () => { canvas.addEventListener('mouseleave', () => { isPanning = false; canvas.style.cursor = 'grab'; + hideTooltip(); }); canvas.addEventListener('wheel', (e) => { @@ -1175,6 +1290,145 @@ function updateDepotInventoryPanel() { panel.innerHTML = html; } +// ═══════════════════════════════════════════════════════════════ +// STATION TOOLTIP +// ═══════════════════════════════════════════════════════════════ + +function handleNodeHover(mouseX, mouseY, clientX, clientY) { + if (!dataLoaded) return; + + const tooltip = document.getElementById('tooltip'); + let foundNode = null; + const hitRadius = 30; // Pixel radius for hover detection + + // Check if mouse is over any node + for (const [id, coords] of Object.entries(instance.locations)) { + const { x, y } = getCoords(id); + const dist = Math.sqrt((mouseX - x) ** 2 + (mouseY - y) ** 2); + + if (dist < hitRadius) { + foundNode = id; + break; + } + } + + if (foundNode) { + hoveredNode = foundNode; + showTooltip(foundNode, clientX, clientY); + } else { + hoveredNode = null; + hideTooltip(); + } +} + +function showTooltip(nodeId, clientX, clientY) { + const tooltip = document.getElementById('tooltip'); + + let content = ''; + const productColors = ['#6366f1', '#22d3ee', '#f472b6', '#34d399', '#fbbf24']; + + if (nodeId.startsWith('S')) { + // Station tooltip - show demand fulfillment + const demands = stationDemandsPerProduct[nodeId] || []; + const deliveries = stationDeliveriesPerProduct[nodeId] || []; + + content = `
⛽ ${nodeId}
`; + content += '
'; + + if (demands.length > 0) { + demands.forEach((demand, idx) => { + const delivered = Math.round(deliveries[idx] || 0); + const demandRounded = Math.round(demand); + const excess = delivered - demandRounded; + const isExcess = excess > 0; + const isShortage = delivered < demandRounded && demand > 0; + const color = productColors[idx % productColors.length]; + + const percent = demand > 0 ? Math.min(100, (delivered / demand) * 100) : 100; + + let statusClass = ''; + let statusText = ''; + if (isExcess) { + statusClass = 'excess'; + statusText = ` (+${excess})`; + } else if (isShortage) { + statusClass = 'shortage'; + } + + content += `
+ P${idx + 1} +
+
+
+ ${delivered}/${demandRounded}${statusText} +
`; + }); + + // Summary + const totalDemand = demands.reduce((a, b) => a + b, 0); + const totalDelivered = deliveries.reduce((a, b) => a + b, 0); + const totalExcess = totalDelivered - totalDemand; + + content += `
`; + content += `Total: ${Math.round(totalDelivered)}/${Math.round(totalDemand)}`; + if (totalExcess > 0) { + content += `+${Math.round(totalExcess)} excess`; + } else if (totalExcess < 0) { + content += `${Math.round(totalExcess)} remaining`; + } + content += `
`; + } else { + content += '
No demand data
'; + } + + content += '
'; + } else if (nodeId.startsWith('D')) { + // Depot tooltip + const supplies = instance.depotSupplies?.[nodeId] || []; + content = `
🏪 ${nodeId}
`; + content += '
'; + + if (supplies.length > 0) { + supplies.forEach((supply, idx) => { + const color = productColors[idx % productColors.length]; + content += `
+ P${idx + 1} + ${Math.round(supply)} units +
`; + }); + } + content += '
'; + } else if (nodeId.startsWith('G')) { + // Garage tooltip + content = `
🏢 ${nodeId}
`; + content += '
Vehicle depot
'; + } + + tooltip.innerHTML = content; + tooltip.style.opacity = '1'; + + // Position tooltip + const tooltipRect = tooltip.getBoundingClientRect(); + let left = clientX + 15; + let top = clientY + 15; + + // Keep tooltip on screen + if (left + tooltipRect.width > window.innerWidth) { + left = clientX - tooltipRect.width - 15; + } + if (top + tooltipRect.height > window.innerHeight) { + top = clientY - tooltipRect.height - 15; + } + + tooltip.style.left = left + 'px'; + tooltip.style.top = top + 'px'; +} + +function hideTooltip() { + const tooltip = document.getElementById('tooltip'); + tooltip.style.opacity = '0'; +} + // Keyboard shortcuts document.addEventListener('keydown', (e) => { // Press 'B' to toggle sidebar diff --git a/pages/visualisation.html b/pages/visualisation.html index b471b02..4f028a9 100644 --- a/pages/visualisation.html +++ b/pages/visualisation.html @@ -49,6 +49,10 @@

MPVRP 0.00 Distance (km) +
+ 0 + Exchanges +
0 Trucks From ce03c1f43e5d1d432a452832a889a9abf29dbf6e Mon Sep 17 00:00:00 2001 From: Rosas Behoundja Date: Mon, 9 Feb 2026 14:06:54 +0100 Subject: [PATCH 2/2] feat: Implement product swap notification system in visualization --- pages/static/css/visualisation.css | 104 +++++++++++++++++++++++++++++ pages/static/js/visualisation.js | 72 ++++++++++++++++++++ pages/visualisation.html | 11 +-- 3 files changed, 183 insertions(+), 4 deletions(-) diff --git a/pages/static/css/visualisation.css b/pages/static/css/visualisation.css index 96047fd..dba4af4 100644 --- a/pages/static/css/visualisation.css +++ b/pages/static/css/visualisation.css @@ -953,3 +953,107 @@ input[type="range"]::-moz-range-thumb { background: var(--border); border-radius: 2px; } + +/* ═══════════════════════════════════════════════════════════════ + NOTIFICATION CONTAINER (Product Swap Notifications) + ═══════════════════════════════════════════════════════════════ */ + +.notification-container { + position: absolute; + top: 16px; + right: 16px; + display: flex; + flex-direction: column; + gap: 8px; + z-index: 100; + max-width: 280px; + pointer-events: none; +} + +.notification { + background: var(--surface); + border: 1px solid var(--border); + border-left: 4px solid var(--primary); + border-radius: 8px; + padding: 12px 16px; + box-shadow: var(--shadow); + animation: slideIn 0.3s ease-out, fadeOut 0.3s ease-in 2.7s; + pointer-events: auto; + display: flex; + align-items: center; + gap: 10px; +} + +.notification.fade-out { + animation: fadeOut 0.3s ease-in forwards; +} + +.notification-icon { + font-size: 18px; +} + +.notification-content { + flex: 1; +} + +.notification-title { + font-size: 12px; + font-weight: 600; + color: var(--text); + margin-bottom: 2px; +} + +.notification-message { + font-size: 11px; + color: var(--text-muted); +} + +.notification-truck { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 6px; + vertical-align: middle; +} + +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +/* Responsive notification */ +@media (max-width: 768px) { + .notification-container { + top: 8px; + right: 8px; + max-width: 200px; + } + + .notification { + padding: 8px 12px; + } + + .notification-title { + font-size: 11px; + } + + .notification-message { + font-size: 10px; + } +} diff --git a/pages/static/js/visualisation.js b/pages/static/js/visualisation.js index 477e2fe..1e9e846 100644 --- a/pages/static/js/visualisation.js +++ b/pages/static/js/visualisation.js @@ -34,6 +34,10 @@ let dataLoaded = false; let totalExchanges = 0; let currentExchanges = 0; +// Product swap tracking for notifications +let lastProductByTruck = {}; // Track last product for each truck +let shownSwapNotifications = {}; // Track which swaps have been notified + // Station deliveries tracking (per product) let stationDeliveriesPerProduct = {}; // { stationId: { productIdx: delivered } } let stationDemandsPerProduct = {}; // { stationId: { productIdx: demand } } @@ -59,6 +63,45 @@ function toggleTheme() { draw(); } +// ═══════════════════════════════════════════════════════════════ +// NOTIFICATION SYSTEM +// ═══════════════════════════════════════════════════════════════ + +function showSwapNotification(truckId, fromProduct, toProduct, truckColor) { + const container = document.getElementById('notificationContainer'); + if (!container) return; + + const notification = document.createElement('div'); + notification.className = 'notification'; + notification.innerHTML = ` +
🔄
+
+
+ + ${truckId} Truck +
+
P${fromProduct} → P${toProduct}
+
+ `; + + container.appendChild(notification); + + // Remove notification after animation completes + setTimeout(() => { + notification.classList.add('fade-out'); + setTimeout(() => { + notification.remove(); + }, 300); + }, 3000); +} + +function clearAllNotifications() { + const container = document.getElementById('notificationContainer'); + if (container) { + container.innerHTML = ''; + } +} + // ═══════════════════════════════════════════════════════════════ // FILE UPLOAD HANDLING // ═══════════════════════════════════════════════════════════════ @@ -431,6 +474,11 @@ function initData() { totalExchanges = solution.metrics?.product_changes || 0; currentExchanges = 0; + // Reset product swap notification tracking + lastProductByTruck = {}; + shownSwapNotifications = {}; + clearAllNotifications(); + // Reset station per-product tracking stationDemandsPerProduct = instance.stationDemandsPerProduct || {}; stationDeliveriesPerProduct = {}; @@ -486,6 +534,7 @@ function initData() { // Update Stats const metrics = solution.metrics || {}; document.getElementById('stat-dist').textContent = (metrics.total_cost || solution.objective || 0).toFixed(2); + document.getElementById('stat-routing').textContent = (metrics.routing_cost || 0).toFixed(2); document.getElementById('stat-exchanges').textContent = `0/${totalExchanges}`; document.getElementById('stat-trucks').textContent = metrics.vehicles_used || trucks.length; @@ -914,6 +963,12 @@ function reset() { isPlaying = false; progress = 0; cancelAnimationFrame(animationId); + + // Reset product swap notification tracking + lastProductByTruck = {}; + shownSwapNotifications = {}; + clearAllNotifications(); + updatePlayBtn(); updateUI(); draw(); @@ -1014,6 +1069,8 @@ function calculateCurrentExchangesAndDeliveries() { // Estimate exchanges: count transitions between depot visits // (simplified - actual exchanges depend on product line in solution) let depotVisitCount = 0; + let lastDepotProduct = lastProductByTruck[vehicleKey] || 1; + for (let i = 0; i < completedSegs; i++) { const toNode = t.segments[i][1]; if (toNode && toNode.startsWith('D')) { @@ -1021,9 +1078,24 @@ function calculateCurrentExchangesAndDeliveries() { if (depotVisitCount > 1) { // Potential product change currentExchanges++; + + // Calculate a simulated product change (cycling through products) + const newProduct = ((lastDepotProduct % numProducts) + 1); + const swapKey = `${vehicleKey}-${i}`; + + // Show notification if this swap hasn't been shown yet + if (!shownSwapNotifications[swapKey]) { + shownSwapNotifications[swapKey] = true; + showSwapNotification(vehicleKey, lastDepotProduct, newProduct, t.color); + } + + lastDepotProduct = newProduct; } } } + + // Update last product tracking for this truck + lastProductByTruck[vehicleKey] = lastDepotProduct; }); // Cap exchanges at total (estimation may overshoot) diff --git a/pages/visualisation.html b/pages/visualisation.html index 4f028a9..104749a 100644 --- a/pages/visualisation.html +++ b/pages/visualisation.html @@ -50,12 +50,12 @@

MPVRP Distance (km)

- 0 - Exchanges + 0.00 + Routing Cost
- 0 - Trucks + 0 + Exchanges
+ + +