From d223865c6801ce0e01ad0b93e798c68505802ce7 Mon Sep 17 00:00:00 2001 From: Ayzen Date: Tue, 30 Sep 2025 13:59:49 +0300 Subject: [PATCH] first refactoring step --- vna_system/web_ui/static/js/main.js | 32 +- .../web_ui/static/js/modules/acquisition.js | 53 +-- vna_system/web_ui/static/js/modules/charts.js | 173 +-------- .../web_ui/static/js/modules/settings.js | 116 +----- vna_system/web_ui/static/js/modules/ui.js | 156 +------- vna_system/web_ui/static/js/modules/utils.js | 344 ++++++++++++++++++ 6 files changed, 401 insertions(+), 473 deletions(-) create mode 100644 vna_system/web_ui/static/js/modules/utils.js diff --git a/vna_system/web_ui/static/js/main.js b/vna_system/web_ui/static/js/main.js index 0e88ebc..ff35a4e 100644 --- a/vna_system/web_ui/static/js/main.js +++ b/vna_system/web_ui/static/js/main.js @@ -36,17 +36,17 @@ class VNADashboard { } }; - // Core managers (order matters now) + // Core managers (initialization order matters) this.storage = new StorageManager(); this.notifications = new NotificationManager(); - // Charts first (used by UI, but UI init не зависит от готовности charts) + // Charts first (used by UI, independent of UI initialization) this.charts = new ChartManager(this.config.charts, this.notifications); - // WebSocket before UI (UI подписывается на события ws) + // WebSocket before UI (UI subscribes to WebSocket events) this.websocket = new WebSocketManager(this.config.websocket, this.notifications); - // UI получает зависимости извне + // UI receives dependencies from outside this.ui = new UIManager(this.notifications, this.websocket, this.charts); this.acquisition = new AcquisitionManager(this.notifications); @@ -320,19 +320,11 @@ window.addEventListener('unhandledrejection', (event) => { }); // Development helpers -if (typeof process !== 'undefined' && process?.env?.NODE_ENV === 'development') { - window.debug = { - dashboard: () => window.vnaDashboard, - websocket: () => window.vnaDashboard?.websocket, - charts: () => window.vnaDashboard?.charts, - ui: () => window.vnaDashboard?.ui - }; -} else { - window.debug = { - dashboard: () => window.vnaDashboard, - websocket: () => window.vnaDashboard?.websocket, - charts: () => window.vnaDashboard?.charts, - ui: () => window.vnaDashboard?.ui, - settings: () => window.vnaDashboard?.settings - }; -} +window.debug = { + dashboard: () => window.vnaDashboard, + websocket: () => window.vnaDashboard?.websocket, + charts: () => window.vnaDashboard?.charts, + ui: () => window.vnaDashboard?.ui, + settings: () => window.vnaDashboard?.settings, + acquisition: () => window.vnaDashboard?.acquisition +}; diff --git a/vna_system/web_ui/static/js/modules/acquisition.js b/vna_system/web_ui/static/js/modules/acquisition.js index bba74cf..a456358 100644 --- a/vna_system/web_ui/static/js/modules/acquisition.js +++ b/vna_system/web_ui/static/js/modules/acquisition.js @@ -3,6 +3,8 @@ * Handles VNA data acquisition control via REST API */ +import { setButtonLoading } from './utils.js'; + export class AcquisitionManager { constructor(notifications) { this.notifications = notifications; @@ -51,7 +53,7 @@ export class AcquisitionManager { async handleStartClick() { try { - this.setButtonLoading(this.elements.startBtn, true); + const originalState = setButtonLoading(this.elements.startBtn, true); const response = await fetch('/api/v1/acquisition/start', { method: 'POST', @@ -70,13 +72,13 @@ export class AcquisitionManager { console.error('Error starting acquisition:', error); this.notifications.show({type: 'error', message: 'Failed to start acquisition'}); } finally { - this.setButtonLoading(this.elements.startBtn, false); + setButtonLoading(this.elements.startBtn, false, originalState); } } async handleStopClick() { try { - this.setButtonLoading(this.elements.stopBtn, true); + const originalState = setButtonLoading(this.elements.stopBtn, true); const response = await fetch('/api/v1/acquisition/stop', { method: 'POST', @@ -95,13 +97,13 @@ export class AcquisitionManager { console.error('Error stopping acquisition:', error); this.notifications.show({type: 'error', message: 'Failed to stop acquisition'}); } finally { - this.setButtonLoading(this.elements.stopBtn, false); + setButtonLoading(this.elements.stopBtn, false, originalState); } } async handleSingleSweepClick() { try { - this.setButtonLoading(this.elements.singleSweepBtn, true); + const originalState = setButtonLoading(this.elements.singleSweepBtn, true); const response = await fetch('/api/v1/acquisition/single-sweep', { method: 'POST', @@ -120,7 +122,7 @@ export class AcquisitionManager { console.error('Error triggering single sweep:', error); this.notifications.show({type: 'error', message: 'Failed to trigger single sweep'}); } finally { - this.setButtonLoading(this.elements.singleSweepBtn, false); + setButtonLoading(this.elements.singleSweepBtn, false, originalState); } } @@ -190,45 +192,6 @@ export class AcquisitionManager { } } - setButtonLoading(button, loading) { - if (!button) return; - - if (loading) { - button.disabled = true; - button.classList.add('loading'); - const icon = button.querySelector('i'); - if (icon) { - icon.setAttribute('data-lucide', 'loader-2'); - icon.style.animation = 'spin 1s linear infinite'; - // Re-initialize lucide for the changed icon - if (window.lucide) { - window.lucide.createIcons(); - } - } - } else { - button.disabled = false; - button.classList.remove('loading'); - const icon = button.querySelector('i'); - if (icon) { - icon.style.animation = ''; - // Restore original icon - const buttonId = button.id; - const originalIcons = { - 'startBtn': 'play', - 'stopBtn': 'square', - 'singleSweepBtn': 'zap' - }; - const originalIcon = originalIcons[buttonId]; - if (originalIcon) { - icon.setAttribute('data-lucide', originalIcon); - if (window.lucide) { - window.lucide.createIcons(); - } - } - } - } - } - // Public method to trigger single sweep programmatically async triggerSingleSweep() { return await this.handleSingleSweepClick(); diff --git a/vna_system/web_ui/static/js/modules/charts.js b/vna_system/web_ui/static/js/modules/charts.js index 319a7c3..646f345 100644 --- a/vna_system/web_ui/static/js/modules/charts.js +++ b/vna_system/web_ui/static/js/modules/charts.js @@ -3,6 +3,8 @@ * Handles Plotly.js chart creation, updates, and management */ +import { formatProcessorName, createParameterControl, safeClone, downloadJSON } from './utils.js'; + export class ChartManager { constructor(config, notifications) { this.config = config; @@ -116,7 +118,7 @@ export class ChartManager { const plotContainer = card.querySelector('.chart-card__plot'); const layout = { ...this.plotlyLayout, - title: { text: this.formatProcessorName(processorId), font: { size: 16, color: '#f1f5f9' } }, + title: { text: formatProcessorName(processorId), font: { size: 16, color: '#f1f5f9' } }, width: plotContainer.clientWidth || 500, height: plotContainer.clientHeight || 420 }; @@ -150,7 +152,7 @@ export class ChartManager { const updateLayout = { ...this.plotlyLayout, ...(plotlyConfig.layout || {}), - title: { text: this.formatProcessorName(processorId), font: { size: 16, color: '#f1f5f9' } } + title: { text: formatProcessorName(processorId), font: { size: 16, color: '#f1f5f9' } } }; delete updateLayout.width; delete updateLayout.height; @@ -200,7 +202,7 @@ export class ChartManager {
- ${this.formatProcessorName(processorId)} + ${formatProcessorName(processorId)}
-
- `; - default: - return ` -
- - -
- `; - } - } - /** * Setup event listeners for settings */ @@ -624,13 +534,13 @@ export class ChartManager { // Prepare and download processor data const processorData = this.prepareProcessorDownloadData(id); if (processorData) { - this.downloadJSON(processorData, `${baseFilename}_data.json`); + downloadJSON(processorData, `${baseFilename}_data.json`); } this.notifications?.show?.({ type: 'success', title: 'Download Complete', - message: `Downloaded ${this.formatProcessorName(id)} plot and data` + message: `Downloaded ${formatProcessorName(id)} plot and data` }); } catch (e) { console.error('❌ Chart download failed:', e); @@ -648,34 +558,10 @@ export class ChartManager { if (!chart || !latestData) return null; - // Safe copy function to avoid circular references - const safeClone = (obj, seen = new WeakSet()) => { - if (obj === null || typeof obj !== 'object') return obj; - if (seen.has(obj)) return '[Circular Reference]'; - - seen.add(obj); - - if (Array.isArray(obj)) { - return obj.map(item => safeClone(item, seen)); - } - - const cloned = {}; - for (const key in obj) { - if (obj.hasOwnProperty(key)) { - try { - cloned[key] = safeClone(obj[key], seen); - } catch (e) { - cloned[key] = `[Error: ${e.message}]`; - } - } - } - return cloned; - }; - return { processor_info: { processor_id: processorId, - processor_name: this.formatProcessorName(processorId), + processor_name: formatProcessorName(processorId), download_timestamp: new Date().toISOString(), is_visible: chart.isVisible }, @@ -689,7 +575,7 @@ export class ChartManager { ui_parameters: safeClone(this.getProcessorSettings(processorId)), raw_sweep_data: this.extractProcessorRawData(latestData), metadata: { - description: `VNA processor data export - ${this.formatProcessorName(processorId)}`, + description: `VNA processor data export - ${formatProcessorName(processorId)}`, format_version: "1.0", exported_by: "VNA System Dashboard", export_type: "processor_data", @@ -741,28 +627,9 @@ export class ChartManager { safeStringify(obj) { try { - // Simple objects - try direct JSON return JSON.parse(JSON.stringify(obj)); } catch (e) { - // If that fails, create a safe representation - if (obj === null || typeof obj !== 'object') return obj; - if (Array.isArray(obj)) return obj.slice(0, 100); // Limit array size - - const safe = {}; - Object.keys(obj).forEach(key => { - try { - if (typeof obj[key] === 'function') { - safe[key] = '[Function]'; - } else if (typeof obj[key] === 'object') { - safe[key] = '[Object]'; - } else { - safe[key] = obj[key]; - } - } catch (err) { - safe[key] = '[Error accessing property]'; - } - }); - return safe; + return safeClone(obj); } } @@ -778,22 +645,6 @@ export class ChartManager { return 'unknown'; } - downloadJSON(data, filename) { - const jsonString = JSON.stringify(data, null, 2); - const blob = new Blob([jsonString], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - - const link = document.createElement('a'); - link.href = url; - link.download = filename; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - // Clean up the URL object - URL.revokeObjectURL(url); - } - toggleFullscreen(id) { const c = this.charts.get(id); if (!c?.element) return; diff --git a/vna_system/web_ui/static/js/modules/settings.js b/vna_system/web_ui/static/js/modules/settings.js index b6ad17e..97f21bb 100644 --- a/vna_system/web_ui/static/js/modules/settings.js +++ b/vna_system/web_ui/static/js/modules/settings.js @@ -1,86 +1,22 @@ /** * Settings Manager Module - * - Управление пресетами VNA - * - Управление калибровками (рабочая/текущая) - * - Построение графиков эталонов (Plotly) - * - Защита от многократных запросов: debounce + runExclusive (мьютексы) - * - Корректная подписка/отписка на WebSocket событие одним и тем же обработчиком + * - Manage VNA presets + * - Manage calibrations (working/current) + * - Build calibration standard plots (Plotly) + * - Protect from multiple requests: debounce + runExclusive (mutexes) + * - Correct subscription/unsubscription to WebSocket events with the same handler */ -/* --------------------------------------------------------- - * Utilities - * --------------------------------------------------------- */ - -class Debouncer { - constructor() { this.timers = new Map(); } - debounce(key, fn, delay = 300) { - if (this.timers.has(key)) clearTimeout(this.timers.get(key)); - const t = setTimeout(() => { this.timers.delete(key); fn(); }, delay); - this.timers.set(key, t); - } - cancel(key) { - if (!this.timers.has(key)) return; - clearTimeout(this.timers.get(key)); - this.timers.delete(key); - } - cancelAll() { - this.timers.forEach(clearTimeout); - this.timers.clear(); - } -} - -/** - * Простой «мьютекс»: не пускает повторное выполнение кода с тем же ключом, - * пока предыдущее не завершилось. - */ -class RequestGuard { - constructor() { this.locks = new Set(); } - isLocked(key) { return this.locks.has(key); } - async runExclusive(key, fn) { - if (this.isLocked(key)) return; - this.locks.add(key); - try { return await fn(); } - finally { this.locks.delete(key); } - } -} - -/** Красивые состояния на кнопках */ -class ButtonState { - static set(el, { state = 'normal', icon = '', text = '', disabled = false }) { - if (!el) return; - switch (state) { - case 'loading': - el.disabled = true; - el.innerHTML = ` ${text || 'Loading...'}`; - break; - case 'normal': - el.disabled = !!disabled; - el.innerHTML = icon ? ` ${text}` : text; - break; - case 'disabled': - el.disabled = true; - el.innerHTML = icon ? ` ${text}` : text; - break; - default: - el.disabled = !!disabled; - el.innerHTML = icon ? ` ${text}` : text; - } - if (typeof lucide !== 'undefined') lucide.createIcons(); - } -} - -/* --------------------------------------------------------- - * Settings Manager - * --------------------------------------------------------- */ +import { Debouncer, RequestGuard, ButtonState, downloadJSON } from './utils.js'; export class SettingsManager { /** - * @param {object} notifications — объект с .show({type,title,message}) - * @param {object} websocket — объект с .on(event, handler) / .off(event, handler) - * @param {object} acquisition — объект с .isRunning() / .triggerSingleSweep() + * @param {object} notifications - Object with .show({type,title,message}) + * @param {object} websocket - Object with .on(event, handler) / .off(event, handler) + * @param {object} acquisition - Object with .isRunning() / .triggerSingleSweep() */ constructor(notifications, websocket, acquisition) { - // DI + // Dependencies this.notifications = notifications; this.websocket = websocket; this.acquisition = acquisition; @@ -93,7 +29,7 @@ export class SettingsManager { this.availableReferences = []; this.currentReference = null; - // Калибровка: состояние захвата + // Calibration: capture state this.disabledStandards = new Set(); // DOM cache @@ -103,8 +39,6 @@ export class SettingsManager { this.debouncer = new Debouncer(); this.reqGuard = new RequestGuard(); - // Единственный bound-обработчик, чтобы корректно отписываться - // Bind UI handlers this.handlePresetChange = this.handlePresetChange.bind(this); this.handleSetPreset = this.handleSetPreset.bind(this); @@ -123,7 +57,7 @@ export class SettingsManager { this.handleClearReference = this.handleClearReference.bind(this); this.handleDeleteReference = this.handleDeleteReference.bind(this); - // Пакет данных для модалки с графиками + // Data package for plots modal this.currentPlotsData = null; } @@ -145,7 +79,7 @@ export class SettingsManager { } destroy() { - // Чистим состояние и подписки + // Clean state and subscriptions this._resetCalibrationCaptureState(); this._detachEvents(); this.isInitialized = false; @@ -595,7 +529,7 @@ export class SettingsManager { this._notify('info', 'Capturing Standard', `Capturing ${standard.toUpperCase()} standard...`); - // Прямой вызов API - бэкенд сам обработает ожидание и триггеринг + // Direct API call - backend will handle waiting and triggering const r = await fetch('/api/v1/settings/calibration/add-standard', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -607,10 +541,10 @@ export class SettingsManager { this._notify('success', 'Standard Captured', result.message); - // Сброс состояния + // Reset state this._resetCalibrationCaptureState(); - // Обновить рабочую калибровку + // Reload working calibration await this._loadWorkingCalibration(); } catch (e) { console.error('Capture standard failed:', e); @@ -640,7 +574,7 @@ export class SettingsManager { this._notify('success', 'Calibration Saved', result.message); - // Очистить рабочую калибровку в UI + // Clear working calibration in UI this._hideCalibrationSteps(); this.elements.calibrationNameInput.value = ''; @@ -1025,7 +959,7 @@ export class SettingsManager { } const data = this._prepareCalibrationDownloadData(standardName); - this._downloadJSON(data, `${base}_data.json`); + downloadJSON(data, `${base}_data.json`); this._notify('success', 'Download Complete', `Downloaded ${standardName.toUpperCase()} standard plot and data`); } catch (e) { @@ -1046,7 +980,7 @@ export class SettingsManager { if (btn) ButtonState.set(btn, { state: 'loading', icon: 'loader', text: 'Downloading...' }); const complete = this._prepareCompleteCalibrationData(); - this._downloadJSON(complete, `${base}.json`); + downloadJSON(complete, `${base}.json`); await this._downloadAllPlotImages(base); @@ -1197,18 +1131,6 @@ export class SettingsManager { await Promise.all(jobs); } - _downloadJSON(data, filename) { - const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = Object.assign(document.createElement('a'), { href: url, download: filename }); - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); - } - - /* ----------------------------- Helpers ----------------------------- */ - /* ----------------------------- Reference Management ----------------------------- */ async _loadReferences() { diff --git a/vna_system/web_ui/static/js/modules/ui.js b/vna_system/web_ui/static/js/modules/ui.js index 2055fc7..018e03e 100644 --- a/vna_system/web_ui/static/js/modules/ui.js +++ b/vna_system/web_ui/static/js/modules/ui.js @@ -3,6 +3,8 @@ * Handles user interface interactions and state management */ +import { formatProcessorName, debounce } from './utils.js'; + export class UIManager { constructor(notifications, websocket, charts) { this.notifications = notifications; @@ -22,7 +24,7 @@ export class UIManager { // State this.currentView = 'dashboard'; this.connectionStatus = 'disconnected'; - // processorId -> { enabled, count, uiParameters, config } + // processorId -> { enabled, uiParameters, config } this.processors = new Map(); // Event handlers @@ -30,7 +32,6 @@ export class UIManager { viewChange: [], processorToggle: [], }; - } /** @@ -58,7 +59,6 @@ export class UIManager { this.websocket.on('processor_result', (payload) => this.onProcessorResult(payload)); } - console.log('✅ UI Manager initialized'); } @@ -103,7 +103,7 @@ export class UIManager { // Window resize - window.addEventListener('resize', this.debounce(() => this.handleResize(), 300)); + window.addEventListener('resize', debounce(() => this.handleResize(), 300)); } /** @@ -184,11 +184,8 @@ export class UIManager { proc.config = metadata?.config || {}; this.processors.set(processor_id, proc); - // Refresh toggles and settings + // Refresh toggles this.refreshProcessorToggles(); - this.updateProcessorSettings(); - - // No more statistics tracking needed // Pass to charts if (this.charts) { @@ -216,7 +213,7 @@ export class UIManager { toggle.dataset.processor = name; toggle.innerHTML = `
-
${this.formatProcessorName(name)}
+
${formatProcessorName(name)}
`; container.appendChild(toggle); } @@ -250,128 +247,6 @@ export class UIManager { this.emitEvent('processorToggle', processorId, enabled); } - /** - * Update processor settings panel (deprecated - now handled by ChartManager) - */ - updateProcessorSettings() { - // Settings are now integrated with charts - no separate panel needed - return; - } - - /** - * Create processor parameter element - */ - createProcessorParam(param, processorId) { - const paramId = `${processorId}_${param.name}`; - const value = param.value; - const opts = param.options || {}; - - switch (param.type) { - case 'slider': - case 'range': - return ` -
-
- ${param.label} - ${value} -
- -
- `; - case 'toggle': - case 'boolean': - return ` -
-
${param.label}
- -
- `; - case 'select': - case 'dropdown': { - let choices = []; - if (Array.isArray(opts)) choices = opts; - else if (Array.isArray(opts.choices)) choices = opts.choices; - const optionsHtml = choices.map(option => - `` - ).join(''); - return ` -
-
${param.label}
- -
- `; - } - default: - return ` -
-
${param.label}
- -
- `; - } - } - - /** - * Toggle processor config panel - */ - toggleProcessorConfig(configElement) { - if (!configElement) return; - const isExpanded = configElement.classList.contains('processor-config--expanded'); - configElement.classList.toggle('processor-config--expanded', !isExpanded); - } - - /** - * Handle processor parameter change - * Sends 'recalculate' with config_updates - */ - handleProcessorParamChange(event) { - const paramElement = event.target.closest('.processor-param'); - const configElement = event.target.closest('.processor-config'); - if (!paramElement || !configElement) return; - - const processorId = configElement.dataset.processor; - const paramName = paramElement.dataset.param; - const input = event.target; - - let value; - if (input.type === 'checkbox') { - value = input.checked; - } else if (input.type === 'range') { - value = parseFloat(input.value); - // Update display value - const valueDisplay = paramElement.querySelector('.processor-param__value'); - if (valueDisplay) valueDisplay.textContent = value; - } else { - value = input.value; - } - - console.log(`🔧 Parameter changed: ${processorId}.${paramName} = ${value}`); - - // Send update via WebSocket using existing command - if (this.websocket) { - this.websocket.recalculate(processorId, { [paramName]: value }); - } - } - - /** * Public API */ @@ -387,13 +262,6 @@ export class UIManager { this.toggleProcessor(processorId, enabled); } - formatProcessorName(processorName) { - return processorName - .split('_') - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); - } - handleResize() { console.log('📱 Window resized'); // Charts handle their own resize @@ -417,18 +285,6 @@ export class UIManager { } } - debounce(func, wait) { - let timeout; - return function executedFunction(...args) { - const later = () => { - clearTimeout(timeout); - func(...args); - }; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - }; - } - getStats() { return { currentView: this.currentView, diff --git a/vna_system/web_ui/static/js/modules/utils.js b/vna_system/web_ui/static/js/modules/utils.js new file mode 100644 index 0000000..181eb88 --- /dev/null +++ b/vna_system/web_ui/static/js/modules/utils.js @@ -0,0 +1,344 @@ +/** + * Shared Utility Functions + * Common utilities used across the application + */ + +/** + * Format processor name: convert snake_case to Title Case + * @param {string} name - Processor name in snake_case + * @returns {string} Formatted name in Title Case + */ +export function formatProcessorName(name) { + return name + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + +/** + * Debounce function execution + * @param {Function} func - Function to debounce + * @param {number} wait - Delay in milliseconds + * @returns {Function} Debounced function + */ +export function debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +} + +/** + * Debouncer class for managing multiple debounced operations + */ +export class Debouncer { + constructor() { + this.timers = new Map(); + } + + debounce(key, fn, delay = 300) { + if (this.timers.has(key)) { + clearTimeout(this.timers.get(key)); + } + const timer = setTimeout(() => { + this.timers.delete(key); + fn(); + }, delay); + this.timers.set(key, timer); + } + + cancel(key) { + if (this.timers.has(key)) { + clearTimeout(this.timers.get(key)); + this.timers.delete(key); + } + } + + cancelAll() { + this.timers.forEach(clearTimeout); + this.timers.clear(); + } +} + +/** + * Request Guard: prevents concurrent execution of the same operation + */ +export class RequestGuard { + constructor() { + this.locks = new Set(); + } + + isLocked(key) { + return this.locks.has(key); + } + + async runExclusive(key, fn) { + if (this.isLocked(key)) { + return; + } + this.locks.add(key); + try { + return await fn(); + } finally { + this.locks.delete(key); + } + } +} + +/** + * Button State Manager: manages button states (loading, disabled, normal) + */ +export class ButtonState { + static set(element, { state = 'normal', icon = '', text = '', disabled = false }) { + if (!element) return; + + switch (state) { + case 'loading': + element.disabled = true; + element.innerHTML = ` ${text || 'Loading...'}`; + break; + case 'disabled': + element.disabled = true; + element.innerHTML = icon ? ` ${text}` : text; + break; + case 'normal': + default: + element.disabled = !!disabled; + element.innerHTML = icon ? ` ${text}` : text; + break; + } + + if (typeof lucide !== 'undefined') { + lucide.createIcons(); + } + } +} + +/** + * Set button to loading state with animation + * @param {HTMLElement} button - Button element + * @param {boolean} loading - Whether to set loading state + * @param {Object} originalState - Original button state to restore + */ +export function setButtonLoading(button, loading, originalState = {}) { + if (!button) return; + + if (loading) { + // Save original state if not provided + if (!originalState.text) { + originalState.text = button.textContent; + originalState.icon = button.querySelector('i')?.getAttribute('data-lucide'); + } + + button.disabled = true; + button.classList.add('loading'); + + const icon = button.querySelector('i'); + if (icon) { + icon.setAttribute('data-lucide', 'loader-2'); + icon.style.animation = 'spin 1s linear infinite'; + } + + if (window.lucide) { + window.lucide.createIcons(); + } + } else { + button.disabled = false; + button.classList.remove('loading'); + + const icon = button.querySelector('i'); + if (icon) { + icon.style.animation = ''; + if (originalState.icon) { + icon.setAttribute('data-lucide', originalState.icon); + if (window.lucide) { + window.lucide.createIcons(); + } + } + } + } + + return originalState; +} + +/** + * Create parameter control HTML for processor settings + * @param {Object} param - Parameter configuration + * @param {string} processorId - Processor ID + * @param {string} idPrefix - Prefix for element IDs + * @returns {string} HTML string for parameter control + */ +export function createParameterControl(param, processorId, idPrefix = 'param') { + const paramId = `${idPrefix}_${processorId}_${param.name}`; + const value = param.value; + const opts = param.options || {}; + + switch (param.type) { + case 'slider': + case 'range': + return ` +
+ + +
+ `; + + case 'toggle': + case 'boolean': + return ` +
+ +
+ `; + + case 'select': + case 'dropdown': { + let choices = []; + if (Array.isArray(opts)) choices = opts; + else if (Array.isArray(opts.choices)) choices = opts.choices; + + const optionsHtml = choices.map(option => + `` + ).join(''); + + return ` +
+ + +
+ `; + } + + case 'button': + const buttonText = param.label || 'Click'; + const actionDesc = opts.action ? `title="${opts.action}"` : ''; + return ` +
+ +
+ `; + + default: + return ` +
+ + +
+ `; + } +} + +/** + * Safe clone object avoiding circular references + * @param {*} obj - Object to clone + * @param {WeakSet} seen - Set of already seen objects + * @returns {*} Cloned object + */ +export function safeClone(obj, seen = new WeakSet()) { + if (obj === null || typeof obj !== 'object') return obj; + if (seen.has(obj)) return '[Circular Reference]'; + + seen.add(obj); + + if (Array.isArray(obj)) { + return obj.map(item => safeClone(item, seen)); + } + + const cloned = {}; + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + try { + cloned[key] = safeClone(obj[key], seen); + } catch (e) { + cloned[key] = `[Error: ${e.message}]`; + } + } + } + return cloned; +} + +/** + * Download JSON data as a file + * @param {Object} data - Data to download + * @param {string} filename - Filename for download + */ +export function downloadJSON(data, filename) { + const jsonString = JSON.stringify(data, null, 2); + const blob = new Blob([jsonString], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + URL.revokeObjectURL(url); +} + +/** + * Escape HTML to prevent XSS attacks + * @param {string} unsafe - Unsafe string + * @returns {string} Escaped string + */ +export function escapeHtml(unsafe) { + if (typeof unsafe !== 'string') return ''; + return unsafe + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * Format bytes to human readable format + * @param {number} bytes - Number of bytes + * @returns {string} Formatted string + */ +export function formatBytes(bytes) { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} \ No newline at end of file