blackopsrepl's picture
Upload 31 files
e40294e verified
/**
* Order Picking Optimization - Frontend Application
*
* This application demonstrates real-time constraint optimization for warehouse
* order picking. It uses SolverForge (a constraint solver) to optimize which
* trolley picks which items and in what order, minimizing total travel distance
* while respecting capacity constraints.
*
* Architecture:
* - Backend: FastAPI server with SolverForge solver
* - Frontend: jQuery + Canvas for visualization
* - Communication: REST API with 250ms polling for real-time updates
*
* Key concepts:
* - Planning entities: TrolleySteps (items to pick)
* - Planning variable: Which trolley picks each step
* - Constraints: Bucket capacity (hard), minimize distance (soft)
*/
// =============================================================================
// Application State
// =============================================================================
/** Current solution data from the solver */
let loadedSchedule = null;
/** Active problem ID when solving (null when not solving) */
let currentProblemId = null;
/** Interval ID for polling updates during solving */
let autoRefreshIntervalId = null;
/** Last score string for detecting improvements */
let lastScore = null;
/** Cached distances per trolley (Map<trolleyId, distance>) */
let distances = new Map();
/** Tracks user intent to solve (prevents race condition with solver startup) */
let userRequestedSolving = false;
// =============================================================================
// Initialization
// =============================================================================
/**
* Initialize the application when the DOM is ready.
* Sets up event handlers and loads initial demo data.
*/
$(document).ready(function() {
// Add SolverForge header and footer branding
replaceQuickstartSolverForgeAutoHeaderFooter();
// Initialize the isometric 3D warehouse canvas
initWarehouseCanvas();
// Load default demo data to show something immediately
loadDemoData();
// Wire up button click handlers
$("#solveButton").click(solve);
$("#stopSolvingButton").click(stopSolving);
$("#analyzeButton").click(analyze);
$("#generateButton").click(generateNewData);
// Update displayed values when sliders change
$("#ordersCountSlider").on("input", function() {
$("#ordersCountValue").text($(this).val());
});
$("#trolleysCountSlider").on("input", function() {
$("#trolleysCountValue").text($(this).val());
});
$("#bucketsCountSlider").on("input", function() {
$("#bucketsCountValue").text($(this).val());
});
// Redraw warehouse on window resize
window.addEventListener('resize', () => {
initWarehouseCanvas();
if (loadedSchedule) {
renderWarehouse(loadedSchedule);
}
});
});
// =============================================================================
// Data Loading
// =============================================================================
/**
* Load the default demo dataset from the server.
* This provides a starting point with pre-configured orders and trolleys.
*/
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);
});
}
/**
* Generate new random demo data based on slider settings.
* Allows testing with different problem sizes.
*/
function generateNewData() {
// Read configuration from UI sliders
const config = {
ordersCount: parseInt($("#ordersCountSlider").val()),
trolleysCount: parseInt($("#trolleysCountSlider").val()),
bucketCount: parseInt($("#bucketsCountSlider").val())
};
// Show loading state
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');
});
}
// =============================================================================
// Solving - Start/Stop Optimization
// =============================================================================
/**
* Start the optimization solver.
*
* Flow:
* 1. POST current schedule to /schedules to start solving
* 2. Server returns a problemId for tracking
* 3. Start polling every 250ms for solution updates
* 4. Update UI in real-time as solver finds better solutions
*/
function solve() {
lastScore = null;
userRequestedSolving = true;
// Show solving state immediately for user feedback
setSolving(true);
// Submit the problem to the solver
fetch('/schedules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(loadedSchedule)
})
.then(r => r.text())
.then(problemId => {
// Store problem ID for subsequent API calls
currentProblemId = problemId.replace(/"/g, '');
// Start animation from first poll response, not stale loadedSchedule
ISO.isSolving = true;
// Poll for updates every 250ms for smooth real-time visualization
autoRefreshIntervalId = setInterval(refreshSchedule, 250);
// Trigger immediate first poll to get real data ASAP
refreshSchedule();
})
.catch(error => {
showError("Failed to start solving", error);
setSolving(false);
stopWarehouseAnimation();
});
}
/**
* Stop the optimization solver early.
* Returns the best solution found so far.
*/
function stopSolving() {
if (!currentProblemId) return;
userRequestedSolving = false;
// Stop polling immediately
if (autoRefreshIntervalId) {
clearInterval(autoRefreshIntervalId);
autoRefreshIntervalId = null;
}
// Update UI immediately - don't wait for server
setSolving(false);
stopWarehouseAnimation();
// Tell the server to terminate solving
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));
}
// =============================================================================
// Real-Time Polling
// =============================================================================
/**
* Fetch the latest solution and status from the server.
* Called every 250ms during solving to provide real-time updates.
*
* Why polling instead of WebSockets/SSE?
* - Simpler implementation and debugging
* - Works reliably across all environments
* - 250ms is fast enough for smooth visualization
* - Proven pattern used across SolverForge quickstarts
*/
function refreshSchedule() {
if (!currentProblemId) return;
if (!userRequestedSolving) return; // Don't process if user stopped
// Fetch solution and status in parallel for efficiency
Promise.all([
fetch(`/schedules/${currentProblemId}`).then(r => r.json()),
fetch(`/schedules/${currentProblemId}/status`).then(r => r.json())
])
.then(([solution, status]) => {
// Double-check user hasn't stopped while fetch was in-flight
if (!userRequestedSolving) return;
// CRITICAL: Sync animation state IMMEDIATELY before any rendering
// This prevents race conditions where animation loop uses stale data
if (typeof ISO !== 'undefined') {
ISO.currentSolution = solution;
}
// Update distance cache from status response
distances = new Map(Object.entries(status.distances || {}));
// Detect score improvements and trigger visual feedback
const newScoreStr = `${status.score.hardScore}hard/${status.score.softScore}soft`;
if (lastScore && newScoreStr !== lastScore) {
flashScoreImprovement();
}
lastScore = newScoreStr;
// Update application state
loadedSchedule = solution;
const isSolving = status.solverStatus !== 'NOT_SOLVING' && status.solverStatus != null;
updateUI(solution, isSolving);
// Update animation paths when solution changes
if (userRequestedSolving) {
if (ISO.trolleyAnimations.size === 0) {
startWarehouseAnimation(solution);
}
updateWarehouseAnimation(solution);
}
// Auto-stop polling when solver finishes (with valid score) or user stopped
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);
});
}
// =============================================================================
// UI Updates
// =============================================================================
/**
* Update all UI components with the current solution state.
*
* @param {Object} solution - The current solution from the solver
* @param {boolean} solving - Whether the solver is currently running
*/
function updateUI(solution, solving) {
updateScore(solution);
updateStats(solution);
updateLegend(solution, distances);
updateTrolleyCards(solution);
renderWarehouse(solution);
setSolving(solving && solution.solverStatus !== 'NOT_SOLVING');
}
/**
* Update the score display.
* Score format: "{hardScore}hard/{softScore}soft"
* - Hard score: Constraint violations (must be 0 for valid solution)
* - Soft score: Optimization objective (minimize distance)
*/
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`);
}
}
/**
* Update statistics cards showing problem metrics.
* Calculates totals from the solution data structure.
*/
function updateStats(solution) {
const orderIds = new Set();
let totalItems = 0;
let activeTrolleys = 0;
let totalDistance = 0;
// Build lookup for resolving step references
const stepLookup = new Map();
for (const step of solution.trolleySteps || []) {
stepLookup.set(step.id, step);
}
// Aggregate statistics from all trolleys
for (const trolley of solution.trolleys || []) {
// Resolve step references (may be IDs or objects)
const steps = (trolley.steps || []).map(ref =>
typeof ref === 'string' ? stepLookup.get(ref) : ref
).filter(s => s);
if (steps.length > 0) {
activeTrolleys++;
totalItems += steps.length;
// Track unique orders
for (const step of steps) {
if (step.orderItem) {
orderIds.add(step.orderItem.orderId);
}
}
}
// Sum distances from cache
const dist = distances.get(trolley.id) || 0;
totalDistance += dist;
}
// Update UI with animations
animateValue("#totalOrders", orderIds.size);
animateValue("#totalItems", totalItems);
animateValue("#activeTrolleys", activeTrolleys);
animateValue("#totalDistance", Math.round(totalDistance / 100)); // cm -> m
}
/**
* Animate a value change with a brief highlight effect.
*/
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);
}
}
/**
* Render the trolley assignment cards showing items per trolley.
* Updates in place to avoid flicker.
*/
function updateTrolleyCards(solution) {
const container = $("#trolleyCardsContainer");
// Build step lookup for reference resolution
const stepLookup = new Map();
for (const step of solution.trolleySteps || []) {
stepLookup.set(step.id, step);
}
const trolleys = solution.trolleys || [];
// Create cards if needed (first time or count changed)
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);
}
}
// Update each card in place
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;
// Calculate capacity
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';
// Update stats
card.find('.trolley-card-stats').text(`${itemCount} items`);
// Update capacity bar
const fill = card.find('.trolley-capacity-fill');
fill.css('width', `${capacityPercent}%`);
fill.removeClass('low medium high').addClass(capacityClass);
// Update items list
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>');
}
}
}
/**
* Update UI to reflect solving/not-solving state.
*/
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);
}
}
/**
* Flash the score display to indicate improvement.
*/
function flashScoreImprovement() {
const display = $("#scoreDisplay");
display.addClass('improved');
setTimeout(() => display.removeClass('improved'), 500);
}
// =============================================================================
// Score Analysis
// =============================================================================
/**
* Fetch and display detailed constraint analysis.
* Shows which constraints are satisfied/violated and their contribution to score.
*/
function analyze() {
if (!currentProblemId) {
showError("No active solution to analyze");
return;
}
// Show loading state
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>');
});
}
/**
* Display score analysis in a modal dialog.
*/
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);
}
}
// Use getOrCreateInstance to avoid stacking modal instances
const modalEl = document.getElementById('scoreAnalysisModal');
bootstrap.Modal.getOrCreateInstance(modalEl).show();
}
// =============================================================================
// Notifications
// =============================================================================
/**
* Display an error notification that auto-dismisses.
*/
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);
}
/**
* Display a success notification that auto-dismisses.
*/
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);
}
// =============================================================================
// SolverForge Branding
// =============================================================================
/**
* Add SolverForge header and footer branding.
* Matches the pattern used in other SolverForge quickstarts.
*/
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>
`));
}
}