const API_BASE = '/api/v1'; let refreshInterval = null; let apiKey = null; // Track logged errors to avoid console spam const loggedErrors = new Map(); // deviceId -> { errorCount, lastError } // Calibration test mode state const calibrationTestState = {}; // deviceId -> Set of active edge names // Modal dirty tracking - stores initial values when modals open let settingsInitialValues = {}; let calibrationInitialValues = {}; const EDGE_TEST_COLORS = { top: [255, 0, 0], right: [0, 255, 0], bottom: [0, 100, 255], left: [255, 255, 0] }; // Modal body lock helpers - prevent layout jump when scrollbar disappears function lockBody() { const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; document.body.style.paddingRight = scrollbarWidth + 'px'; document.body.classList.add('modal-open'); } function unlockBody() { document.body.classList.remove('modal-open'); document.body.style.paddingRight = ''; } // Locale management let currentLocale = 'en'; let translations = {}; const supportedLocales = { 'en': 'English', 'ru': 'Русский' }; // Minimal inline fallback for critical UI elements const fallbackTranslations = { 'app.title': 'WLED Screen Controller', 'auth.placeholder': 'Enter your API key...', 'auth.button.login': 'Login' }; // Translation function function t(key, params = {}) { let text = translations[key] || fallbackTranslations[key] || key; // Replace parameters like {name}, {value}, etc. Object.keys(params).forEach(param => { text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]); }); return text; } // Load translation file async function loadTranslations(locale) { try { const response = await fetch(`/static/locales/${locale}.json`); if (!response.ok) { throw new Error(`Failed to load ${locale}.json`); } return await response.json(); } catch (error) { console.error(`Error loading translations for ${locale}:`, error); // Fallback to English if loading fails if (locale !== 'en') { return await loadTranslations('en'); } return {}; } } // Detect browser locale function detectBrowserLocale() { const browserLang = navigator.language || navigator.languages?.[0] || 'en'; const langCode = browserLang.split('-')[0]; // 'en-US' -> 'en', 'ru-RU' -> 'ru' // Only return if we support it return supportedLocales[langCode] ? langCode : 'en'; } // Initialize locale async function initLocale() { const savedLocale = localStorage.getItem('locale') || detectBrowserLocale(); await setLocale(savedLocale); } // Set locale async function setLocale(locale) { if (!supportedLocales[locale]) { locale = 'en'; } // Load translations for the locale translations = await loadTranslations(locale); currentLocale = locale; document.documentElement.setAttribute('data-locale', locale); document.documentElement.setAttribute('lang', locale); localStorage.setItem('locale', locale); // Update all text updateAllText(); // Update locale select dropdown (if visible) updateLocaleSelect(); } // Change locale from dropdown function changeLocale() { const select = document.getElementById('locale-select'); const newLocale = select.value; if (newLocale && newLocale !== currentLocale) { localStorage.setItem('locale', newLocale); setLocale(newLocale); } } // Update locale select dropdown function updateLocaleSelect() { const select = document.getElementById('locale-select'); if (select) { select.value = currentLocale; } } // Update all text on page function updateAllText() { // Update all elements with data-i18n attribute document.querySelectorAll('[data-i18n]').forEach(el => { const key = el.getAttribute('data-i18n'); el.textContent = t(key); }); // Update all elements with data-i18n-placeholder attribute document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { const key = el.getAttribute('data-i18n-placeholder'); el.placeholder = t(key); }); // Update all elements with data-i18n-title attribute document.querySelectorAll('[data-i18n-title]').forEach(el => { const key = el.getAttribute('data-i18n-title'); el.title = t(key); }); // Re-render dynamic content with new translations if (apiKey) { loadDisplays(); loadDevices(); } } // Initialize app document.addEventListener('DOMContentLoaded', async () => { // Initialize locale first await initLocale(); // Show content now that translations are loaded document.body.style.visibility = 'visible'; // Restore active tab initTabs(); // Load API key from localStorage apiKey = localStorage.getItem('wled_api_key'); // Setup form handler document.getElementById('add-device-form').addEventListener('submit', handleAddDevice); // Show modal if no API key is stored if (!apiKey) { // Wait for modal functions to be defined setTimeout(() => { if (typeof showApiKeyModal === 'function') { showApiKeyModal('Welcome! Please login with your API key to get started.', true); } }, 100); return; // Don't load data yet } // User is logged in, load data loadServerInfo(); loadDisplays(); loadDevices(); // Start auto-refresh startAutoRefresh(); }); // Helper function to add auth header if needed function getHeaders() { const headers = { 'Content-Type': 'application/json' }; if (apiKey) { headers['Authorization'] = `Bearer ${apiKey}`; } return headers; } // Handle 401 errors by showing login modal function handle401Error() { // Clear invalid API key localStorage.removeItem('wled_api_key'); apiKey = null; // Stop auto-refresh to prevent repeated 401 errors if (refreshInterval) { clearInterval(refreshInterval); refreshInterval = null; } if (typeof updateAuthUI === 'function') { updateAuthUI(); } if (typeof showApiKeyModal === 'function') { showApiKeyModal('Your session has expired or the API key is invalid. Please login again.', true); } else { showToast('Authentication failed. Please reload the page and login.', 'error'); } } // Configure API key function configureApiKey() { const currentKey = localStorage.getItem('wled_api_key'); const message = currentKey ? 'Current API key is set. Enter new key to update or leave blank to remove:' : 'Enter your API key:'; const key = prompt(message); if (key === null) { return; // Cancelled } if (key === '') { localStorage.removeItem('wled_api_key'); apiKey = null; document.getElementById('api-key-btn').style.display = 'none'; showToast('API key removed', 'info'); } else { localStorage.setItem('wled_api_key', key); apiKey = key; document.getElementById('api-key-btn').style.display = 'inline-block'; showToast('API key updated', 'success'); } // Reload data with new key loadServerInfo(); loadDisplays(); loadDevices(); } // Server info async function loadServerInfo() { try { const response = await fetch('/health'); const data = await response.json(); document.getElementById('version-number').textContent = `v${data.version}`; document.getElementById('server-status').textContent = '●'; document.getElementById('server-status').className = 'status-badge online'; } catch (error) { console.error('Failed to load server info:', error); document.getElementById('server-status').className = 'status-badge offline'; showToast(t('server.offline'), 'error'); } } // Load displays async function loadDisplays() { try { const response = await fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }); if (response.status === 401) { handle401Error(); return; } const data = await response.json(); const container = document.getElementById('displays-list'); if (!data.displays || data.displays.length === 0) { container.innerHTML = `
${t('displays.none')}
`; document.getElementById('display-layout-canvas').innerHTML = `
${t('displays.none')}
`; return; } // Cache and render visual layout _cachedDisplays = data.displays; renderDisplayLayout(data.displays); } catch (error) { console.error('Failed to load displays:', error); document.getElementById('displays-list').innerHTML = `
${t('displays.failed')}
`; document.getElementById('display-layout-canvas').innerHTML = `
${t('displays.failed')}
`; } } let _cachedDisplays = null; function switchTab(name) { document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === name)); document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.toggle('active', panel.id === `tab-${name}`)); localStorage.setItem('activeTab', name); if (name === 'displays' && _cachedDisplays) { requestAnimationFrame(() => renderDisplayLayout(_cachedDisplays)); } } function initTabs() { const saved = localStorage.getItem('activeTab'); if (saved && document.getElementById(`tab-${saved}`)) { switchTab(saved); } } function renderDisplayLayout(displays) { const canvas = document.getElementById('display-layout-canvas'); if (!displays || displays.length === 0) { canvas.innerHTML = `
${t('displays.none')}
`; return; } // Calculate bounding box for all displays let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; displays.forEach(display => { minX = Math.min(minX, display.x); minY = Math.min(minY, display.y); maxX = Math.max(maxX, display.x + display.width); maxY = Math.max(maxY, display.y + display.height); }); const totalWidth = maxX - minX; const totalHeight = maxY - minY; // Scale factor to fit in canvas (respect available width, maintain aspect ratio) const availableWidth = canvas.clientWidth - 60; // account for padding const maxCanvasHeight = 350; const scaleX = availableWidth / totalWidth; const scaleY = maxCanvasHeight / totalHeight; const scale = Math.min(scaleX, scaleY); const canvasWidth = totalWidth * scale; const canvasHeight = totalHeight * scale; // Create display elements const displayElements = displays.map(display => { const left = (display.x - minX) * scale; const top = (display.y - minY) * scale; const width = display.width * scale; const height = display.height * scale; return `
(${display.x}, ${display.y})
#${display.index}
${display.name} ${display.width}×${display.height} ${display.refresh_rate}Hz
${display.is_primary ? '
' : ''}
`; }).join(''); canvas.innerHTML = `
${displayElements}
`; } // Load devices async function loadDevices() { try { const response = await fetch(`${API_BASE}/devices`, { headers: getHeaders() }); if (response.status === 401) { handle401Error(); return; } const data = await response.json(); const devices = data.devices || []; const container = document.getElementById('devices-list'); if (!devices || devices.length === 0) { container.innerHTML = `
+
${t('devices.add')}
`; return; } // Fetch state for each device const devicesWithState = await Promise.all( devices.map(async (device) => { try { const stateResponse = await fetch(`${API_BASE}/devices/${device.id}/state`, { headers: getHeaders() }); const state = await stateResponse.json(); const metricsResponse = await fetch(`${API_BASE}/devices/${device.id}/metrics`, { headers: getHeaders() }); const metrics = await metricsResponse.json(); // Log device errors only when they change (avoid console spam) const deviceKey = device.id; const lastLogged = loggedErrors.get(deviceKey); const hasNewErrors = !lastLogged || lastLogged.errorCount !== metrics.errors_count || lastLogged.lastError !== metrics.last_error; if (metrics.errors_count > 0 && hasNewErrors) { console.warn(`[Device: ${device.name || device.id}] Has ${metrics.errors_count} error(s)`); // Log recent errors from state if (state.errors && state.errors.length > 0) { console.error('Recent errors:'); state.errors.forEach((error, index) => { console.error(` ${index + 1}. ${error}`); }); } // Log last error from metrics if (metrics.last_error) { console.error('Last error:', metrics.last_error); } // Log full state and metrics for debugging console.log('Full state:', state); console.log('Full metrics:', metrics); // Update tracking loggedErrors.set(deviceKey, { errorCount: metrics.errors_count, lastError: metrics.last_error }); } else if (metrics.errors_count === 0 && lastLogged) { // Clear tracking when errors are resolved console.log(`[Device: ${device.name || device.id}] Errors cleared`); loggedErrors.delete(deviceKey); } return { ...device, state, metrics }; } catch (error) { console.error(`Failed to load state for device ${device.id}:`, error); return device; } }) ); container.innerHTML = devicesWithState.map(device => createDeviceCard(device)).join('') + `
+
${t('devices.add')}
`; // Update footer WLED Web UI link with first device's URL const webuiLink = document.querySelector('.wled-webui-link'); if (webuiLink && devicesWithState.length > 0 && devicesWithState[0].url) { webuiLink.href = devicesWithState[0].url; webuiLink.target = '_blank'; webuiLink.rel = 'noopener'; } // Attach event listeners devicesWithState.forEach(device => { attachDeviceListeners(device.id); }); } catch (error) { console.error('Failed to load devices:', error); document.getElementById('devices-list').innerHTML = `
${t('devices.failed')}
`; } } function createDeviceCard(device) { const state = device.state || {}; const metrics = device.metrics || {}; const settings = device.settings || {}; const isProcessing = state.processing || false; const brightnessPercent = Math.round((settings.brightness !== undefined ? settings.brightness : 1.0) * 100); const statusKey = isProcessing ? 'device.status.processing' : 'device.status.idle'; const status = isProcessing ? 'processing' : 'idle'; // WLED device health indicator const wledOnline = state.wled_online || false; const wledLatency = state.wled_latency_ms; const wledName = state.wled_name; const wledVersion = state.wled_version; const wledLastChecked = state.wled_last_checked; let healthClass, healthTitle, healthLabel; if (wledLastChecked === null || wledLastChecked === undefined) { healthClass = 'health-unknown'; healthTitle = t('device.health.checking'); healthLabel = ''; } else if (wledOnline) { healthClass = 'health-online'; healthTitle = `${t('device.health.online')}`; if (wledName) healthTitle += ` - ${wledName}`; if (wledVersion) healthTitle += ` v${wledVersion}`; if (wledLatency !== null && wledLatency !== undefined) healthTitle += ` (${Math.round(wledLatency)}ms)`; healthLabel = ''; } else { healthClass = 'health-offline'; healthTitle = t('device.health.offline'); if (state.wled_error) healthTitle += `: ${state.wled_error}`; healthLabel = `${t('device.health.offline')}`; } const displayIndex = settings.display_index !== undefined ? settings.display_index : 0; const ledCount = state.wled_led_count || device.led_count; return `
${device.name || device.id} ${healthLabel} ${isProcessing ? `${t('device.status.processing')}` : ''}
${wledVersion ? `v${wledVersion}` : ''} 🖥️ ${displayIndex} ${ledCount ? `💡 ${ledCount}` : ''} ${state.wled_led_type ? `🔌 ${state.wled_led_type.replace(/ RGBW$/, '')}` : ''} ${state.wled_rgbw ? '' : ''}
${t('device.url')} ${device.url || 'N/A'}
${isProcessing ? `
${state.fps_actual?.toFixed(1) || '0.0'}
${t('device.metrics.actual_fps')}
${state.fps_target || 0}
${t('device.metrics.target_fps')}
${metrics.frames_processed || 0}
${t('device.metrics.frames')}
${metrics.errors_count || 0}
${t('device.metrics.errors')}
` : ''}
${isProcessing ? ` ` : ` `} ${device.url ? ` 🌐 ` : ''}
`; } function attachDeviceListeners(deviceId) { // Add any specific event listeners here if needed } // Device actions async function startProcessing(deviceId) { console.log(`[Device: ${deviceId}] Starting processing...`); try { const response = await fetch(`${API_BASE}/devices/${deviceId}/start`, { method: 'POST', headers: getHeaders() }); if (response.status === 401) { handle401Error(); return; } if (response.ok) { console.log(`[Device: ${deviceId}] Processing started successfully`); showToast('Processing started', 'success'); loadDevices(); } else { const error = await response.json(); console.error(`[Device: ${deviceId}] Failed to start:`, error); showToast(`Failed to start: ${error.detail}`, 'error'); } } catch (error) { console.error(`[Device: ${deviceId}] Failed to start processing:`, error); showToast('Failed to start processing', 'error'); } } async function stopProcessing(deviceId) { console.log(`[Device: ${deviceId}] Stopping processing...`); try { const response = await fetch(`${API_BASE}/devices/${deviceId}/stop`, { method: 'POST', headers: getHeaders() }); if (response.status === 401) { handle401Error(); return; } if (response.ok) { console.log(`[Device: ${deviceId}] Processing stopped successfully`); showToast('Processing stopped', 'success'); loadDevices(); } else { const error = await response.json(); console.error(`[Device: ${deviceId}] Failed to stop:`, error); showToast(`Failed to stop: ${error.detail}`, 'error'); } } catch (error) { console.error(`[Device: ${deviceId}] Failed to stop processing:`, error); showToast('Failed to stop processing', 'error'); } } async function removeDevice(deviceId) { const confirmed = await showConfirm(t('device.remove.confirm')); if (!confirmed) { return; } try { const response = await fetch(`${API_BASE}/devices/${deviceId}`, { method: 'DELETE', headers: getHeaders() }); if (response.status === 401) { handle401Error(); return; } if (response.ok) { showToast('Device removed', 'success'); loadDevices(); } else { const error = await response.json(); showToast(`Failed to remove: ${error.detail}`, 'error'); } } catch (error) { console.error('Failed to remove device:', error); showToast('Failed to remove device', 'error'); } } async function showSettings(deviceId) { try { // Fetch device data and displays in parallel const [deviceResponse, displaysResponse] = await Promise.all([ fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }), fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }), ]); if (deviceResponse.status === 401) { handle401Error(); return; } if (!deviceResponse.ok) { showToast('Failed to load device settings', 'error'); return; } const device = await deviceResponse.json(); // Populate display index select const displaySelect = document.getElementById('settings-display-index'); displaySelect.innerHTML = ''; if (displaysResponse.ok) { const displaysData = await displaysResponse.json(); (displaysData.displays || []).forEach(d => { const opt = document.createElement('option'); opt.value = d.index; opt.textContent = `${d.index}: ${d.width}x${d.height}${d.is_primary ? ` (${t('displays.badge.primary')})` : ''}`; displaySelect.appendChild(opt); }); } if (displaySelect.options.length === 0) { const opt = document.createElement('option'); opt.value = '0'; opt.textContent = '0'; displaySelect.appendChild(opt); } displaySelect.value = String(device.settings.display_index ?? 0); // Populate other fields document.getElementById('settings-device-id').value = device.id; document.getElementById('settings-device-name').value = device.name; document.getElementById('settings-device-url').value = device.url; document.getElementById('settings-health-interval').value = device.settings.state_check_interval || 30; // Snapshot initial values for dirty checking settingsInitialValues = { name: device.name, url: device.url, display_index: String(device.settings.display_index ?? 0), state_check_interval: String(device.settings.state_check_interval || 30), }; // Show modal const modal = document.getElementById('device-settings-modal'); modal.style.display = 'flex'; lockBody(); // Focus first input setTimeout(() => { document.getElementById('settings-device-name').focus(); }, 100); } catch (error) { console.error('Failed to load device settings:', error); showToast('Failed to load device settings', 'error'); } } function isSettingsDirty() { return ( document.getElementById('settings-device-name').value !== settingsInitialValues.name || document.getElementById('settings-device-url').value !== settingsInitialValues.url || document.getElementById('settings-display-index').value !== settingsInitialValues.display_index || document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval ); } function forceCloseDeviceSettingsModal() { const modal = document.getElementById('device-settings-modal'); const error = document.getElementById('settings-error'); modal.style.display = 'none'; error.style.display = 'none'; unlockBody(); settingsInitialValues = {}; } async function closeDeviceSettingsModal() { if (isSettingsDirty()) { const confirmed = await showConfirm(t('modal.discard_changes')); if (!confirmed) return; } forceCloseDeviceSettingsModal(); } async function saveDeviceSettings() { const deviceId = document.getElementById('settings-device-id').value; const name = document.getElementById('settings-device-name').value.trim(); const url = document.getElementById('settings-device-url').value.trim(); const display_index = parseInt(document.getElementById('settings-display-index').value) || 0; const state_check_interval = parseInt(document.getElementById('settings-health-interval').value) || 30; const error = document.getElementById('settings-error'); // Validation if (!name || !url) { error.textContent = 'Please fill in all fields correctly'; error.style.display = 'block'; return; } try { // Update device info (name, url) const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, { method: 'PUT', headers: getHeaders(), body: JSON.stringify({ name, url }) }); if (deviceResponse.status === 401) { handle401Error(); return; } if (!deviceResponse.ok) { const errorData = await deviceResponse.json(); error.textContent = `Failed to update device: ${errorData.detail}`; error.style.display = 'block'; return; } // Update settings (health check interval) const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, { method: 'PUT', headers: getHeaders(), body: JSON.stringify({ display_index, state_check_interval }) }); if (settingsResponse.status === 401) { handle401Error(); return; } if (settingsResponse.ok) { showToast('Device settings updated', 'success'); forceCloseDeviceSettingsModal(); loadDevices(); } else { const errorData = await settingsResponse.json(); error.textContent = `Failed to update settings: ${errorData.detail}`; error.style.display = 'block'; } } catch (err) { console.error('Failed to save device settings:', err); error.textContent = 'Failed to save settings'; error.style.display = 'block'; } } // Card brightness controls function updateBrightnessLabel(deviceId, value) { const slider = document.querySelector(`[data-device-id="${deviceId}"] .brightness-slider`); if (slider) slider.title = value + '%'; } async function saveCardBrightness(deviceId, value) { const brightness = parseInt(value) / 100.0; try { await fetch(`${API_BASE}/devices/${deviceId}/settings`, { method: 'PUT', headers: getHeaders(), body: JSON.stringify({ brightness }) }); } catch (err) { console.error('Failed to update brightness:', err); showToast('Failed to update brightness', 'error'); } } // Add device modal function showAddDevice() { const modal = document.getElementById('add-device-modal'); const form = document.getElementById('add-device-form'); const error = document.getElementById('add-device-error'); form.reset(); error.style.display = 'none'; modal.style.display = 'flex'; lockBody(); setTimeout(() => document.getElementById('device-name').focus(), 100); } function closeAddDeviceModal() { const modal = document.getElementById('add-device-modal'); modal.style.display = 'none'; unlockBody(); } async function handleAddDevice(event) { event.preventDefault(); const name = document.getElementById('device-name').value.trim(); const url = document.getElementById('device-url').value.trim(); const error = document.getElementById('add-device-error'); if (!name || !url) { error.textContent = 'Please fill in all fields'; error.style.display = 'block'; return; } try { const response = await fetch(`${API_BASE}/devices`, { method: 'POST', headers: getHeaders(), body: JSON.stringify({ name, url }) }); if (response.status === 401) { handle401Error(); return; } if (response.ok) { const result = await response.json(); console.log('Device added successfully:', result); showToast('Device added successfully', 'success'); closeAddDeviceModal(); loadDevices(); } else { const errorData = await response.json(); console.error('Failed to add device:', errorData); error.textContent = `Failed to add device: ${errorData.detail}`; error.style.display = 'block'; } } catch (err) { console.error('Failed to add device:', err); showToast('Failed to add device', 'error'); } } // Auto-refresh function startAutoRefresh() { if (refreshInterval) { clearInterval(refreshInterval); } refreshInterval = setInterval(() => { // Only refresh if user is authenticated if (apiKey) { loadDevices(); } }, 2000); // Refresh every 2 seconds } // Toast notifications function showToast(message, type = 'info') { const toast = document.getElementById('toast'); toast.textContent = message; toast.className = `toast ${type} show`; setTimeout(() => { toast.className = 'toast'; }, 3000); } // Confirmation modal let confirmResolve = null; function showConfirm(message, title = null) { return new Promise((resolve) => { confirmResolve = resolve; const modal = document.getElementById('confirm-modal'); const titleEl = document.getElementById('confirm-title'); const messageEl = document.getElementById('confirm-message'); const yesBtn = document.getElementById('confirm-yes-btn'); const noBtn = document.getElementById('confirm-no-btn'); titleEl.textContent = title || t('confirm.title'); messageEl.textContent = message; yesBtn.textContent = t('confirm.yes'); noBtn.textContent = t('confirm.no'); modal.style.display = 'flex'; lockBody(); }); } function closeConfirmModal(result) { const modal = document.getElementById('confirm-modal'); modal.style.display = 'none'; unlockBody(); if (confirmResolve) { confirmResolve(result); confirmResolve = null; } } // Calibration functions async function showCalibration(deviceId) { try { // Fetch device data and displays in parallel const [response, displaysResponse] = await Promise.all([ fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }), fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }), ]); if (response.status === 401) { handle401Error(); return; } if (!response.ok) { showToast('Failed to load calibration', 'error'); return; } const device = await response.json(); const calibration = device.calibration; // Set aspect ratio from device's display const preview = document.querySelector('.calibration-preview'); if (displaysResponse.ok) { const displaysData = await displaysResponse.json(); const displayIndex = device.settings?.display_index ?? 0; const display = (displaysData.displays || []).find(d => d.index === displayIndex); if (display && display.width && display.height) { preview.style.aspectRatio = `${display.width} / ${display.height}`; } else { preview.style.aspectRatio = ''; } } else { preview.style.aspectRatio = ''; } // Store device ID and LED count document.getElementById('calibration-device-id').value = device.id; document.getElementById('cal-device-led-count-inline').textContent = device.led_count; // Set layout document.getElementById('cal-start-position').value = calibration.start_position; document.getElementById('cal-layout').value = calibration.layout; document.getElementById('cal-offset').value = calibration.offset || 0; // Set LED counts per edge document.getElementById('cal-top-leds').value = calibration.leds_top || 0; document.getElementById('cal-right-leds').value = calibration.leds_right || 0; document.getElementById('cal-bottom-leds').value = calibration.leds_bottom || 0; document.getElementById('cal-left-leds').value = calibration.leds_left || 0; // Initialize edge spans window.edgeSpans = { top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 }, right: { start: calibration.span_right_start ?? 0, end: calibration.span_right_end ?? 1 }, bottom: { start: calibration.span_bottom_start ?? 0, end: calibration.span_bottom_end ?? 1 }, left: { start: calibration.span_left_start ?? 0, end: calibration.span_left_end ?? 1 }, }; // Snapshot initial values for dirty checking calibrationInitialValues = { start_position: calibration.start_position, layout: calibration.layout, offset: String(calibration.offset || 0), top: String(calibration.leds_top || 0), right: String(calibration.leds_right || 0), bottom: String(calibration.leds_bottom || 0), left: String(calibration.leds_left || 0), spans: JSON.stringify(window.edgeSpans), }; // Initialize test mode state for this device calibrationTestState[device.id] = new Set(); // Update preview updateCalibrationPreview(); // Show modal const modal = document.getElementById('calibration-modal'); modal.style.display = 'flex'; lockBody(); // Initialize span drag and render canvas after layout settles initSpanDrag(); requestAnimationFrame(() => renderCalibrationCanvas()); // Re-render on container resize (e.g. window resize changes aspect-ratio container) if (!window._calibrationResizeObserver) { window._calibrationResizeObserver = new ResizeObserver(() => { updateSpanBars(); renderCalibrationCanvas(); }); } window._calibrationResizeObserver.observe(preview); } catch (error) { console.error('Failed to load calibration:', error); showToast('Failed to load calibration', 'error'); } } function isCalibrationDirty() { return ( document.getElementById('cal-start-position').value !== calibrationInitialValues.start_position || document.getElementById('cal-layout').value !== calibrationInitialValues.layout || document.getElementById('cal-offset').value !== calibrationInitialValues.offset || document.getElementById('cal-top-leds').value !== calibrationInitialValues.top || document.getElementById('cal-right-leds').value !== calibrationInitialValues.right || document.getElementById('cal-bottom-leds').value !== calibrationInitialValues.bottom || document.getElementById('cal-left-leds').value !== calibrationInitialValues.left || JSON.stringify(window.edgeSpans) !== calibrationInitialValues.spans ); } function forceCloseCalibrationModal() { const deviceId = document.getElementById('calibration-device-id').value; if (deviceId) { clearTestMode(deviceId); } if (window._calibrationResizeObserver) { window._calibrationResizeObserver.disconnect(); } const modal = document.getElementById('calibration-modal'); const error = document.getElementById('calibration-error'); modal.style.display = 'none'; error.style.display = 'none'; unlockBody(); calibrationInitialValues = {}; } async function closeCalibrationModal() { if (isCalibrationDirty()) { const confirmed = await showConfirm(t('modal.discard_changes')); if (!confirmed) return; } forceCloseCalibrationModal(); } function updateCalibrationPreview() { // Calculate total from edge inputs const total = parseInt(document.getElementById('cal-top-leds').value || 0) + parseInt(document.getElementById('cal-right-leds').value || 0) + parseInt(document.getElementById('cal-bottom-leds').value || 0) + parseInt(document.getElementById('cal-left-leds').value || 0); // Warning if total doesn't match device LED count const totalEl = document.querySelector('.preview-screen-total'); const deviceCount = parseInt(document.getElementById('cal-device-led-count-inline').textContent || 0); const mismatch = total !== deviceCount; document.getElementById('cal-total-leds-inline').textContent = (mismatch ? '\u26A0 ' : '') + total; if (totalEl) { totalEl.classList.toggle('mismatch', mismatch); } // Update corner dot highlights for start position const startPos = document.getElementById('cal-start-position').value; ['top_left', 'top_right', 'bottom_left', 'bottom_right'].forEach(corner => { const cornerEl = document.querySelector(`.preview-corner.corner-${corner.replace('_', '-')}`); if (cornerEl) { if (corner === startPos) { cornerEl.classList.add('active'); } else { cornerEl.classList.remove('active'); } } }); // Update direction toggle display const direction = document.getElementById('cal-layout').value; const dirIcon = document.getElementById('direction-icon'); const dirLabel = document.getElementById('direction-label'); if (dirIcon) dirIcon.textContent = direction === 'clockwise' ? '↻' : '↺'; if (dirLabel) dirLabel.textContent = direction === 'clockwise' ? 'CW' : 'CCW'; // Update edge highlight states const deviceId = document.getElementById('calibration-device-id').value; const activeEdges = calibrationTestState[deviceId] || new Set(); ['top', 'right', 'bottom', 'left'].forEach(edge => { const toggleEl = document.querySelector(`.edge-toggle.toggle-${edge}`); if (!toggleEl) return; if (activeEdges.has(edge)) { const [r, g, b] = EDGE_TEST_COLORS[edge]; toggleEl.style.background = `rgba(${r}, ${g}, ${b}, 0.35)`; toggleEl.style.boxShadow = `inset 0 0 6px rgba(${r}, ${g}, ${b}, 0.5)`; } else { toggleEl.style.background = ''; toggleEl.style.boxShadow = ''; } }); // Position span bars and render canvas overlay updateSpanBars(); renderCalibrationCanvas(); } function renderCalibrationCanvas() { const canvas = document.getElementById('calibration-preview-canvas'); if (!canvas) return; const container = canvas.parentElement; const containerRect = container.getBoundingClientRect(); if (containerRect.width === 0 || containerRect.height === 0) return; // Canvas extends beyond the container (matches CSS: left:-40px, top:-40px, +80px/+80px) const padX = 40; const padY = 40; const dpr = window.devicePixelRatio || 1; const canvasW = containerRect.width + padX * 2; const canvasH = containerRect.height + padY * 2; canvas.width = canvasW * dpr; canvas.height = canvasH * dpr; const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); ctx.clearRect(0, 0, canvasW, canvasH); // Container origin within canvas coordinate system const ox = padX; const oy = padY; const cW = containerRect.width; // container inner width const cH = containerRect.height; // container inner height // Read current form values const startPos = document.getElementById('cal-start-position').value; const layout = document.getElementById('cal-layout').value; const offset = parseInt(document.getElementById('cal-offset').value || 0); const calibration = { start_position: startPos, layout: layout, offset: offset, leds_top: parseInt(document.getElementById('cal-top-leds').value || 0), leds_right: parseInt(document.getElementById('cal-right-leds').value || 0), leds_bottom: parseInt(document.getElementById('cal-bottom-leds').value || 0), leds_left: parseInt(document.getElementById('cal-left-leds').value || 0), }; const segments = buildSegments(calibration); if (segments.length === 0) return; const totalLeds = calibration.leds_top + calibration.leds_right + calibration.leds_bottom + calibration.leds_left; // Theme-aware colors const isDark = document.documentElement.getAttribute('data-theme') !== 'light'; const tickStroke = isDark ? 'rgba(255, 255, 255, 0.4)' : 'rgba(0, 0, 0, 0.3)'; const tickFill = isDark ? 'rgba(255, 255, 255, 0.65)' : 'rgba(0, 0, 0, 0.6)'; const chevronStroke = isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.4)'; // Edge bar geometry (matches CSS: corner zones 56px × 36px fixed) const cw = 56; const ch = 36; // Span-aware edge geometry: ticks/arrows render only within the span region const spans = window.edgeSpans || {}; const edgeLenH = cW - 2 * cw; const edgeLenV = cH - 2 * ch; const edgeGeometry = { top: { x1: ox + cw + (spans.top?.start || 0) * edgeLenH, x2: ox + cw + (spans.top?.end || 1) * edgeLenH, midY: oy + ch / 2, horizontal: true }, bottom: { x1: ox + cw + (spans.bottom?.start || 0) * edgeLenH, x2: ox + cw + (spans.bottom?.end || 1) * edgeLenH, midY: oy + cH - ch / 2, horizontal: true }, left: { y1: oy + ch + (spans.left?.start || 0) * edgeLenV, y2: oy + ch + (spans.left?.end || 1) * edgeLenV, midX: ox + cw / 2, horizontal: false }, right: { y1: oy + ch + (spans.right?.start || 0) * edgeLenV, y2: oy + ch + (spans.right?.end || 1) * edgeLenV, midX: ox + cW - cw / 2, horizontal: false }, }; // Axis positions for labels (outside the 16px toggle zones) const toggleSize = 16; const axisPos = { top: oy - toggleSize - 3, bottom: oy + cH + toggleSize + 3, left: ox - toggleSize - 3, right: ox + cW + toggleSize + 3, }; // Arrow positions (inside the screen area, near each edge bar) const arrowInset = 12; const arrowPos = { top: oy + ch + arrowInset, bottom: oy + cH - ch - arrowInset, left: ox + cw + arrowInset, right: ox + cW - cw - arrowInset, }; // Draw ticks and direction arrows for each segment segments.forEach(seg => { const geo = edgeGeometry[seg.edge]; if (!geo) return; const count = seg.led_count; if (count === 0) return; // Edge boundary ticks (first/last LED on edge) and special ticks (LED 0 position) const edgeBounds = new Set(); edgeBounds.add(0); if (count > 1) edgeBounds.add(count - 1); const specialTicks = new Set(); if (offset > 0 && totalLeds > 0) { const zeroPos = (totalLeds - seg.led_start % totalLeds) % totalLeds; if (zeroPos < count) specialTicks.add(zeroPos); } // Round-number ticks get priority; edge boundary labels suppressed if overlapping const labelsToShow = new Set([...specialTicks]); const tickLinesOnly = new Set(); if (count > 2) { const edgeLen = geo.horizontal ? (geo.x2 - geo.x1) : (geo.y2 - geo.y1); const maxDigits = String(totalLeds > 0 ? totalLeds - 1 : count - 1).length; const minSpacing = geo.horizontal ? maxDigits * 7 + 8 : 22; const allMandatory = new Set([...edgeBounds, ...specialTicks]); const maxIntermediate = Math.max(0, 5 - allMandatory.size); const niceSteps = [5, 10, 25, 50, 100, 250, 500]; let step = niceSteps[niceSteps.length - 1]; for (const s of niceSteps) { if (Math.floor(count / s) <= maxIntermediate) { step = s; break; } } const tickPx = i => { const f = i / (count - 1); return (seg.reverse ? (1 - f) : f) * edgeLen; }; // Phase 1: place round-number ticks (checked against specials + each other) const placed = []; specialTicks.forEach(i => placed.push(tickPx(i))); for (let i = 1; i < count - 1; i++) { if (specialTicks.has(i)) continue; const idx = totalLeds > 0 ? (seg.led_start + i) % totalLeds : seg.led_start + i; if (idx % step === 0) { const px = tickPx(i); if (!placed.some(p => Math.abs(px - p) < minSpacing)) { labelsToShow.add(i); placed.push(px); } } } // Phase 2: edge boundaries — show label unless overlapping a round-number tick edgeBounds.forEach(bi => { if (labelsToShow.has(bi) || specialTicks.has(bi)) return; const px = tickPx(bi); if (placed.some(p => Math.abs(px - p) < minSpacing)) { tickLinesOnly.add(bi); } else { labelsToShow.add(bi); placed.push(px); } }); } else { edgeBounds.forEach(i => labelsToShow.add(i)); } // Tick styling const tickLenLong = toggleSize + 3; const tickLenShort = 4; ctx.strokeStyle = tickStroke; ctx.lineWidth = 1; ctx.fillStyle = tickFill; ctx.font = '12px -apple-system, BlinkMacSystemFont, sans-serif'; // Draw labeled ticks labelsToShow.forEach(i => { const fraction = count > 1 ? i / (count - 1) : 0.5; const displayFraction = seg.reverse ? (1 - fraction) : fraction; const ledIndex = totalLeds > 0 ? (seg.led_start + i) % totalLeds : seg.led_start + i; const tickLen = edgeBounds.has(i) ? tickLenLong : tickLenShort; if (geo.horizontal) { const tx = geo.x1 + displayFraction * (geo.x2 - geo.x1); const axisY = axisPos[seg.edge]; const tickDir = seg.edge === 'top' ? 1 : -1; ctx.beginPath(); ctx.moveTo(tx, axisY); ctx.lineTo(tx, axisY + tickDir * tickLen); ctx.stroke(); ctx.textAlign = 'center'; ctx.textBaseline = seg.edge === 'top' ? 'bottom' : 'top'; ctx.fillText(String(ledIndex), tx, axisY - tickDir * 1); } else { const ty = geo.y1 + displayFraction * (geo.y2 - geo.y1); const axisX = axisPos[seg.edge]; const tickDir = seg.edge === 'left' ? 1 : -1; ctx.beginPath(); ctx.moveTo(axisX, ty); ctx.lineTo(axisX + tickDir * tickLen, ty); ctx.stroke(); ctx.textBaseline = 'middle'; ctx.textAlign = seg.edge === 'left' ? 'right' : 'left'; ctx.fillText(String(ledIndex), axisX - tickDir * 1, ty); } }); // Draw tick lines only (no labels) for suppressed edge boundaries tickLinesOnly.forEach(i => { const fraction = count > 1 ? i / (count - 1) : 0.5; const displayFraction = seg.reverse ? (1 - fraction) : fraction; if (geo.horizontal) { const tx = geo.x1 + displayFraction * (geo.x2 - geo.x1); const axisY = axisPos[seg.edge]; const tickDir = seg.edge === 'top' ? 1 : -1; ctx.beginPath(); ctx.moveTo(tx, axisY); ctx.lineTo(tx, axisY + tickDir * tickLenLong); ctx.stroke(); } else { const ty = geo.y1 + displayFraction * (geo.y2 - geo.y1); const axisX = axisPos[seg.edge]; const tickDir = seg.edge === 'left' ? 1 : -1; ctx.beginPath(); ctx.moveTo(axisX, ty); ctx.lineTo(axisX + tickDir * tickLenLong, ty); ctx.stroke(); } }); // Draw direction chevron at full-edge midpoint (not affected by span) const s = 7; let mx, my, angle; if (geo.horizontal) { mx = ox + cw + edgeLenH / 2; my = arrowPos[seg.edge]; angle = seg.reverse ? Math.PI : 0; } else { mx = arrowPos[seg.edge]; my = oy + ch + edgeLenV / 2; angle = seg.reverse ? -Math.PI / 2 : Math.PI / 2; } ctx.save(); ctx.translate(mx, my); ctx.rotate(angle); ctx.fillStyle = 'rgba(76, 175, 80, 0.85)'; ctx.strokeStyle = chevronStroke; ctx.lineWidth = 1; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.beginPath(); ctx.moveTo(-s * 0.5, -s * 0.6); ctx.lineTo(s * 0.5, 0); ctx.lineTo(-s * 0.5, s * 0.6); ctx.closePath(); ctx.fill(); ctx.stroke(); ctx.restore(); }); } function updateSpanBars() { const spans = window.edgeSpans || {}; const container = document.querySelector('.calibration-preview'); ['top', 'right', 'bottom', 'left'].forEach(edge => { const bar = document.querySelector(`.edge-span-bar[data-edge="${edge}"]`); if (!bar) return; const span = spans[edge] || { start: 0, end: 1 }; const edgeEl = bar.parentElement; const isHorizontal = (edge === 'top' || edge === 'bottom'); if (isHorizontal) { const totalWidth = edgeEl.clientWidth; bar.style.left = (span.start * totalWidth) + 'px'; bar.style.width = ((span.end - span.start) * totalWidth) + 'px'; } else { const totalHeight = edgeEl.clientHeight; bar.style.top = (span.start * totalHeight) + 'px'; bar.style.height = ((span.end - span.start) * totalHeight) + 'px'; } // Also reposition toggle zone to match span region if (!container) return; const toggle = container.querySelector(`.toggle-${edge}`); if (!toggle) return; if (isHorizontal) { const cornerW = 56; const edgeW = container.clientWidth - 2 * cornerW; toggle.style.left = (cornerW + span.start * edgeW) + 'px'; toggle.style.right = 'auto'; toggle.style.width = ((span.end - span.start) * edgeW) + 'px'; } else { const cornerH = 36; const edgeH = container.clientHeight - 2 * cornerH; toggle.style.top = (cornerH + span.start * edgeH) + 'px'; toggle.style.bottom = 'auto'; toggle.style.height = ((span.end - span.start) * edgeH) + 'px'; } }); } function initSpanDrag() { const MIN_SPAN = 0.05; document.querySelectorAll('.edge-span-bar').forEach(bar => { const edge = bar.dataset.edge; const isHorizontal = (edge === 'top' || edge === 'bottom'); // Prevent edge click-through when interacting with span bar bar.addEventListener('click', e => e.stopPropagation()); // Handle resize via handles bar.querySelectorAll('.edge-span-handle').forEach(handle => { handle.addEventListener('mousedown', e => { e.preventDefault(); e.stopPropagation(); const handleType = handle.dataset.handle; const edgeEl = bar.parentElement; const rect = edgeEl.getBoundingClientRect(); function onMouseMove(ev) { const span = window.edgeSpans[edge]; let fraction; if (isHorizontal) { fraction = (ev.clientX - rect.left) / rect.width; } else { fraction = (ev.clientY - rect.top) / rect.height; } fraction = Math.max(0, Math.min(1, fraction)); if (handleType === 'start') { span.start = Math.min(fraction, span.end - MIN_SPAN); } else { span.end = Math.max(fraction, span.start + MIN_SPAN); } updateSpanBars(); renderCalibrationCanvas(); } function onMouseUp() { document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); } document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); }); }); // Handle body drag (move entire span) bar.addEventListener('mousedown', e => { if (e.target.classList.contains('edge-span-handle')) return; e.preventDefault(); e.stopPropagation(); const edgeEl = bar.parentElement; const rect = edgeEl.getBoundingClientRect(); const span = window.edgeSpans[edge]; const spanWidth = span.end - span.start; let startFraction; if (isHorizontal) { startFraction = (e.clientX - rect.left) / rect.width; } else { startFraction = (e.clientY - rect.top) / rect.height; } const offsetInSpan = startFraction - span.start; function onMouseMove(ev) { let fraction; if (isHorizontal) { fraction = (ev.clientX - rect.left) / rect.width; } else { fraction = (ev.clientY - rect.top) / rect.height; } let newStart = fraction - offsetInSpan; newStart = Math.max(0, Math.min(1 - spanWidth, newStart)); span.start = newStart; span.end = newStart + spanWidth; updateSpanBars(); renderCalibrationCanvas(); } function onMouseUp() { document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); } document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); }); }); // Initial positioning updateSpanBars(); } function setStartPosition(position) { document.getElementById('cal-start-position').value = position; updateCalibrationPreview(); } function toggleEdgeInputs() { const preview = document.querySelector('.calibration-preview'); if (preview) preview.classList.toggle('inputs-dimmed'); } function toggleDirection() { const select = document.getElementById('cal-layout'); select.value = select.value === 'clockwise' ? 'counterclockwise' : 'clockwise'; updateCalibrationPreview(); } async function toggleTestEdge(edge) { const deviceId = document.getElementById('calibration-device-id').value; const error = document.getElementById('calibration-error'); if (!calibrationTestState[deviceId]) { calibrationTestState[deviceId] = new Set(); } // Toggle edge if (calibrationTestState[deviceId].has(edge)) { calibrationTestState[deviceId].delete(edge); } else { calibrationTestState[deviceId].add(edge); } // Build edges dict for API const edges = {}; calibrationTestState[deviceId].forEach(e => { edges[e] = EDGE_TEST_COLORS[e]; }); // Update visual state immediately updateCalibrationPreview(); try { const response = await fetch(`${API_BASE}/devices/${deviceId}/calibration/test`, { method: 'PUT', headers: getHeaders(), body: JSON.stringify({ edges }) }); if (response.status === 401) { handle401Error(); return; } if (!response.ok) { const errorData = await response.json(); error.textContent = `Test failed: ${errorData.detail}`; error.style.display = 'block'; } } catch (err) { console.error('Failed to toggle test edge:', err); error.textContent = 'Failed to toggle test edge'; error.style.display = 'block'; } } async function clearTestMode(deviceId) { if (!calibrationTestState[deviceId] || calibrationTestState[deviceId].size === 0) { return; } calibrationTestState[deviceId] = new Set(); try { await fetch(`${API_BASE}/devices/${deviceId}/calibration/test`, { method: 'PUT', headers: getHeaders(), body: JSON.stringify({ edges: {} }) }); } catch (err) { console.error('Failed to clear test mode:', err); } } async function saveCalibration() { const deviceId = document.getElementById('calibration-device-id').value; const deviceLedCount = parseInt(document.getElementById('cal-device-led-count-inline').textContent); const error = document.getElementById('calibration-error'); // Clear test mode before saving await clearTestMode(deviceId); updateCalibrationPreview(); const topLeds = parseInt(document.getElementById('cal-top-leds').value || 0); const rightLeds = parseInt(document.getElementById('cal-right-leds').value || 0); const bottomLeds = parseInt(document.getElementById('cal-bottom-leds').value || 0); const leftLeds = parseInt(document.getElementById('cal-left-leds').value || 0); const total = topLeds + rightLeds + bottomLeds + leftLeds; // Validation if (total !== deviceLedCount) { error.textContent = `Total LEDs (${total}) must equal device LED count (${deviceLedCount})`; error.style.display = 'block'; return; } // Build calibration config const startPosition = document.getElementById('cal-start-position').value; const layout = document.getElementById('cal-layout').value; const offset = parseInt(document.getElementById('cal-offset').value || 0); const spans = window.edgeSpans || {}; const calibration = { layout: layout, start_position: startPosition, offset: offset, leds_top: topLeds, leds_right: rightLeds, leds_bottom: bottomLeds, leds_left: leftLeds, span_top_start: spans.top?.start ?? 0, span_top_end: spans.top?.end ?? 1, span_right_start: spans.right?.start ?? 0, span_right_end: spans.right?.end ?? 1, span_bottom_start: spans.bottom?.start ?? 0, span_bottom_end: spans.bottom?.end ?? 1, span_left_start: spans.left?.start ?? 0, span_left_end: spans.left?.end ?? 1, }; try { const response = await fetch(`${API_BASE}/devices/${deviceId}/calibration`, { method: 'PUT', headers: getHeaders(), body: JSON.stringify(calibration) }); if (response.status === 401) { handle401Error(); return; } if (response.ok) { showToast('Calibration saved', 'success'); forceCloseCalibrationModal(); loadDevices(); } else { const errorData = await response.json(); error.textContent = `Failed to save: ${errorData.detail}`; error.style.display = 'block'; } } catch (err) { console.error('Failed to save calibration:', err); error.textContent = 'Failed to save calibration'; error.style.display = 'block'; } } function getEdgeOrder(startPosition, layout) { const orders = { 'bottom_left_clockwise': ['left', 'top', 'right', 'bottom'], 'bottom_left_counterclockwise': ['bottom', 'right', 'top', 'left'], 'bottom_right_clockwise': ['bottom', 'left', 'top', 'right'], 'bottom_right_counterclockwise': ['right', 'top', 'left', 'bottom'], 'top_left_clockwise': ['top', 'right', 'bottom', 'left'], 'top_left_counterclockwise': ['left', 'bottom', 'right', 'top'], 'top_right_clockwise': ['right', 'bottom', 'left', 'top'], 'top_right_counterclockwise': ['top', 'left', 'bottom', 'right'] }; return orders[`${startPosition}_${layout}`] || ['left', 'top', 'right', 'bottom']; } function shouldReverse(edge, startPosition, layout) { // Determine if this edge should be reversed based on LED strip direction const reverseRules = { 'bottom_left_clockwise': { left: true, top: false, right: false, bottom: true }, 'bottom_left_counterclockwise': { bottom: false, right: true, top: true, left: false }, 'bottom_right_clockwise': { bottom: true, left: true, top: false, right: false }, 'bottom_right_counterclockwise': { right: true, top: true, left: false, bottom: false }, 'top_left_clockwise': { top: false, right: false, bottom: true, left: true }, 'top_left_counterclockwise': { left: false, bottom: false, right: true, top: true }, 'top_right_clockwise': { right: false, bottom: true, left: true, top: false }, 'top_right_counterclockwise': { top: true, left: false, bottom: false, right: true } }; const rules = reverseRules[`${startPosition}_${layout}`]; return rules ? rules[edge] : false; } function buildSegments(calibration) { const edgeOrder = getEdgeOrder(calibration.start_position, calibration.layout); const edgeCounts = { top: calibration.leds_top || 0, right: calibration.leds_right || 0, bottom: calibration.leds_bottom || 0, left: calibration.leds_left || 0 }; const segments = []; let ledStart = calibration.offset || 0; edgeOrder.forEach(edge => { const count = edgeCounts[edge]; if (count > 0) { segments.push({ edge: edge, led_start: ledStart, led_count: count, reverse: shouldReverse(edge, calibration.start_position, calibration.layout) }); ledStart += count; } }); return segments; } // Close modals on backdrop click (only if mousedown also started on backdrop) let backdropMouseDownTarget = null; document.addEventListener('mousedown', (e) => { backdropMouseDownTarget = e.target; }); document.addEventListener('click', (e) => { if (!e.target.classList.contains('modal')) return; if (backdropMouseDownTarget !== e.target) return; const modalId = e.target.id; // Confirm modal: backdrop click acts as Cancel if (modalId === 'confirm-modal') { closeConfirmModal(false); return; } // Login modal: close only if cancel button is visible (not required login) if (modalId === 'api-key-modal') { const cancelBtn = document.getElementById('modal-cancel-btn'); if (cancelBtn && cancelBtn.style.display !== 'none') { closeApiKeyModal(); } return; } // Settings modal: dirty check if (modalId === 'device-settings-modal') { closeDeviceSettingsModal(); return; } // Calibration modal: dirty check if (modalId === 'calibration-modal') { closeCalibrationModal(); return; } // Add device modal: close on backdrop if (modalId === 'add-device-modal') { closeAddDeviceModal(); return; } }); // Cleanup on page unload window.addEventListener('beforeunload', () => { if (refreshInterval) { clearInterval(refreshInterval); } });