Spaces:
Sleeping
Sleeping
| // 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 = ` | |
| <div class="summary-header"> | |
| <i class="ri-file-list-3-line"></i> | |
| <span>Images ready for processing</span> | |
| </div> | |
| `; | |
| 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 = '<i class="ri-loader-4-line"></i> Processing...'; | |
| document.body.classList.add('processing'); | |
| // Disable input changes during processing | |
| inputMode.disabled = true; | |
| fileInput.disabled = true; | |
| confidenceSlider.disabled = true; | |
| } else { | |
| startProcessingBtn.innerHTML = '<i class="ri-play-line"></i> 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 = ` | |
| <td> | |
| <i class="ri-image-line"></i> | |
| ${result.filename} | |
| </td> | |
| <td>${result.num_eggs}</td> | |
| <td class="text-right"> | |
| <button class="view-button" title="Click to view image"> | |
| <i class="ri-eye-line"></i> | |
| View | |
| </button> | |
| </td> | |
| `; | |
| // 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 = '<i class="ri-arrow-left-s-line"></i>'; | |
| 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 = '<i class="ri-arrow-right-s-line"></i>'; | |
| 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 = ` | |
| <i class="ri-image-line"></i> ${result.filename} | |
| <br> | |
| <i class="ri-egg-line"></i> ${result.num_eggs} eggs detected | |
| `; | |
| if (currentZoomLevel > 1) { | |
| infoText += ` | |
| <br> | |
| <small style="color: var(--text-muted);"> | |
| <i class="ri-drag-move-line"></i> Click and drag to pan or use arrow keys | |
| </small> | |
| `; | |
| } | |
| imageInfo.innerHTML = infoText; | |
| } | |
| // Initialize | |
| updateUploadState(); | |
| logStatus('Application ready'); | |
| }); |