nemaquant / static /script.js
sloneckity's picture
Fix Safari compatibility issues: CSV download and image navigation
da0b374
raw
history blame
35.4 kB
// 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');
});