// Basic front-end logic document.addEventListener('DOMContentLoaded', () => { // UI Elements const dropZone = document.getElementById('drop-zone'); const fileInput = document.getElementById('file-input'); const fileList = document.getElementById('file-list'); const uploadText = document.getElementById('upload-text'); const inputMode = document.getElementById('input-mode'); const startProcessingBtn = document.getElementById('start-processing'); const confidenceSlider = document.getElementById('confidence-threshold'); const confidenceValue = document.getElementById('confidence-value'); const progress = document.getElementById('progress'); const progressText = document.getElementById('progress-text'); const progressPercentage = document.getElementById('progress-percentage'); const imageCounter = document.getElementById('image-counter'); const statusOutput = document.getElementById('status-output'); const clearLogBtn = document.getElementById('clear-log'); const resultsTableBody = document.querySelector('.results-table tbody'); const previewImage = document.getElementById('preview-image'); const imageInfo = document.getElementById('image-info'); const prevBtn = document.getElementById('prev-image'); const nextBtn = document.getElementById('next-image'); const zoomInBtn = document.getElementById('zoom-in'); const zoomOutBtn = document.getElementById('zoom-out'); const exportCsvBtn = document.getElementById('export-csv'); const exportImagesBtn = document.getElementById('export-images'); const inputModeHelp = document.getElementById('input-mode-help'); const imageContainer = document.getElementById('image-container'); let currentResults = []; let currentImageIndex = -1; let currentJobId = null; let currentZoomLevel = 1; const MAX_ZOOM = 3; const MIN_ZOOM = 0.5; let progressInterval = null; // Interval timer for polling // Panning variables let isPanning = false; let startPanX = 0; let startPanY = 0; let currentPanX = 0; let currentPanY = 0; // Pagination and sorting variables const RESULTS_PER_PAGE = 10; let currentPage = 1; let totalPages = 1; let currentSortField = null; let currentSortDirection = 'asc'; // Input mode change inputMode.addEventListener('change', () => { const mode = inputMode.value; if (mode === 'files') { fileInput.removeAttribute('webkitdirectory'); fileInput.removeAttribute('directory'); fileInput.setAttribute('multiple', ''); inputModeHelp.textContent = 'Choose one or more image files for processing'; uploadText.textContent = 'Drag and drop images here or click to browse'; } else { fileInput.setAttribute('webkitdirectory', ''); fileInput.setAttribute('directory', ''); fileInput.removeAttribute('multiple'); inputModeHelp.textContent = 'Select a folder containing images to process'; uploadText.textContent = 'Click to select a folder containing images'; } // Clear any existing files fileInput.value = ''; fileList.innerHTML = ''; updateUploadState(); }); // File Upload Handling function handleFiles(files) { const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/tiff', 'image/tif']; const validFiles = Array.from(files).filter(file => { if (inputMode.value === 'folder') { return allowedTypes.includes(file.type) && file.webkitRelativePath && !file.webkitRelativePath.startsWith('.'); } return allowedTypes.includes(file.type); }); const invalidFiles = Array.from(files).filter(file => !allowedTypes.includes(file.type)); if (invalidFiles.length > 0) { logStatus(`Warning: Skipped ${invalidFiles.length} invalid files. Only PNG, JPG, and TIFF are supported.`); invalidFiles.forEach(file => { logStatus(`- Skipped: ${file.name} (invalid type: ${file.type || 'unknown'})`); }); } if (validFiles.length === 0) { logStatus('Error: No valid image files selected.'); return; } fileList.innerHTML = ''; // Just show an icon to indicate files are selected const summaryDiv = document.createElement('div'); summaryDiv.className = 'file-summary'; summaryDiv.innerHTML = `
Images ready for processing
`; fileList.appendChild(summaryDiv); fileInput.files = files; updateUploadState(validFiles.length); } function formatFileSize(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; } // Drag and Drop ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { dropZone.addEventListener(eventName, preventDefaults, false); document.body.addEventListener(eventName, preventDefaults, false); }); function preventDefaults(e) { e.preventDefault(); e.stopPropagation(); } ['dragenter', 'dragover'].forEach(eventName => { dropZone.addEventListener(eventName, highlight, false); }); ['dragleave', 'drop'].forEach(eventName => { dropZone.addEventListener(eventName, unhighlight, false); }); function highlight() { dropZone.classList.add('drag-over'); } function unhighlight() { dropZone.classList.remove('drag-over'); } dropZone.addEventListener('drop', (e) => { const dt = e.dataTransfer; handleFiles(dt.files); }); // Click to upload dropZone.addEventListener('click', () => { fileInput.click(); }); fileInput.addEventListener('change', () => { handleFiles(fileInput.files); }); // Input mode change inputMode.addEventListener('change', () => { updateUploadState(); }); function updateUploadState(validFileCount) { const files = fileInput.files; if (!files || files.length === 0) { uploadText.textContent = inputMode.value === 'folder' ? 'Click to select a folder containing images' : 'Drag and drop images here or click to browse'; startProcessingBtn.disabled = true; } else { // Show the count here uploadText.textContent = `${validFileCount} image${validFileCount === 1 ? '' : 's'} selected`; startProcessingBtn.disabled = validFileCount === 0; } } // Confidence threshold confidenceSlider.addEventListener('input', () => { confidenceValue.textContent = confidenceSlider.value; }); // Clear log clearLogBtn.addEventListener('click', () => { statusOutput.textContent = ''; logStatus('Log cleared'); }); // Processing startProcessingBtn.addEventListener('click', async () => { const files = fileInput.files; if (!files || files.length === 0) { logStatus('Error: No files selected.'); return; } const mode = inputMode.value; // Removed single file warning, as backend now handles it // if (mode === 'single' && files.length > 1) { ... } // Start processing setLoading(true); logStatus('Starting upload and processing...'); updateProgress(0, 'Uploading files...'); resultsTableBody.innerHTML = ''; // Clear previous results clearPreview(); // Clear image preview currentResults = []; // Reset results data if (progressInterval) { clearInterval(progressInterval); progressInterval = null; } const formData = new FormData(); for (const file of files) { formData.append('files', file); } formData.append('input_mode', mode); formData.append('confidence_threshold', confidenceSlider.value); try { const response = await fetch('/process', { method: 'POST', body: formData, }); // Handle non-JSON initial response or network errors if (!response.ok) { let errorText = `HTTP error! status: ${response.status}`; try { const errorData = await response.json(); errorText += `: ${errorData.error || 'Unknown server error'}`; if (errorData.log) logStatus(`Server Log: ${errorData.log}`); } catch (e) { // Response wasn't JSON errorText += ` - ${response.statusText}`; } throw new Error(errorText); } const data = await response.json(); // Check for errors returned immediately by /process if (data.error) { logStatus(`Error starting process: ${data.error}`); if(data.log) logStatus(`Details: ${data.log}`); throw new Error(data.error); // Throw to trigger catch block } // If processing started successfully, begin polling if (data.status === 'processing' && data.job_id) { currentJobId = data.job_id; logStatus(data.initial_log || `Processing started with Job ID: ${currentJobId}. Polling for progress...`); updateProgress(1, 'Processing started...'); // Small initial progress pollProgress(currentJobId); } else { // Should not happen if backend is correct, but handle defensively throw new Error('Unexpected response from server after starting process.'); } } catch (error) { logStatus(`Error: ${error.message}`); updateProgress(0, 'Error occurred'); setLoading(false); if (progressInterval) { clearInterval(progressInterval); progressInterval = null; } } // Removed finally block here, setLoading(false) is handled by pollProgress or catch block }); // --- New Polling Function --- function pollProgress(jobId) { if (progressInterval) { clearInterval(progressInterval); // Clear any existing timer } const totalImages = Array.from(fileInput.files).filter(file => { const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/tiff', 'image/tif']; if (inputMode.value === 'folder') { return allowedTypes.includes(file.type) && file.webkitRelativePath && !file.webkitRelativePath.startsWith('.'); } return allowedTypes.includes(file.type); }).length; progressInterval = setInterval(async () => { try { const response = await fetch(`/progress/${jobId}`); if (!response.ok) { // Handle cases where the progress endpoint itself fails let errorText = `Progress check failed: ${response.status}`; try { const errorData = await response.json(); errorText += `: ${errorData.error || 'Unknown progress error'}`; } catch(e) { errorText += ` - ${response.statusText}`; } throw new Error(errorText); } const data = await response.json(); // Update UI based on status updateProgress(data.progress || 0, data.status); // Update image counter based on progress percentage if (data.status === 'running' || data.status === 'starting') { const processedImages = Math.floor((data.progress / 90) * totalImages); // 90 is the max progress before completion imageCounter.textContent = `${processedImages} of ${totalImages} images`; } if (data.status === 'success') { clearInterval(progressInterval); progressInterval = null; updateProgress(100, 'Processing complete'); logStatus("Processing finished successfully."); displayResults(data.results || []); setLoading(false); } else if (data.status === 'error') { clearInterval(progressInterval); progressInterval = null; logStatus(`Error during processing: ${data.error || 'Unknown error'}`); updateProgress(data.progress || 100, 'Error'); // Show error state on progress bar setLoading(false); } else if (data.status === 'running' || data.status === 'starting') { // Continue polling // logStatus(`Processing status: ${data.status} (${data.progress}%)`); // Removed for cleaner log // You could add snippets from data.log here if the backend provides useful intermediate logs } else { // Unknown status - stop polling to prevent infinite loops clearInterval(progressInterval); progressInterval = null; logStatus(`Warning: Unknown job status received: ${data.status}. Stopping progress updates.`); updateProgress(data.progress || 0, `Unknown (${data.status})`); setLoading(false); } } catch (error) { clearInterval(progressInterval); progressInterval = null; logStatus(`Error polling progress: ${error.message}`); updateProgress(0, 'Polling Error'); setLoading(false); } }, 2000); // Poll every 2 seconds } // --- UI Update Functions --- function setLoading(isLoading) { startProcessingBtn.disabled = isLoading; if (isLoading) { startProcessingBtn.innerHTML = ' Processing...'; document.body.classList.add('processing'); // Disable input changes during processing inputMode.disabled = true; fileInput.disabled = true; confidenceSlider.disabled = true; } else { startProcessingBtn.innerHTML = ' Start Processing'; document.body.classList.remove('processing'); // Re-enable inputs after processing inputMode.disabled = false; fileInput.disabled = false; confidenceSlider.disabled = false; } } function updateProgress(value, message) { progress.value = value; progressPercentage.textContent = `${value}%`; progressText.textContent = message; // Clear the image counter when not processing if (message === 'Ready to process' || message === 'Processing complete' || message.startsWith('Error')) { imageCounter.textContent = ''; } } function logStatus(message) { const timestamp = new Date().toLocaleTimeString(); statusOutput.innerHTML += `[${timestamp}] ${message}\n`; statusOutput.scrollTop = statusOutput.scrollHeight; } // Add click handlers for sortable columns document.querySelectorAll('.results-table th[data-sort]').forEach(header => { header.addEventListener('click', () => { const field = header.dataset.sort; if (currentSortField === field) { currentSortDirection = currentSortDirection === 'asc' ? 'desc' : 'asc'; } else { currentSortField = field; currentSortDirection = 'asc'; } // Update sort indicators document.querySelectorAll('.results-table th[data-sort]').forEach(h => { h.classList.remove('sort-asc', 'sort-desc'); }); header.classList.add(`sort-${currentSortDirection}`); // Sort and redisplay results sortResults(); displayResultsPage(currentPage); }); }); function sortResults() { if (!currentSortField) return; currentResults.sort((a, b) => { let aVal = a[currentSortField]; let bVal = b[currentSortField]; // Handle numeric sorting for num_eggs if (currentSortField === 'num_eggs') { aVal = parseInt(aVal) || 0; bVal = parseInt(bVal) || 0; } if (aVal < bVal) return currentSortDirection === 'asc' ? -1 : 1; if (aVal > bVal) return currentSortDirection === 'asc' ? 1 : -1; return 0; }); } // Results Display function displayResults(results) { currentResults = results; resultsTableBody.innerHTML = ''; currentImageIndex = -1; currentSortField = null; currentSortDirection = 'asc'; if (!results || results.length === 0) { logStatus("No results to display"); clearPreview(); return; } // Reset sort indicators document.querySelectorAll('.results-table th[data-sort]').forEach(h => { h.classList.remove('sort-asc', 'sort-desc'); }); // Calculate pagination totalPages = Math.ceil(results.length / RESULTS_PER_PAGE); currentPage = 1; // Display current page displayResultsPage(currentPage); // Enable export buttons exportCsvBtn.disabled = false; exportImagesBtn.disabled = false; // Show first image displayImage(0); logStatus(`Displayed ${results.length} results`); } // Display a specific page of results function displayResultsPage(page) { resultsTableBody.innerHTML = ''; // Calculate start and end indices for current page const startIndex = (page - 1) * RESULTS_PER_PAGE; const endIndex = Math.min(startIndex + RESULTS_PER_PAGE, currentResults.length); // Display results for current page for (let i = startIndex; i < endIndex; i++) { const result = currentResults[i]; const row = resultsTableBody.insertRow(); row.innerHTML = ` ${result.filename} ${result.num_eggs} `; // Store the original index to maintain image preview relationship row.dataset.originalIndex = currentResults.indexOf(result); row.addEventListener('click', () => { const originalIndex = parseInt(row.dataset.originalIndex); displayImage(originalIndex); document.querySelectorAll('.results-table tr').forEach(r => r.classList.remove('selected')); row.classList.add('selected'); }); } // Update pagination UI updatePaginationControls(); } // Update pagination controls with enhanced display function updatePaginationControls() { const paginationContainer = document.getElementById('pagination-controls'); if (!paginationContainer) return; paginationContainer.innerHTML = ''; if (totalPages <= 1) return; const controls = document.createElement('div'); controls.className = 'pagination-controls'; // Previous button const prevButton = document.createElement('button'); prevButton.innerHTML = ''; prevButton.className = 'pagination-btn'; prevButton.disabled = currentPage === 1; prevButton.addEventListener('click', () => { if (currentPage > 1) { currentPage--; displayResultsPage(currentPage); } }); controls.appendChild(prevButton); // Page numbers with ellipsis const addPageButton = (pageNum) => { const button = document.createElement('button'); button.textContent = pageNum; button.className = 'pagination-btn' + (pageNum === currentPage ? ' active' : ''); button.addEventListener('click', () => { currentPage = pageNum; displayResultsPage(currentPage); }); controls.appendChild(button); }; const addEllipsis = () => { const span = document.createElement('span'); span.className = 'pagination-ellipsis'; span.textContent = '...'; controls.appendChild(span); }; // First page addPageButton(1); // Calculate visible page range if (totalPages > 7) { if (currentPage > 3) { addEllipsis(); } for (let i = Math.max(2, currentPage - 1); i <= Math.min(currentPage + 1, totalPages - 1); i++) { addPageButton(i); } if (currentPage < totalPages - 2) { addEllipsis(); } } else { // Show all pages if total pages is small for (let i = 2; i < totalPages; i++) { addPageButton(i); } } // Last page if not already added if (totalPages > 1) { addPageButton(totalPages); } // Next button const nextButton = document.createElement('button'); nextButton.innerHTML = ''; nextButton.className = 'pagination-btn'; nextButton.disabled = currentPage === totalPages; nextButton.addEventListener('click', () => { if (currentPage < totalPages) { currentPage++; displayResultsPage(currentPage); } }); controls.appendChild(nextButton); // Add page info const pageInfo = document.createElement('div'); pageInfo.className = 'pagination-info'; pageInfo.textContent = `Page ${currentPage} of ${totalPages}`; // Add both controls and info to container paginationContainer.appendChild(controls); paginationContainer.appendChild(pageInfo); } // Image Preview function displayImage(index) { if (!currentResults[index]) return; currentImageIndex = index; const result = currentResults[index]; if (result.annotated_filename) { const imageUrl = `/results/${currentJobId}/${result.annotated_filename}`; // Create a new image object to handle loading const tempImage = new Image(); tempImage.onload = function() { previewImage.src = imageUrl; previewImage.alt = result.filename; // Update image info with the new function updateImageInfo(); // Enable zoom controls zoomInBtn.disabled = false; zoomOutBtn.disabled = false; // Calculate which page this image should be on const targetPage = Math.floor(index / RESULTS_PER_PAGE) + 1; // If we're not on the correct page, switch to it if (currentPage !== targetPage) { currentPage = targetPage; displayResultsPage(currentPage); } // Remove selection from all rows document.querySelectorAll('.results-table tr').forEach(r => r.classList.remove('selected')); // Find and highlight the corresponding row const rows = Array.from(resultsTableBody.querySelectorAll('tr')); const targetRow = rows.find(row => parseInt(row.dataset.originalIndex, 10) === index); if (targetRow) { targetRow.classList.add('selected'); } // Reset panning when a new image is displayed resetPanZoom(); }; tempImage.onerror = function() { console.error('Failed to load image:', imageUrl); clearPreview(); }; // Start loading the image tempImage.src = imageUrl; } else { clearPreview(); } // Update navigation prevBtn.disabled = index <= 0; nextBtn.disabled = index >= currentResults.length - 1; } function clearPreview() { previewImage.src = ''; previewImage.alt = 'No image selected'; imageInfo.textContent = 'Select an image from the results to view'; currentImageIndex = -1; resetPanZoom(); // Disable controls prevBtn.disabled = true; nextBtn.disabled = true; zoomInBtn.disabled = true; zoomOutBtn.disabled = true; } function resetPanZoom() { // Reset zoom and pan values currentZoomLevel = 1; currentPanX = 0; currentPanY = 0; // Reset transform directly if (previewImage.src) { previewImage.style.transform = 'none'; // Force a reflow to ensure the transform is actually reset void previewImage.offsetWidth; // Then apply the default transform updateImageTransform(); } updatePanCursorState(); } // Image Navigation with debouncing let navigationInProgress = false; prevBtn.addEventListener('click', (e) => { e.preventDefault(); if (!navigationInProgress && currentImageIndex > 0) { navigationInProgress = true; displayImage(currentImageIndex - 1); setTimeout(() => { navigationInProgress = false; }, 300); } }); nextBtn.addEventListener('click', (e) => { e.preventDefault(); if (!navigationInProgress && currentImageIndex < currentResults.length - 1) { navigationInProgress = true; displayImage(currentImageIndex + 1); setTimeout(() => { navigationInProgress = false; }, 300); } }); // Zoom Controls function updateImageTransform() { if (previewImage.src) { previewImage.style.transform = `translate(${currentPanX}px, ${currentPanY}px) scale(${currentZoomLevel})`; } } // Unified zoom function for both buttons and wheel function zoomImage(newZoom, zoomX, zoomY) { if (newZoom >= MIN_ZOOM && newZoom <= MAX_ZOOM) { const oldZoom = currentZoomLevel; // If zooming out to minimum, just reset everything if (newZoom === MIN_ZOOM) { currentZoomLevel = MIN_ZOOM; currentPanX = 0; currentPanY = 0; } else { // Calculate zoom ratio const zoomRatio = newZoom / oldZoom; // Get the image and container dimensions const containerRect = imageContainer.getBoundingClientRect(); const imgRect = previewImage.getBoundingClientRect(); // Calculate the center of the image const imgCenterX = imgRect.width / 2; const imgCenterY = imgRect.height / 2; // Calculate the point to zoom relative to const relativeX = zoomX - (imgRect.left - containerRect.left); const relativeY = zoomY - (imgRect.top - containerRect.top); // Update zoom level currentZoomLevel = newZoom; // Adjust pan to maintain the zoom point position currentPanX = currentPanX + (imgCenterX - relativeX) * (zoomRatio - 1); currentPanY = currentPanY + (imgCenterY - relativeY) * (zoomRatio - 1); } updateImageTransform(); updatePanCursorState(); updateImageInfo(); } } // Zoom in button handler zoomInBtn.addEventListener('click', () => { if (currentZoomLevel < MAX_ZOOM && previewImage.src) { const containerRect = imageContainer.getBoundingClientRect(); const imgRect = previewImage.getBoundingClientRect(); // Calculate the center point of the image const centerX = (imgRect.left - containerRect.left) + imgRect.width / 2; const centerY = (imgRect.top - containerRect.top) + imgRect.height / 2; zoomImage(currentZoomLevel + 0.25, centerX, centerY); } }); // Zoom out button handler zoomOutBtn.addEventListener('click', () => { if (currentZoomLevel > MIN_ZOOM && previewImage.src) { const containerRect = imageContainer.getBoundingClientRect(); const imgRect = previewImage.getBoundingClientRect(); // Calculate the center point of the image const centerX = (imgRect.left - containerRect.left) + imgRect.width / 2; const centerY = (imgRect.top - containerRect.top) + imgRect.height / 2; zoomImage(currentZoomLevel - 0.25, centerX, centerY); } }); // Mouse wheel zoom uses cursor position imageContainer.addEventListener('wheel', (e) => { if (previewImage.src) { e.preventDefault(); // Get mouse position relative to container const rect = imageContainer.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; // Calculate zoom direction and factor const zoomDirection = e.deltaY < 0 ? 1 : -1; const zoomFactor = 0.1; // Calculate and apply new zoom level const newZoom = currentZoomLevel + (zoomDirection * zoomFactor); zoomImage(newZoom, mouseX, mouseY); } }); // Panning event listeners imageContainer.addEventListener('mousedown', (e) => { if (e.button === 0 && previewImage.src && currentZoomLevel > 1) { // Left mouse button and zoomed in isPanning = true; startPanX = e.clientX - currentPanX; startPanY = e.clientY - currentPanY; imageContainer.classList.add('panning'); e.preventDefault(); // Prevent image dragging behavior } }); window.addEventListener('mousemove', (e) => { if (isPanning) { currentPanX = e.clientX - startPanX; currentPanY = e.clientY - startPanY; updateImageTransform(); } }); window.addEventListener('mouseup', () => { if (isPanning) { isPanning = false; imageContainer.classList.remove('panning'); } }); // Function to update cursor state based on zoom level function updatePanCursorState() { if (currentZoomLevel > 1) { imageContainer.classList.add('can-pan'); } else { imageContainer.classList.remove('can-pan'); imageContainer.classList.remove('panning'); } } // Export Handlers exportCsvBtn.addEventListener('click', () => { if (!currentJobId) return; window.location.href = `/results/${currentJobId}/results.csv`; }); exportImagesBtn.addEventListener('click', () => { if (!currentJobId) return; logStatus('Downloading annotated images...'); window.location.href = `/export_images/${currentJobId}`; }); // Add keyboard controls for panning (arrow keys) window.addEventListener('keydown', (e) => { if (previewImage.src && currentZoomLevel > 1) { const PAN_AMOUNT = 30; // pixels to pan per key press switch (e.key) { case 'ArrowLeft': currentPanX += PAN_AMOUNT; updateImageTransform(); e.preventDefault(); break; case 'ArrowRight': currentPanX -= PAN_AMOUNT; updateImageTransform(); e.preventDefault(); break; case 'ArrowUp': currentPanY += PAN_AMOUNT; updateImageTransform(); e.preventDefault(); break; case 'ArrowDown': currentPanY -= PAN_AMOUNT; updateImageTransform(); e.preventDefault(); break; case 'Home': // Reset pan position but keep zoom currentPanX = 0; currentPanY = 0; updateImageTransform(); e.preventDefault(); break; } } }); // Add some instructions to the image info when zoomed in function updateImageInfo() { if (!currentResults[currentImageIndex]) return; const result = currentResults[currentImageIndex]; let infoText = ` ${result.filename}
${result.num_eggs} eggs detected `; if (currentZoomLevel > 1) { infoText += `
Click and drag to pan or use arrow keys `; } imageInfo.innerHTML = infoText; } // Initialize updateUploadState(); logStatus('Application ready'); });