// Global variables
let allPatterns = [];
let selectedPattern = null;
let previewObserver = null;
let currentBatch = 0;
const BATCH_SIZE = 40; // Increased batch size for better performance
let previewCache = new Map(); // Simple in-memory cache for preview data
let imageCache = new Map(); // Cache for preloaded images
// Global variables for lazy loading
let pendingPatterns = new Map(); // pattern -> element mapping
let batchTimeout = null;
const INITIAL_BATCH_SIZE = 12; // Smaller initial batch for faster first load
const LAZY_BATCH_SIZE = 5; // Reduced batch size for smoother loading
const MAX_RETRIES = 3; // Maximum number of retries for failed loads
const RETRY_DELAY = 1000; // Delay between retries in ms
// Shared caching for patterns list (persistent across sessions)
const PATTERNS_CACHE_KEY = 'dune_weaver_patterns_cache';
// IndexedDB cache for preview images with size management (shared with playlists page)
const PREVIEW_CACHE_DB_NAME = 'dune_weaver_previews';
const PREVIEW_CACHE_DB_VERSION = 1;
const PREVIEW_CACHE_STORE_NAME = 'previews';
const MAX_CACHE_SIZE_MB = 200;
const MAX_CACHE_SIZE_BYTES = MAX_CACHE_SIZE_MB * 1024 * 1024;
let previewCacheDB = null;
// Define constants for log message types
const LOG_TYPE = {
SUCCESS: 'success',
WARNING: 'warning',
ERROR: 'error',
INFO: 'info',
DEBUG: 'debug'
};
// Cache progress storage keys
const CACHE_PROGRESS_KEY = 'dune_weaver_cache_progress';
const CACHE_TIMESTAMP_KEY = 'dune_weaver_cache_timestamp';
const CACHE_PROGRESS_EXPIRY = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
// Animated Preview Variables
let animatedPreviewData = null;
let animationFrameId = null;
let isPlaying = false;
let currentProgress = 0;
let animationSpeed = 1;
let lastTimestamp = 0;
// Function to show status message
function showStatusMessage(message, type = 'success') {
const statusContainer = document.getElementById('status-message-container');
const statusMessage = document.getElementById('status-message');
if (!statusContainer || !statusMessage) return;
// Set message and color based on type
statusMessage.textContent = message;
statusMessage.className = `text-base font-semibold opacity-0 transform -translate-y-2 transition-all duration-300 ease-in-out px-4 py-2 rounded-lg shadow-lg ${
type === 'success' ? 'bg-green-50 text-green-700 border border-green-200' :
type === 'error' ? 'bg-red-50 text-red-700 border border-red-200' :
type === 'warning' ? 'bg-yellow-50 text-yellow-700 border border-yellow-200' :
'bg-blue-50 text-blue-700 border border-blue-200'
}`;
// Show message with animation
requestAnimationFrame(() => {
statusMessage.classList.remove('opacity-0', '-translate-y-2');
statusMessage.classList.add('opacity-100', 'translate-y-0');
});
// Hide message after 5 seconds
setTimeout(() => {
statusMessage.classList.remove('opacity-100', 'translate-y-0');
statusMessage.classList.add('opacity-0', '-translate-y-2');
}, 5000);
}
// Function to log messages
function logMessage(message, type = LOG_TYPE.DEBUG) {
console.log(`[${type}] ${message}`);
}
// Initialize IndexedDB for preview caching (shared with playlists page)
async function initPreviewCacheDB() {
if (previewCacheDB) return previewCacheDB;
return new Promise((resolve, reject) => {
const request = indexedDB.open(PREVIEW_CACHE_DB_NAME, PREVIEW_CACHE_DB_VERSION);
request.onerror = () => {
logMessage('Failed to open preview cache database', LOG_TYPE.ERROR);
reject(request.error);
};
request.onsuccess = () => {
previewCacheDB = request.result;
logMessage('Preview cache database opened successfully', LOG_TYPE.DEBUG);
resolve(previewCacheDB);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Create object store for preview cache
const store = db.createObjectStore(PREVIEW_CACHE_STORE_NAME, { keyPath: 'pattern' });
store.createIndex('lastAccessed', 'lastAccessed', { unique: false });
store.createIndex('size', 'size', { unique: false });
logMessage('Preview cache database schema created', LOG_TYPE.DEBUG);
};
});
}
// Get preview from IndexedDB cache
async function getPreviewFromCache(pattern) {
try {
if (!previewCacheDB) await initPreviewCacheDB();
const transaction = previewCacheDB.transaction([PREVIEW_CACHE_STORE_NAME], 'readwrite');
const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME);
return new Promise((resolve, reject) => {
const request = store.get(pattern);
request.onsuccess = () => {
const result = request.result;
if (result) {
// Update last accessed time
result.lastAccessed = Date.now();
store.put(result);
resolve(result.data);
} else {
resolve(null);
}
};
request.onerror = () => reject(request.error);
});
} catch (error) {
logMessage(`Error getting preview from cache: ${error.message}`, LOG_TYPE.WARNING);
return null;
}
}
// Save preview to IndexedDB cache with size management
async function savePreviewToCache(pattern, previewData) {
try {
if (!previewCacheDB) await initPreviewCacheDB();
// Validate preview data before attempting to fetch
if (!previewData || !previewData.image_data) {
logMessage(`Invalid preview data for ${pattern}, skipping cache save`, LOG_TYPE.WARNING);
return;
}
// Convert preview URL to blob for size calculation
const response = await fetch(previewData.image_data);
const blob = await response.blob();
const size = blob.size;
// Check if we need to free up space
await managePreviewCacheSize(size);
const cacheEntry = {
pattern: pattern,
data: previewData,
size: size,
lastAccessed: Date.now(),
created: Date.now()
};
const transaction = previewCacheDB.transaction([PREVIEW_CACHE_STORE_NAME], 'readwrite');
const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME);
return new Promise((resolve, reject) => {
const request = store.put(cacheEntry);
request.onsuccess = () => {
logMessage(`Preview cached for ${pattern} (${(size / 1024).toFixed(1)}KB)`, LOG_TYPE.DEBUG);
resolve();
};
request.onerror = () => reject(request.error);
});
} catch (error) {
logMessage(`Error saving preview to cache: ${error.message}`, LOG_TYPE.WARNING);
}
}
// Manage cache size by removing least recently used items
async function managePreviewCacheSize(newItemSize) {
try {
const currentSize = await getPreviewCacheSize();
if (currentSize + newItemSize <= MAX_CACHE_SIZE_BYTES) {
return; // No cleanup needed
}
logMessage(`Cache size would exceed limit (${((currentSize + newItemSize) / 1024 / 1024).toFixed(1)}MB), cleaning up...`, LOG_TYPE.DEBUG);
const transaction = previewCacheDB.transaction([PREVIEW_CACHE_STORE_NAME], 'readwrite');
const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME);
const index = store.index('lastAccessed');
// Get all entries sorted by last accessed (oldest first)
const entries = await new Promise((resolve, reject) => {
const request = index.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
// Sort by last accessed time (oldest first)
entries.sort((a, b) => a.lastAccessed - b.lastAccessed);
let freedSpace = 0;
const targetSpace = newItemSize + (MAX_CACHE_SIZE_BYTES * 0.1); // Free 10% extra buffer
for (const entry of entries) {
if (freedSpace >= targetSpace) break;
await new Promise((resolve, reject) => {
const deleteRequest = store.delete(entry.pattern);
deleteRequest.onsuccess = () => {
freedSpace += entry.size;
logMessage(`Evicted cached preview for ${entry.pattern} (${(entry.size / 1024).toFixed(1)}KB)`, LOG_TYPE.DEBUG);
resolve();
};
deleteRequest.onerror = () => reject(deleteRequest.error);
});
}
logMessage(`Freed ${(freedSpace / 1024 / 1024).toFixed(1)}MB from preview cache`, LOG_TYPE.DEBUG);
} catch (error) {
logMessage(`Error managing cache size: ${error.message}`, LOG_TYPE.WARNING);
}
}
// Get current cache size
async function getPreviewCacheSize() {
try {
if (!previewCacheDB) return 0;
const transaction = previewCacheDB.transaction([PREVIEW_CACHE_STORE_NAME], 'readonly');
const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME);
return new Promise((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => {
const totalSize = request.result.reduce((sum, entry) => sum + (entry.size || 0), 0);
resolve(totalSize);
};
request.onerror = () => reject(request.error);
});
} catch (error) {
logMessage(`Error getting cache size: ${error.message}`, LOG_TYPE.WARNING);
return 0;
}
}
// Preload images in batch
async function preloadImages(urls) {
const promises = urls.map(url => {
return new Promise((resolve, reject) => {
if (imageCache.has(url)) {
resolve(imageCache.get(url));
return;
}
const img = new Image();
img.onload = () => {
imageCache.set(url, img);
resolve(img);
};
img.onerror = reject;
img.src = url;
});
});
return Promise.allSettled(promises);
}
// Initialize Intersection Observer for lazy loading
function initPreviewObserver() {
if (previewObserver) {
previewObserver.disconnect();
}
previewObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const previewContainer = entry.target;
const pattern = previewContainer.dataset.pattern;
if (pattern) {
addPatternToBatch(pattern, previewContainer);
previewObserver.unobserve(previewContainer);
}
}
});
}, {
rootMargin: '200px 0px', // Reduced margin for more precise loading
threshold: 0.1
});
}
// Add pattern to pending batch for efficient loading
async function addPatternToBatch(pattern, element) {
// Check in-memory cache first
if (previewCache.has(pattern)) {
const previewData = previewCache.get(pattern);
if (previewData && !previewData.error) {
if (element) {
updatePreviewElement(element, previewData.image_data);
}
}
return;
}
// Check IndexedDB cache
const cachedData = await getPreviewFromCache(pattern);
if (cachedData && !cachedData.error) {
// Add to in-memory cache for faster access
previewCache.set(pattern, cachedData);
if (element) {
updatePreviewElement(element, cachedData.image_data);
}
return;
}
// Check if this is a newly uploaded pattern
const isNewUpload = element?.dataset.isNewUpload === 'true';
// Reset retry flags when starting fresh
if (element) {
element.dataset.retryCount = '0';
element.dataset.hasTriedIndividual = 'false';
}
// Add loading indicator with better styling
if (!element.querySelector('img')) {
const loadingText = isNewUpload ? 'Generating preview...' : 'Loading...';
element.innerHTML = `
`;
}
// Add to pending batch
pendingPatterns.set(pattern, element);
// Process batch immediately if it's full or if it's a new upload
if (pendingPatterns.size >= LAZY_BATCH_SIZE || isNewUpload) {
processPendingBatch();
}
}
// Update preview element with smooth transition
function updatePreviewElement(element, imageUrl) {
const img = new Image();
img.onload = () => {
element.innerHTML = '';
element.appendChild(img);
img.className = 'w-full h-full object-contain transition-opacity duration-300';
img.style.opacity = '0';
requestAnimationFrame(() => {
img.style.opacity = '1';
});
};
img.src = imageUrl;
img.alt = 'Pattern Preview';
}
// Process pending patterns in batches
async function processPendingBatch() {
if (pendingPatterns.size === 0) return;
// Create a copy of current pending patterns and clear the original
const currentBatch = new Map(pendingPatterns);
pendingPatterns.clear();
const patternsToLoad = Array.from(currentBatch.keys());
try {
logMessage(`Loading batch of ${patternsToLoad.length} pattern previews`, LOG_TYPE.DEBUG);
const response = await fetch('/preview_thr_batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_names: patternsToLoad })
});
if (response.ok) {
const results = await response.json();
// Process all results
for (const [pattern, data] of Object.entries(results)) {
const element = currentBatch.get(pattern);
if (data && !data.error && data.image_data) {
// Cache in memory with size limit
if (previewCache.size > 100) { // Limit cache size
const oldestKey = previewCache.keys().next().value;
previewCache.delete(oldestKey);
}
previewCache.set(pattern, data);
// Save to IndexedDB cache for persistence
await savePreviewToCache(pattern, data);
if (element) {
updatePreviewElement(element, data.image_data);
}
} else {
handleLoadError(pattern, element, data?.error || 'Failed to load preview');
}
}
}
} catch (error) {
logMessage(`Error loading preview batch: ${error.message}`, LOG_TYPE.ERROR);
// Handle error for each pattern in batch
for (const pattern of patternsToLoad) {
const element = currentBatch.get(pattern);
handleLoadError(pattern, element, error.message);
}
}
}
// Trigger preview loading for currently visible patterns
function triggerPreviewLoadingForVisible() {
// Get all pattern cards currently in the DOM
const patternCards = document.querySelectorAll('.pattern-card');
patternCards.forEach(card => {
const pattern = card.dataset.pattern;
const previewContainer = card.querySelector('.pattern-preview');
// Check if this pattern needs preview loading
if (pattern && !previewCache.has(pattern) && !pendingPatterns.has(pattern)) {
// Add to batch for immediate loading
addPatternToBatch(pattern, previewContainer);
}
});
// Process any pending previews immediately
if (pendingPatterns.size > 0) {
processPendingBatch();
}
}
// Load individual pattern preview (fallback when batch loading fails)
async function loadIndividualPreview(pattern, element) {
try {
logMessage(`Loading individual preview for ${pattern}`, LOG_TYPE.DEBUG);
const response = await fetch('/preview_thr_batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_names: [pattern] })
});
if (response.ok) {
const results = await response.json();
const data = results[pattern];
if (data && !data.error && data.image_data) {
// Cache in memory with size limit
if (previewCache.size > 100) { // Limit cache size
const oldestKey = previewCache.keys().next().value;
previewCache.delete(oldestKey);
}
previewCache.set(pattern, data);
// Save to IndexedDB cache for persistence
await savePreviewToCache(pattern, data);
if (element) {
updatePreviewElement(element, data.image_data);
}
logMessage(`Individual preview loaded successfully for ${pattern}`, LOG_TYPE.DEBUG);
} else {
throw new Error(data?.error || 'Failed to load preview data');
}
} else {
throw new Error(`HTTP error! status: ${response.status}`);
}
} catch (error) {
logMessage(`Error loading individual preview for ${pattern}: ${error.message}`, LOG_TYPE.ERROR);
// Continue with normal error handling
handleLoadError(pattern, element, error.message);
}
}
// Handle load errors with retry logic
function handleLoadError(pattern, element, error) {
const retryCount = element.dataset.retryCount || 0;
const isNewUpload = element.dataset.isNewUpload === 'true';
const hasTriedIndividual = element.dataset.hasTriedIndividual === 'true';
// Use longer delays for newly uploaded patterns
const retryDelay = isNewUpload ? RETRY_DELAY * 2 : RETRY_DELAY;
const maxRetries = isNewUpload ? MAX_RETRIES * 2 : MAX_RETRIES;
if (retryCount < maxRetries) {
// Update retry count
element.dataset.retryCount = parseInt(retryCount) + 1;
// Determine retry strategy
let retryStrategy = 'batch';
if (retryCount >= 1 && !hasTriedIndividual) {
// After first batch attempt fails, try individual loading
retryStrategy = 'individual';
element.dataset.hasTriedIndividual = 'true';
}
// Show retry message with different text for new uploads and retry strategies
let retryText;
if (isNewUpload) {
retryText = retryStrategy === 'individual' ?
`Trying individual load... (${retryCount + 1}/${maxRetries})` :
`Generating preview... (${retryCount + 1}/${maxRetries})`;
} else {
retryText = retryStrategy === 'individual' ?
`Trying individual load... (${retryCount + 1}/${maxRetries})` :
`Retrying... (${retryCount + 1}/${maxRetries})`;
}
element.innerHTML = `
${isNewUpload ? 'Processing new pattern' : 'Failed to load'}
${retryText}
`;
// Retry after delay with appropriate strategy
setTimeout(() => {
if (retryStrategy === 'individual') {
loadIndividualPreview(pattern, element);
} else {
addPatternToBatch(pattern, element);
}
}, retryDelay);
} else {
// Show final error state
element.innerHTML = `
Failed to load
Click to retry
`;
// Add click handler for manual retry
element.onclick = () => {
element.dataset.retryCount = '0';
element.dataset.hasTriedIndividual = 'false';
addPatternToBatch(pattern, element);
};
}
previewCache.set(pattern, { error: true });
}
// Load and display patterns
async function loadPatterns(forceRefresh = false) {
try {
logMessage('Loading patterns...', LOG_TYPE.INFO);
logMessage('Fetching fresh patterns list from server', LOG_TYPE.DEBUG);
const response = await fetch('/list_theta_rho_files');
const allFiles = await response.json();
logMessage(`Received ${allFiles.length} files from server`, LOG_TYPE.INFO);
// Filter for .thr files
let patterns = allFiles.filter(file => file.endsWith('.thr'));
logMessage(`Filtered to ${patterns.length} .thr files`, LOG_TYPE.INFO);
if (forceRefresh) {
showStatusMessage('Patterns list refreshed successfully', 'success');
}
// Sort patterns with custom_patterns on top and all alphabetically sorted
const sortedPatterns = patterns.sort((a, b) => {
const isCustomA = a.startsWith('custom_patterns/');
const isCustomB = b.startsWith('custom_patterns/');
if (isCustomA && !isCustomB) return -1;
if (!isCustomA && isCustomB) return 1;
return a.localeCompare(b);
});
allPatterns = sortedPatterns;
currentBatch = 0;
logMessage('Displaying initial batch of patterns...', LOG_TYPE.INFO);
displayPatternBatch();
logMessage('Initial batch loaded successfully.', LOG_TYPE.SUCCESS);
} catch (error) {
logMessage(`Error loading patterns: ${error.message}`, LOG_TYPE.ERROR);
console.error('Full error:', error);
showStatusMessage('Failed to load patterns', 'error');
}
}
// Display a batch of patterns with improved initial load
function displayPatternBatch() {
const patternGrid = document.querySelector('.grid');
if (!patternGrid) {
logMessage('Pattern grid not found in the DOM', LOG_TYPE.ERROR);
return;
}
const start = currentBatch * BATCH_SIZE;
const end = Math.min(start + BATCH_SIZE, allPatterns.length);
const batchPatterns = allPatterns.slice(start, end);
// Display batch patterns
batchPatterns.forEach(pattern => {
const patternCard = createPatternCard(pattern);
patternGrid.appendChild(patternCard);
});
// If there are more patterns to load, set up the observer for the last few cards
if (end < allPatterns.length) {
const lastCards = Array.from(patternGrid.children).slice(-3); // Observe last 3 cards
lastCards.forEach(card => {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
currentBatch++;
displayPatternBatch();
observer.disconnect();
}
}, {
rootMargin: '200px 0px',
threshold: 0.1
});
observer.observe(card);
});
}
}
// Create a pattern card element
function createPatternCard(pattern) {
const card = document.createElement('div');
card.className = 'pattern-card flex flex-col items-center gap-3 bg-gray-50';
card.dataset.pattern = pattern;
// Create preview container with proper styling for loading indicator
const previewContainer = document.createElement('div');
previewContainer.className = 'w-32 h-32 rounded-full shadow-md relative pattern-preview group';
previewContainer.dataset.pattern = pattern;
// Add loading indicator
previewContainer.innerHTML = '';
// Add play button overlay (hidden by default, shown on hover)
const playOverlay = document.createElement('div');
playOverlay.className = 'absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 cursor-pointer';
playOverlay.innerHTML = 'play_arrow
';
// Add click handler for play button (separate from card click)
playOverlay.addEventListener('click', (e) => {
e.stopPropagation(); // Prevent card selection
openAnimatedPreview(pattern);
});
previewContainer.appendChild(playOverlay);
// Create pattern name
const patternName = document.createElement('p');
patternName.className = 'text-gray-700 text-sm font-medium text-center truncate w-full';
patternName.textContent = pattern.replace('.thr', '').split('/').pop();
// Add click handler
card.onclick = () => selectPattern(pattern, card);
// Check if preview is already in cache
const previewData = previewCache.get(pattern);
if (previewData && !previewData.error && previewData.image_data) {
updatePreviewElement(previewContainer, previewData.image_data);
} else {
// Start observing the preview container for lazy loading
previewObserver.observe(previewContainer);
}
card.appendChild(previewContainer);
card.appendChild(patternName);
return card;
}
// Select a pattern
function selectPattern(pattern, card) {
// Remove selected class from all cards
document.querySelectorAll('.pattern-card').forEach(c => {
c.classList.remove('selected');
});
// Add selected class to clicked card
card.classList.add('selected');
// Show pattern preview
showPatternPreview(pattern);
}
// Show pattern preview
async function showPatternPreview(pattern) {
try {
// Check in-memory cache first
let data = previewCache.get(pattern);
// If not in cache, fetch it
if (!data) {
const response = await fetch('/preview_thr_batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_names: [pattern] })
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const results = await response.json();
data = results[pattern];
if (data && !data.error) {
// Cache in memory
previewCache.set(pattern, data);
} else {
throw new Error(data?.error || 'Failed to get preview data');
}
}
const previewPanel = document.getElementById('patternPreviewPanel');
const layoutContainer = document.querySelector('.layout-content-container');
// Update preview content
if (data.image_data) {
document.getElementById('patternPreviewImage').src = data.image_data;
}
// Set pattern name in the preview panel
const patternName = pattern.replace('.thr', '').split('/').pop();
document.getElementById('patternPreviewTitle').textContent = patternName;
// Format and display coordinates
const formatCoordinate = (coord) => {
if (!coord) return '(0, 0)';
const x = coord.x !== undefined ? coord.x.toFixed(1) : '0.0';
const y = coord.y !== undefined ? coord.y.toFixed(1) : '0.0';
return `(${x}, ${y})`;
};
document.getElementById('firstCoordinate').textContent = formatCoordinate(data.first_coordinate);
document.getElementById('lastCoordinate').textContent = formatCoordinate(data.last_coordinate);
// Show preview panel
previewPanel.classList.remove('translate-x-full');
if (window.innerWidth >= 1024) {
// For large screens, show preview alongside content
layoutContainer.parentElement.classList.add('preview-open');
previewPanel.classList.remove('lg:opacity-0', 'lg:pointer-events-none');
} else {
// For small screens, show preview as overlay
layoutContainer.parentElement.classList.remove('preview-open');
}
// Setup preview panel events
setupPreviewPanelEvents(pattern);
} catch (error) {
logMessage(`Error showing preview: ${error.message}`, LOG_TYPE.ERROR);
}
}
function hidePatternPreview() {
const previewPanel = document.getElementById('patternPreviewPanel');
const layoutContainer = document.querySelector('.layout-content-container');
previewPanel.classList.add('translate-x-full');
if (window.innerWidth >= 1024) {
previewPanel.classList.add('lg:opacity-0', 'lg:pointer-events-none');
}
layoutContainer.parentElement.classList.remove('preview-open');
}
// Add window resize handler
window.addEventListener('resize', () => {
const previewPanel = document.getElementById('patternPreviewPanel');
const layoutContainer = document.querySelector('.layout-content-container');
if (window.innerWidth >= 1024) {
if (!previewPanel.classList.contains('translate-x-full')) {
layoutContainer.parentElement.classList.add('preview-open');
previewPanel.classList.remove('lg:opacity-0', 'lg:pointer-events-none');
}
} else {
layoutContainer.parentElement.classList.remove('preview-open');
previewPanel.classList.add('lg:opacity-0', 'lg:pointer-events-none');
}
});
// Setup preview panel events
function setupPreviewPanelEvents(pattern) {
const panel = document.getElementById('patternPreviewPanel');
const closeButton = document.getElementById('closePreviewPanel');
const playButton = document.getElementById('playPattern');
const deleteButton = document.getElementById('deletePattern');
const preExecutionInputs = document.querySelectorAll('input[name="preExecutionAction"]');
const previewPlayOverlay = document.getElementById('previewPlayOverlay');
// Close panel when clicking the close button
closeButton.onclick = () => {
hidePatternPreview();
// Remove selected state from all cards when closing
document.querySelectorAll('.pattern-card').forEach(c => {
c.classList.remove('selected');
});
};
// Handle play button overlay click in preview panel
if (previewPlayOverlay) {
previewPlayOverlay.onclick = () => {
openAnimatedPreview(pattern);
};
}
// Handle play button click
playButton.onclick = async () => {
if (!pattern) {
showStatusMessage('No pattern selected', 'error');
return;
}
try {
// Show the preview modal
if (window.openPlayerPreviewModal) {
window.openPlayerPreviewModal();
}
// Get the selected pre-execution action
const preExecutionInput = document.querySelector('input[name="preExecutionAction"]:checked');
const preExecution = preExecutionInput ? preExecutionInput.value : 'none';
const response = await fetch('/run_theta_rho', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
file_name: pattern,
pre_execution: preExecution
})
});
const data = await response.json();
if (response.ok) {
showStatusMessage(`Running pattern: ${pattern.split('/').pop()}`, 'success');
hidePatternPreview();
} else {
let errorMsg = data.detail || 'Failed to run pattern';
let errorType = 'error';
// Handle specific error cases with appropriate messaging
if (data.detail === 'Connection not established') {
errorMsg = 'Please connect to the device before running a pattern';
errorType = 'warning';
} else if (response.status === 409) {
errorMsg = 'Another pattern is already running. Please stop the current pattern first.';
errorType = 'warning';
} else if (response.status === 404) {
errorMsg = 'Pattern file not found. Please refresh the page and try again.';
errorType = 'error';
} else if (response.status === 400) {
errorMsg = 'Invalid request. Please check your settings and try again.';
errorType = 'error';
} else if (response.status === 500) {
errorMsg = 'Server error. Please try again later.';
errorType = 'error';
}
showStatusMessage(errorMsg, errorType);
return;
}
} catch (error) {
console.error('Error running pattern:', error);
// Handle network errors specifically
if (error.name === 'TypeError' && error.message.includes('fetch')) {
showStatusMessage('Network error. Please check your connection and try again.', 'error');
} else if (error.message && error.message.includes('409')) {
showStatusMessage('Another pattern is already running', 'warning');
} else if (error.message) {
showStatusMessage(error.message, 'error');
} else {
showStatusMessage('Failed to run pattern', 'error');
}
}
};
// Handle delete button click
deleteButton.onclick = async () => {
if (!pattern.startsWith('custom_patterns/')) {
logMessage('Cannot delete built-in patterns', LOG_TYPE.WARNING);
showStatusMessage('Cannot delete built-in patterns', 'warning');
return;
}
if (confirm('Are you sure you want to delete this pattern?')) {
try {
logMessage(`Deleting pattern: ${pattern}`, LOG_TYPE.INFO);
const response = await fetch('/delete_theta_rho_file', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ file_name: pattern })
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result.success) {
logMessage(`Pattern deleted successfully: ${pattern}`, LOG_TYPE.SUCCESS);
showStatusMessage(`Pattern "${pattern.split('/').pop()}" deleted successfully`);
// Remove the pattern card
const selectedCard = document.querySelector('.pattern-card.selected');
if (selectedCard) {
selectedCard.remove();
}
// Close the preview panel
const previewPanel = document.getElementById('patternPreviewPanel');
const layoutContainer = document.querySelector('.layout-content-container');
previewPanel.classList.add('translate-x-full');
if (window.innerWidth >= 1024) {
previewPanel.classList.add('lg:opacity-0', 'lg:pointer-events-none');
}
layoutContainer.parentElement.classList.remove('preview-open');
// Clear the preview panel content
document.getElementById('patternPreviewImage').src = '';
document.getElementById('patternPreviewTitle').textContent = 'Pattern Details';
document.getElementById('firstCoordinate').textContent = '(0, 0)';
document.getElementById('lastCoordinate').textContent = '(0, 0)';
// Refresh the pattern list (force refresh since pattern was deleted)
await loadPatterns(true);
} else {
throw new Error(result.error || 'Unknown error');
}
} catch (error) {
logMessage(`Failed to delete pattern: ${error.message}`, LOG_TYPE.ERROR);
showStatusMessage(`Failed to delete pattern: ${error.message}`, 'error');
}
}
};
// Handle pre-execution action changes
preExecutionInputs.forEach(input => {
input.onchange = () => {
const action = input.parentElement.textContent.trim();
logMessage(`Pre-execution action changed to: ${action}`, LOG_TYPE.INFO);
};
});
}
// Search patterns
function searchPatterns(query) {
if (!query) {
// If search is empty, clear grid and show all patterns
const patternGrid = document.querySelector('.grid');
if (patternGrid) {
patternGrid.innerHTML = '';
}
// Reset current batch and display from beginning
currentBatch = 0;
displayPatternBatch();
return;
}
const searchInput = query.toLowerCase();
const patternGrid = document.querySelector('.grid');
if (!patternGrid) {
logMessage('Pattern grid not found in the DOM', LOG_TYPE.ERROR);
return;
}
// Clear existing patterns
patternGrid.innerHTML = '';
// Filter patterns
const filteredPatterns = allPatterns.filter(pattern =>
pattern.toLowerCase().includes(searchInput)
);
// Display filtered patterns
filteredPatterns.forEach(pattern => {
const patternCard = createPatternCard(pattern);
patternGrid.appendChild(patternCard);
});
// Give the browser a chance to render the cards
requestAnimationFrame(() => {
// Trigger preview loading for the search results
triggerPreviewLoadingForVisible();
});
logMessage(`Showing ${filteredPatterns.length} patterns matching "${query}"`, LOG_TYPE.INFO);
}
// Filter patterns by category
function filterPatternsByCategory(category) {
// TODO: Implement category filtering logic
logMessage(`Filtering patterns by category: ${category}`, LOG_TYPE.INFO);
}
// Filter patterns by tag
function filterPatternsByTag(tag) {
// TODO: Implement tag filtering logic
logMessage(`Filtering patterns by tag: ${tag}`, LOG_TYPE.INFO);
}
// Initialize the patterns page
document.addEventListener('DOMContentLoaded', async () => {
try {
logMessage('Initializing patterns page...', LOG_TYPE.DEBUG);
// Initialize IndexedDB preview cache (shared with playlists page)
await initPreviewCacheDB();
// Setup upload event handlers
setupUploadEventHandlers();
// Initialize intersection observer for lazy loading
initPreviewObserver();
// Setup search functionality
const searchInput = document.getElementById('patternSearch');
const searchButton = document.getElementById('searchButton');
const cacheAllButton = document.getElementById('cacheAllButton');
if (searchInput && searchButton) {
// Search on button click
searchButton.addEventListener('click', () => {
searchPatterns(searchInput.value.trim());
});
// Search on Enter key
searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
searchPatterns(searchInput.value.trim());
}
});
// Clear search when input is empty
searchInput.addEventListener('input', (e) => {
if (e.target.value.trim() === '') {
searchPatterns('');
}
});
}
// Setup cache all button - now triggers the modal
if (cacheAllButton) {
cacheAllButton.addEventListener('click', () => {
// Always show the modal when manually clicked, using forceShow parameter
if (typeof showCacheAllPrompt === 'function') {
showCacheAllPrompt(true); // true = forceShow
} else {
// Fallback if function not available
const modal = document.getElementById('cacheAllPromptModal');
if (modal) {
modal.classList.remove('hidden');
modal.dataset.manuallyTriggered = 'true';
}
}
});
}
// Load patterns on page load
await loadPatterns();
logMessage('Patterns page initialized successfully', LOG_TYPE.SUCCESS);
} catch (error) {
logMessage(`Error during initialization: ${error.message}`, LOG_TYPE.ERROR);
}
});
function updateCurrentlyPlayingUI(status) {
// Get all required DOM elements once
const container = document.getElementById('currently-playing-container');
const fileNameElement = document.getElementById('currently-playing-file');
const progressBar = document.getElementById('play_progress');
const progressText = document.getElementById('play_progress_text');
const pausePlayButton = document.getElementById('pausePlayCurrent');
const speedDisplay = document.getElementById('current_speed_display');
const speedInput = document.getElementById('speedInput');
// Check if all required elements exist
if (!container || !fileNameElement || !progressBar || !progressText) {
console.log('Required DOM elements not found:', {
container: !!container,
fileNameElement: !!fileNameElement,
progressBar: !!progressBar,
progressText: !!progressText
});
setTimeout(() => updateCurrentlyPlayingUI(status), 100);
return;
}
// Update container visibility based on status
if (status.current_file && status.is_running) {
document.body.classList.add('playing');
container.style.display = 'flex';
} else {
document.body.classList.remove('playing');
container.style.display = 'none';
}
// Update file name display
if (status.current_file) {
const fileName = status.current_file.replace('./patterns/', '');
fileNameElement.textContent = fileName;
} else {
fileNameElement.textContent = 'No pattern playing';
}
// Update next file display
const nextFileElement = document.getElementById('next-file');
if (nextFileElement) {
if (status.playlist && status.playlist.next_file) {
const nextFileName = status.playlist.next_file.replace('./patterns/', '');
nextFileElement.textContent = `(Next: ${nextFileName})`;
nextFileElement.style.display = 'block';
} else {
nextFileElement.style.display = 'none';
}
}
// Update speed display and input if they exist
if (status.speed) {
if (speedDisplay) {
speedDisplay.textContent = `Current Speed: ${status.speed}`;
}
if (speedInput) {
speedInput.value = status.speed;
}
}
// Update pattern preview if it's a new pattern
// ... existing code ...
}
// Setup upload event handlers
function setupUploadEventHandlers() {
// Upload file input handler
document.getElementById('patternFileInput').addEventListener('change', async function(e) {
const file = e.target.files[0];
if (!file) return;
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/upload_theta_rho', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
showStatusMessage(`Pattern "${file.name}" uploaded successfully`);
// Clear any existing cache for this pattern to ensure fresh loading
const newPatternPath = `custom_patterns/${file.name}`;
previewCache.delete(newPatternPath);
// Add a small delay to allow backend preview generation to complete
await new Promise(resolve => setTimeout(resolve, 1000));
// Refresh the pattern list (force refresh since new pattern was uploaded)
await loadPatterns(true);
// Clear the file input
e.target.value = '';
// Trigger preview loading for newly uploaded patterns with extended retry
setTimeout(() => {
const newPatternCard = document.querySelector(`[data-pattern="${newPatternPath}"]`);
if (newPatternCard) {
const previewContainer = newPatternCard.querySelector('.pattern-preview');
if (previewContainer) {
// Clear any existing retry count and force reload
previewContainer.dataset.retryCount = '0';
previewContainer.dataset.hasTriedIndividual = 'false';
previewContainer.dataset.isNewUpload = 'true';
addPatternToBatch(newPatternPath, previewContainer);
}
}
}, 500);
} else {
showStatusMessage(`Failed to upload pattern: ${result.error}`, 'error');
}
} catch (error) {
console.error('Error uploading pattern:', error);
showStatusMessage(`Error uploading pattern: ${error.message}`, 'error');
}
});
// Pattern deletion handler
const deleteModal = document.getElementById('deleteConfirmModal');
if (deleteModal) {
const confirmBtn = deleteModal.querySelector('#confirmDeleteBtn');
const cancelBtn = deleteModal.querySelector('#cancelDeleteBtn');
if (confirmBtn) {
confirmBtn.addEventListener('click', async () => {
const patternToDelete = confirmBtn.dataset.pattern;
if (patternToDelete) {
await deletePattern(patternToDelete);
// Force refresh after deletion
await loadPatterns(true);
}
deleteModal.classList.add('hidden');
});
}
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
deleteModal.classList.add('hidden');
});
}
}
}
// Cache all pattern previews
async function cacheAllPreviews() {
const cacheAllButton = document.getElementById('cacheAllButton');
if (!cacheAllButton) return;
try {
// Disable button and show loading state
cacheAllButton.disabled = true;
// Get current cache size
const currentSize = await getPreviewCacheSize();
const maxSize = MAX_CACHE_SIZE_BYTES || (200 * 1024 * 1024); // 200MB default
if (currentSize > maxSize) {
// Clear cache if it's too large
await clearPreviewCache();
// Also clear progress since we're starting fresh
localStorage.removeItem(CACHE_PROGRESS_KEY);
localStorage.removeItem(CACHE_TIMESTAMP_KEY);
}
// Get all patterns that aren't cached yet
const uncachedPatterns = allPatterns.filter(pattern => !previewCache.has(pattern));
if (uncachedPatterns.length === 0) {
showStatusMessage('All patterns are already cached!', 'info');
return;
}
// Check for existing progress
let startIndex = 0;
const savedProgress = localStorage.getItem(CACHE_PROGRESS_KEY);
const savedTimestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY);
if (savedProgress && savedTimestamp) {
const progressAge = Date.now() - parseInt(savedTimestamp);
if (progressAge < CACHE_PROGRESS_EXPIRY) {
const lastCachedPattern = savedProgress;
const lastIndex = uncachedPatterns.findIndex(p => p === lastCachedPattern);
if (lastIndex !== -1) {
startIndex = lastIndex + 1;
showStatusMessage('Resuming from previous progress...', 'info');
}
} else {
// Clear expired progress
localStorage.removeItem(CACHE_PROGRESS_KEY);
localStorage.removeItem(CACHE_TIMESTAMP_KEY);
}
}
// Process patterns in smaller batches to avoid overwhelming the server
const BATCH_SIZE = 10;
const remainingPatterns = uncachedPatterns.slice(startIndex);
const totalBatches = Math.ceil(remainingPatterns.length / BATCH_SIZE);
for (let i = 0; i < totalBatches; i++) {
const batchStart = i * BATCH_SIZE;
const batchEnd = Math.min(batchStart + BATCH_SIZE, remainingPatterns.length);
const batchPatterns = remainingPatterns.slice(batchStart, batchEnd);
// Update button text with progress
const overallProgress = Math.round(((startIndex + batchStart + BATCH_SIZE) / uncachedPatterns.length) * 100);
cacheAllButton.innerHTML = `
Caching ${overallProgress}%
`;
try {
const response = await fetch('/preview_thr_batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_names: batchPatterns })
});
if (response.ok) {
const results = await response.json();
// Cache each preview
for (const [pattern, data] of Object.entries(results)) {
if (data && !data.error && data.image_data) {
previewCache.set(pattern, data);
await savePreviewToCache(pattern, data);
// Save progress after each successful pattern
localStorage.setItem(CACHE_PROGRESS_KEY, pattern);
localStorage.setItem(CACHE_TIMESTAMP_KEY, Date.now().toString());
}
}
}
} catch (error) {
logMessage(`Error caching batch ${i + 1}: ${error.message}`, LOG_TYPE.ERROR);
// Don't clear progress on error - allows resuming from last successful pattern
}
// Small delay between batches to prevent overwhelming the server
await new Promise(resolve => setTimeout(resolve, 100));
}
// Clear progress after successful completion
localStorage.removeItem(CACHE_PROGRESS_KEY);
localStorage.removeItem(CACHE_TIMESTAMP_KEY);
// Show success message
showStatusMessage('All pattern previews have been cached!', 'success');
} catch (error) {
logMessage(`Error caching previews: ${error.message}`, LOG_TYPE.ERROR);
showStatusMessage('Failed to cache all previews. Click again to resume.', 'error');
} finally {
// Reset button state
if (cacheAllButton) {
cacheAllButton.disabled = false;
cacheAllButton.innerHTML = `
cached
Cache All Previews
`;
}
}
}
// Open animated preview modal
async function openAnimatedPreview(pattern) {
try {
const modal = document.getElementById('animatedPreviewModal');
const title = document.getElementById('animatedPreviewTitle');
const canvas = document.getElementById('animatedPreviewCanvas');
const ctx = canvas.getContext('2d');
// Set title
title.textContent = pattern.replace('.thr', '').split('/').pop();
// Show modal
modal.classList.remove('hidden');
// Load pattern coordinates
const response = await fetch('/get_theta_rho_coordinates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_name: pattern })
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
animatedPreviewData = data.coordinates;
// Setup canvas
setupAnimatedPreviewCanvas(ctx);
// Setup controls
setupAnimatedPreviewControls();
// Draw initial state
drawAnimatedPreview(ctx, 0);
// Auto-play the animation
setTimeout(() => {
playAnimation();
}, 100); // Small delay to ensure everything is set up
} catch (error) {
logMessage(`Error opening animated preview: ${error.message}`, LOG_TYPE.ERROR);
showStatusMessage('Failed to load pattern for animation', 'error');
}
}
// Setup animated preview canvas
function setupAnimatedPreviewCanvas(ctx) {
const canvas = ctx.canvas;
const size = canvas.width;
const center = size / 2;
const scale = (size / 2) - 30; // Slightly smaller to account for border
// Clear canvas with white background
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, size, size);
// Set drawing style for ultra-high quality lines
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1; // Thinner line for higher resolution
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// Enable high quality rendering
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
}
// Setup animated preview controls
function setupAnimatedPreviewControls() {
const modal = document.getElementById('animatedPreviewModal');
const closeBtn = document.getElementById('closeAnimatedPreview');
const playPauseBtn = document.getElementById('playPauseBtn');
const resetBtn = document.getElementById('resetBtn');
const speedSlider = document.getElementById('speedSlider');
const speedValue = document.getElementById('speedValue');
const progressSlider = document.getElementById('progressSlider');
const progressValue = document.getElementById('progressValue');
const canvas = document.getElementById('animatedPreviewCanvas');
const playPauseOverlay = document.getElementById('playPauseOverlay');
// Set responsive canvas size with ultra-high-DPI support
const setCanvasSize = () => {
const container = canvas.parentElement;
const modal = document.getElementById('animatedPreviewModal');
if (!container || !modal) return;
// Calculate available viewport space
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Calculate modal content area (95vh max height - header - padding)
const modalMaxHeight = viewportHeight * 0.95;
const headerHeight = 80; // Approximate header height with padding
const modalPadding = 48; // Modal padding (p-6 = 24px each side)
const availableHeight = modalMaxHeight - headerHeight - modalPadding;
// Calculate available width (max-w-4xl = 896px, but respect viewport)
const modalMaxWidth = Math.min(896, viewportWidth - 32); // Account for modal margin
const availableWidth = modalMaxWidth - modalPadding;
// Calculate ideal canvas size (use 80% of available space as requested)
const targetHeight = availableHeight * 0.8;
const targetWidth = availableWidth * 0.8;
// Use the smaller dimension to maintain square aspect ratio
let idealSize = Math.min(targetWidth, targetHeight);
// Cap at reasonable maximum and minimum
idealSize = Math.min(idealSize, 800); // Maximum size cap
idealSize = Math.max(idealSize, 200); // Minimum size
const displaySize = idealSize;
console.log('Canvas sizing:', {
viewport: `${viewportWidth}x${viewportHeight}`,
availableModal: `${availableWidth}x${availableHeight}`,
target80pct: `${targetWidth}x${targetHeight}`,
finalSize: displaySize
});
// Get device pixel ratio and multiply by 2 for higher resolution
const pixelRatio = (window.devicePixelRatio || 1) * 2;
// Set the display size (CSS pixels) - use pixels, not percentage
canvas.style.width = displaySize + 'px';
canvas.style.height = displaySize + 'px';
// Set the actual canvas size (device pixels) - increased resolution
canvas.width = displaySize * pixelRatio;
canvas.height = displaySize * pixelRatio;
// Scale the context to match the increased pixel ratio
const ctx = canvas.getContext('2d', { alpha: false }); // Disable alpha for better performance
ctx.scale(pixelRatio, pixelRatio);
// Enable high quality rendering
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
// Redraw with new size
if (animatedPreviewData) {
setupAnimatedPreviewCanvas(ctx);
drawAnimatedPreview(ctx, currentProgress / 100);
}
};
// Set initial size
setCanvasSize();
// Handle window resize with debouncing
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(setCanvasSize, 16); // ~60fps update rate
});
// Close modal
closeBtn.onclick = closeAnimatedPreview;
modal.onclick = (e) => {
if (e.target === modal) closeAnimatedPreview();
};
// Play/Pause button
playPauseBtn.onclick = toggleAnimation;
// Reset button
resetBtn.onclick = resetAnimation;
// Speed slider
speedSlider.oninput = (e) => {
animationSpeed = parseFloat(e.target.value);
speedValue.textContent = `${animationSpeed}x`;
};
// Progress slider
progressSlider.oninput = (e) => {
currentProgress = parseFloat(e.target.value);
progressValue.textContent = `${currentProgress.toFixed(1)}%`;
drawAnimatedPreview(canvas.getContext('2d'), currentProgress / 100);
if (isPlaying) {
// Pause animation when manually adjusting progress
toggleAnimation();
}
};
// Canvas click to play/pause
canvas.onclick = () => {
playPauseOverlay.style.opacity = '1';
setTimeout(() => {
playPauseOverlay.style.opacity = '0';
}, 200);
toggleAnimation();
};
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (modal.classList.contains('hidden')) return;
switch(e.code) {
case 'Space':
e.preventDefault();
toggleAnimation();
break;
case 'Escape':
closeAnimatedPreview();
break;
case 'ArrowLeft':
e.preventDefault();
currentProgress = Math.max(0, currentProgress - 5);
updateProgressUI();
drawAnimatedPreview(canvas.getContext('2d'), currentProgress / 100);
break;
case 'ArrowRight':
e.preventDefault();
currentProgress = Math.min(100, currentProgress + 5);
updateProgressUI();
drawAnimatedPreview(canvas.getContext('2d'), currentProgress / 100);
break;
}
});
}
// Draw animated preview
function drawAnimatedPreview(ctx, progress) {
if (!animatedPreviewData || animatedPreviewData.length === 0) return;
const canvas = ctx.canvas;
const pixelRatio = (window.devicePixelRatio || 1) * 2; // Match the increased ratio
const displayWidth = parseInt(canvas.style.width);
const displayHeight = parseInt(canvas.style.height);
const center = (canvas.width / pixelRatio) / 2;
const scale = ((canvas.width / pixelRatio) / 2) - 30;
// Clear canvas with white background
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Calculate how many points to draw
const totalPoints = animatedPreviewData.length;
const pointsToDraw = Math.floor(totalPoints * progress);
if (pointsToDraw < 2) return;
// Draw the path with ultra-high quality settings
ctx.beginPath();
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1; // Thinner line for higher resolution
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// Ensure sub-pixel alignment for ultra-high resolution
for (let i = 0; i < pointsToDraw; i++) {
const [theta, rho] = animatedPreviewData[i];
// Round to nearest 0.25 for even more precise lines
// Mirror both X and Y coordinates
const x = Math.round((center + rho * scale * Math.cos(theta)) * 4) / 4; // Changed minus to plus
const y = Math.round((center + rho * scale * Math.sin(theta)) * 4) / 4;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.stroke();
// Draw current position dot
if (pointsToDraw > 0) {
const [currentTheta, currentRho] = animatedPreviewData[pointsToDraw - 1];
const currentX = Math.round((center + currentRho * scale * Math.cos(currentTheta)) * 4) / 4; // Changed minus to plus
const currentY = Math.round((center + currentRho * scale * Math.sin(currentTheta)) * 4) / 4;
// Draw a filled circle at current position with anti-aliasing
ctx.fillStyle = '#ff4444'; // Red dot
ctx.beginPath();
ctx.arc(currentX, currentY, 6, 0, 2 * Math.PI); // Increased dot size
ctx.fill();
// Add a subtle white border
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1.5;
ctx.stroke();
}
}
// Toggle animation play/pause
function toggleAnimation() {
if (isPlaying) {
pauseAnimation();
} else {
playAnimation();
}
}
// Play animation
function playAnimation() {
if (!animatedPreviewData) return;
isPlaying = true;
lastTimestamp = performance.now();
// Update UI
const playPauseBtn = document.getElementById('playPauseBtn');
const playPauseBtnIcon = document.getElementById('playPauseBtnIcon');
const playPauseBtnText = document.getElementById('playPauseBtnText');
if (playPauseBtnIcon) playPauseBtnIcon.textContent = 'pause';
if (playPauseBtnText) playPauseBtnText.textContent = 'Pause';
// Start animation loop
animationFrameId = requestAnimationFrame(animate);
}
// Pause animation
function pauseAnimation() {
isPlaying = false;
// Update UI
const playPauseBtn = document.getElementById('playPauseBtn');
const playPauseBtnIcon = document.getElementById('playPauseBtnIcon');
const playPauseBtnText = document.getElementById('playPauseBtnText');
if (playPauseBtnIcon) playPauseBtnIcon.textContent = 'play_arrow';
if (playPauseBtnText) playPauseBtnText.textContent = 'Play';
// Cancel animation frame
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
}
// Animation loop
function animate(timestamp) {
if (!isPlaying) return;
const deltaTime = timestamp - lastTimestamp;
const progressIncrement = (deltaTime / 1000) * animationSpeed * 2.0; // Much faster base speed
currentProgress = Math.min(100, currentProgress + progressIncrement);
// Update UI
updateProgressUI();
// Draw frame
const canvas = document.getElementById('animatedPreviewCanvas');
if (canvas) {
drawAnimatedPreview(canvas.getContext('2d'), currentProgress / 100);
}
// Continue animation
if (currentProgress < 100) {
lastTimestamp = timestamp;
animationFrameId = requestAnimationFrame(animate);
} else {
// Animation complete
pauseAnimation();
}
}
// Reset animation
function resetAnimation() {
pauseAnimation();
currentProgress = 0;
updateProgressUI();
const canvas = document.getElementById('animatedPreviewCanvas');
drawAnimatedPreview(canvas.getContext('2d'), 0);
}
// Update progress UI
function updateProgressUI() {
const progressSlider = document.getElementById('progressSlider');
const progressValue = document.getElementById('progressValue');
progressSlider.value = currentProgress;
progressValue.textContent = `${currentProgress.toFixed(1)}%`;
}
// Close animated preview
function closeAnimatedPreview() {
pauseAnimation();
const modal = document.getElementById('animatedPreviewModal');
modal.classList.add('hidden');
// Clear data
animatedPreviewData = null;
currentProgress = 0;
animationSpeed = 1;
// Reset UI
const speedSlider = document.getElementById('speedSlider');
const speedValue = document.getElementById('speedValue');
const progressSlider = document.getElementById('progressSlider');
const progressValue = document.getElementById('progressValue');
speedSlider.value = 1;
speedValue.textContent = '1x';
progressSlider.value = 0;
progressValue.textContent = '0%';
}