| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | |
| | |
| | |
| |
|
| | |
| | let loadedSchedule = null; |
| |
|
| | |
| | let currentProblemId = null; |
| |
|
| | |
| | let autoRefreshIntervalId = null; |
| |
|
| | |
| | let lastScore = null; |
| |
|
| | |
| | let distances = new Map(); |
| |
|
| | |
| | let userRequestedSolving = false; |
| |
|
| | |
| | |
| | |
| |
|
| | |
| | |
| | |
| | |
| | $(document).ready(function() { |
| | |
| | replaceQuickstartSolverForgeAutoHeaderFooter(); |
| |
|
| | |
| | initWarehouseCanvas(); |
| |
|
| | |
| | loadDemoData(); |
| |
|
| | |
| | $("#solveButton").click(solve); |
| | $("#stopSolvingButton").click(stopSolving); |
| | $("#analyzeButton").click(analyze); |
| | $("#generateButton").click(generateNewData); |
| |
|
| | |
| | $("#ordersCountSlider").on("input", function() { |
| | $("#ordersCountValue").text($(this).val()); |
| | }); |
| | $("#trolleysCountSlider").on("input", function() { |
| | $("#trolleysCountValue").text($(this).val()); |
| | }); |
| | $("#bucketsCountSlider").on("input", function() { |
| | $("#bucketsCountValue").text($(this).val()); |
| | }); |
| |
|
| | |
| | window.addEventListener('resize', () => { |
| | initWarehouseCanvas(); |
| | if (loadedSchedule) { |
| | renderWarehouse(loadedSchedule); |
| | } |
| | }); |
| | }); |
| |
|
| | |
| | |
| | |
| |
|
| | |
| | |
| | |
| | |
| | function loadDemoData() { |
| | fetch('/demo-data/DEFAULT') |
| | .then(r => r.json()) |
| | .then(solution => { |
| | loadedSchedule = solution; |
| | currentProblemId = null; |
| | updateUI(solution, false); |
| | }) |
| | .catch(error => { |
| | showError("Failed to load demo data", error); |
| | }); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | function generateNewData() { |
| | |
| | const config = { |
| | ordersCount: parseInt($("#ordersCountSlider").val()), |
| | trolleysCount: parseInt($("#trolleysCountSlider").val()), |
| | bucketCount: parseInt($("#bucketsCountSlider").val()) |
| | }; |
| |
|
| | |
| | const btn = $("#generateButton"); |
| | btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Generating...'); |
| |
|
| | fetch('/demo-data/generate', { |
| | method: 'POST', |
| | headers: { 'Content-Type': 'application/json' }, |
| | body: JSON.stringify(config) |
| | }) |
| | .then(r => { |
| | if (!r.ok) { |
| | return r.text().then(text => { |
| | throw new Error(`Server error ${r.status}: ${text}`); |
| | }); |
| | } |
| | return r.json(); |
| | }) |
| | .then(solution => { |
| | loadedSchedule = solution; |
| | currentProblemId = null; |
| | distances.clear(); |
| | updateUI(solution, false); |
| | $("#settingsPanel").collapse('hide'); |
| | showSuccess(`Generated ${config.ordersCount} orders with ${config.trolleysCount} trolleys`); |
| | }) |
| | .catch(error => { |
| | console.error("Generate error:", error); |
| | showError("Failed to generate data: " + error.message, error); |
| | }) |
| | .finally(() => { |
| | btn.prop('disabled', false).html('<i class="fas fa-sync-alt"></i> Generate New'); |
| | }); |
| | } |
| |
|
| | |
| | |
| | |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | function solve() { |
| | lastScore = null; |
| | userRequestedSolving = true; |
| |
|
| | |
| | setSolving(true); |
| |
|
| | |
| | fetch('/schedules', { |
| | method: 'POST', |
| | headers: { 'Content-Type': 'application/json' }, |
| | body: JSON.stringify(loadedSchedule) |
| | }) |
| | .then(r => r.text()) |
| | .then(problemId => { |
| | |
| | currentProblemId = problemId.replace(/"/g, ''); |
| |
|
| | |
| | ISO.isSolving = true; |
| |
|
| | |
| | autoRefreshIntervalId = setInterval(refreshSchedule, 250); |
| |
|
| | |
| | refreshSchedule(); |
| | }) |
| | .catch(error => { |
| | showError("Failed to start solving", error); |
| | setSolving(false); |
| | stopWarehouseAnimation(); |
| | }); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | function stopSolving() { |
| | if (!currentProblemId) return; |
| | userRequestedSolving = false; |
| |
|
| | |
| | if (autoRefreshIntervalId) { |
| | clearInterval(autoRefreshIntervalId); |
| | autoRefreshIntervalId = null; |
| | } |
| |
|
| | |
| | setSolving(false); |
| | stopWarehouseAnimation(); |
| |
|
| | |
| | fetch(`/schedules/${currentProblemId}`, { method: 'DELETE' }) |
| | .then(r => r.ok ? r.json() : Promise.reject(`HTTP ${r.status}`)) |
| | .then(solution => { |
| | loadedSchedule = solution; |
| | updateUI(solution, false); |
| | }) |
| | .catch(error => showError("Failed to stop solving", error)); |
| | } |
| |
|
| | |
| | |
| | |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | function refreshSchedule() { |
| | if (!currentProblemId) return; |
| | if (!userRequestedSolving) return; |
| |
|
| | |
| | Promise.all([ |
| | fetch(`/schedules/${currentProblemId}`).then(r => r.json()), |
| | fetch(`/schedules/${currentProblemId}/status`).then(r => r.json()) |
| | ]) |
| | .then(([solution, status]) => { |
| | |
| | if (!userRequestedSolving) return; |
| |
|
| | |
| | |
| | if (typeof ISO !== 'undefined') { |
| | ISO.currentSolution = solution; |
| | } |
| |
|
| | |
| | distances = new Map(Object.entries(status.distances || {})); |
| |
|
| | |
| | const newScoreStr = `${status.score.hardScore}hard/${status.score.softScore}soft`; |
| | if (lastScore && newScoreStr !== lastScore) { |
| | flashScoreImprovement(); |
| | } |
| | lastScore = newScoreStr; |
| |
|
| | |
| | loadedSchedule = solution; |
| | const isSolving = status.solverStatus !== 'NOT_SOLVING' && status.solverStatus != null; |
| | updateUI(solution, isSolving); |
| |
|
| | |
| | if (userRequestedSolving) { |
| | if (ISO.trolleyAnimations.size === 0) { |
| | startWarehouseAnimation(solution); |
| | } |
| | updateWarehouseAnimation(solution); |
| | } |
| |
|
| | |
| | const solverSaysNotSolving = status.solverStatus === 'NOT_SOLVING'; |
| | const solverActuallyFinished = solverSaysNotSolving && solution.score !== null; |
| | const shouldStop = !userRequestedSolving || solverActuallyFinished; |
| |
|
| | if (shouldStop) { |
| | if (autoRefreshIntervalId) { |
| | clearInterval(autoRefreshIntervalId); |
| | autoRefreshIntervalId = null; |
| | } |
| | userRequestedSolving = false; |
| | setSolving(false); |
| | stopWarehouseAnimation(); |
| | } |
| | }) |
| | .catch(error => { |
| | console.error("Refresh error:", error); |
| | }); |
| | } |
| |
|
| | |
| | |
| | |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | function updateUI(solution, solving) { |
| | updateScore(solution); |
| | updateStats(solution); |
| | updateLegend(solution, distances); |
| | updateTrolleyCards(solution); |
| | renderWarehouse(solution); |
| | setSolving(solving && solution.solverStatus !== 'NOT_SOLVING'); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | function updateScore(solution) { |
| | const score = solution.score; |
| | if (!score) { |
| | $("#score").text("?"); |
| | } else if (typeof score === 'string') { |
| | $("#score").text(score); |
| | } else { |
| | $("#score").text(`${score.hardScore}hard/${score.softScore}soft`); |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | function updateStats(solution) { |
| | const orderIds = new Set(); |
| | let totalItems = 0; |
| | let activeTrolleys = 0; |
| | let totalDistance = 0; |
| |
|
| | |
| | const stepLookup = new Map(); |
| | for (const step of solution.trolleySteps || []) { |
| | stepLookup.set(step.id, step); |
| | } |
| |
|
| | |
| | for (const trolley of solution.trolleys || []) { |
| | |
| | const steps = (trolley.steps || []).map(ref => |
| | typeof ref === 'string' ? stepLookup.get(ref) : ref |
| | ).filter(s => s); |
| |
|
| | if (steps.length > 0) { |
| | activeTrolleys++; |
| | totalItems += steps.length; |
| |
|
| | |
| | for (const step of steps) { |
| | if (step.orderItem) { |
| | orderIds.add(step.orderItem.orderId); |
| | } |
| | } |
| | } |
| |
|
| | |
| | const dist = distances.get(trolley.id) || 0; |
| | totalDistance += dist; |
| | } |
| |
|
| | |
| | animateValue("#totalOrders", orderIds.size); |
| | animateValue("#totalItems", totalItems); |
| | animateValue("#activeTrolleys", activeTrolleys); |
| | animateValue("#totalDistance", Math.round(totalDistance / 100)); |
| | } |
| |
|
| | |
| | |
| | |
| | function animateValue(selector, newValue) { |
| | const el = $(selector); |
| | const oldValue = parseInt(el.text()) || 0; |
| | if (oldValue !== newValue) { |
| | el.text(newValue); |
| | el.addClass('value-changed'); |
| | setTimeout(() => el.removeClass('value-changed'), 500); |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | function updateTrolleyCards(solution) { |
| | const container = $("#trolleyCardsContainer"); |
| |
|
| | |
| | const stepLookup = new Map(); |
| | for (const step of solution.trolleySteps || []) { |
| | stepLookup.set(step.id, step); |
| | } |
| |
|
| | const trolleys = solution.trolleys || []; |
| |
|
| | |
| | if (container.children().length !== trolleys.length) { |
| | container.empty(); |
| | for (const trolley of trolleys) { |
| | const color = getTrolleyColor(trolley.id); |
| | const card = $(` |
| | <div class="trolley-card" data-trolley-id="${trolley.id}"> |
| | <div class="trolley-card-header"> |
| | <div class="trolley-color-badge" style="background: ${color}"></div> |
| | <div class="trolley-card-info"> |
| | <div class="trolley-card-title">Trolley ${trolley.id}</div> |
| | <div class="trolley-card-stats"></div> |
| | </div> |
| | <div class="trolley-capacity-bar"> |
| | <div class="trolley-capacity-fill"></div> |
| | </div> |
| | </div> |
| | <div class="trolley-card-body"></div> |
| | </div> |
| | `); |
| | container.append(card); |
| | } |
| | } |
| |
|
| | |
| | for (const trolley of trolleys) { |
| | const card = container.find(`[data-trolley-id="${trolley.id}"]`); |
| | if (!card.length) continue; |
| |
|
| | const steps = (trolley.steps || []).map(ref => |
| | typeof ref === 'string' ? stepLookup.get(ref) : ref |
| | ).filter(s => s); |
| |
|
| | const itemCount = steps.length; |
| |
|
| | |
| | let totalVolume = 0; |
| | const bucketCapacity = 50000; |
| | const bucketCount = trolley.bucketCount || 6; |
| | const maxCapacity = bucketCapacity * bucketCount; |
| | for (const step of steps) { |
| | if (step.orderItem?.product?.volume) { |
| | totalVolume += step.orderItem.product.volume; |
| | } |
| | } |
| | const capacityPercent = Math.min(100, Math.round((totalVolume / maxCapacity) * 100)); |
| | const capacityClass = capacityPercent > 90 ? 'high' : capacityPercent > 70 ? 'medium' : 'low'; |
| |
|
| | |
| | card.find('.trolley-card-stats').text(`${itemCount} items`); |
| |
|
| | |
| | const fill = card.find('.trolley-capacity-fill'); |
| | fill.css('width', `${capacityPercent}%`); |
| | fill.removeClass('low medium high').addClass(capacityClass); |
| |
|
| | |
| | const body = card.find('.trolley-card-body'); |
| | if (itemCount > 0) { |
| | body.html(` |
| | <div class="trolley-items-list"> |
| | ${steps.map((step, i) => ` |
| | <div class="trolley-item"> |
| | <span class="trolley-item-number">${i + 1}</span> |
| | ${step.orderItem?.product?.name?.substring(0, 15) || 'Item'} |
| | </div> |
| | `).join('')} |
| | </div> |
| | `); |
| | } else { |
| | body.html('<div class="trolley-empty">No items assigned</div>'); |
| | } |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | function setSolving(solving) { |
| | if (solving) { |
| | $("#solveButton").hide(); |
| | $("#stopSolvingButton").show(); |
| | $("#solvingIndicator").show(); |
| | $("#generateButton").prop('disabled', true); |
| | } else { |
| | $("#solveButton").show(); |
| | $("#stopSolvingButton").hide(); |
| | $("#solvingIndicator").hide(); |
| | $("#generateButton").prop('disabled', false); |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | function flashScoreImprovement() { |
| | const display = $("#scoreDisplay"); |
| | display.addClass('improved'); |
| | setTimeout(() => display.removeClass('improved'), 500); |
| | } |
| |
|
| | |
| | |
| | |
| |
|
| | |
| | |
| | |
| | |
| | function analyze() { |
| | if (!currentProblemId) { |
| | showError("No active solution to analyze"); |
| | return; |
| | } |
| |
|
| | |
| | const btn = $("#analyzeButton"); |
| | btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i>'); |
| |
|
| | fetch(`/schedules/${currentProblemId}/score-analysis`) |
| | .then(r => r.json()) |
| | .then(analysis => { |
| | showScoreAnalysis(analysis); |
| | }) |
| | .catch(error => { |
| | showError("Failed to load score analysis", error); |
| | }) |
| | .finally(() => { |
| | btn.prop('disabled', false).html('<i class="fas fa-chart-bar"></i>'); |
| | }); |
| | } |
| |
|
| | |
| | |
| | |
| | function showScoreAnalysis(analysis) { |
| | const content = $("#scoreAnalysisModalContent"); |
| | content.empty(); |
| |
|
| | if (!analysis || !analysis.constraints) { |
| | content.html('<p>No constraint data available.</p>'); |
| | } else { |
| | for (const constraint of analysis.constraints) { |
| | const score = constraint.score || '0'; |
| | const isHard = score.includes('hard'); |
| |
|
| | const group = $(` |
| | <div class="constraint-group"> |
| | <div class="constraint-header"> |
| | <span class="constraint-name">${constraint.name}</span> |
| | <span class="constraint-score ${isHard ? 'hard' : 'soft'}">${score}</span> |
| | </div> |
| | </div> |
| | `); |
| | content.append(group); |
| | } |
| | } |
| |
|
| | |
| | const modalEl = document.getElementById('scoreAnalysisModal'); |
| | bootstrap.Modal.getOrCreateInstance(modalEl).show(); |
| | } |
| |
|
| | |
| | |
| | |
| |
|
| | |
| | |
| | |
| | function showError(message, error) { |
| | console.error(message, error); |
| | const alert = $(` |
| | <div class="alert alert-danger alert-dismissible fade show"> |
| | <i class="fas fa-exclamation-circle me-2"></i> |
| | <strong>Error:</strong> ${message} |
| | <button type="button" class="btn-close" data-bs-dismiss="alert"></button> |
| | </div> |
| | `); |
| | $("#notificationPanel").append(alert); |
| | setTimeout(() => alert.alert('close'), 5000); |
| | } |
| |
|
| | |
| | |
| | |
| | function showSuccess(message) { |
| | const alert = $(` |
| | <div class="alert alert-success alert-dismissible fade show"> |
| | <i class="fas fa-check-circle me-2"></i>${message} |
| | <button type="button" class="btn-close" data-bs-dismiss="alert"></button> |
| | </div> |
| | `); |
| | $("#notificationPanel").append(alert); |
| | setTimeout(() => alert.alert('close'), 3000); |
| | } |
| |
|
| | |
| | |
| | |
| |
|
| | |
| | |
| | |
| | |
| | function replaceQuickstartSolverForgeAutoHeaderFooter() { |
| | const header = $("header#solverforge-auto-header"); |
| | if (header.length) { |
| | header.css("background-color", "#ffffff"); |
| | header.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> |
| | </nav> |
| | </div> |
| | `)); |
| | } |
| |
|
| | const footer = $("footer#solverforge-auto-footer"); |
| | if (footer.length) { |
| | footer.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:info@solverforge.org">Support</a></div> |
| | </div> |
| | </div> |
| | </footer> |
| | `)); |
| | } |
| | } |
| |
|