|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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> |
|
|
`)); |
|
|
} |
|
|
} |
|
|
|