first refactoring step

This commit is contained in:
Ayzen
2025-09-30 13:59:49 +03:00
parent b31568900c
commit d223865c68
6 changed files with 401 additions and 473 deletions

View File

@ -36,17 +36,17 @@ class VNADashboard {
} }
}; };
// Core managers (order matters now) // Core managers (initialization order matters)
this.storage = new StorageManager(); this.storage = new StorageManager();
this.notifications = new NotificationManager(); 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); 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); 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.ui = new UIManager(this.notifications, this.websocket, this.charts);
this.acquisition = new AcquisitionManager(this.notifications); this.acquisition = new AcquisitionManager(this.notifications);
@ -320,19 +320,11 @@ window.addEventListener('unhandledrejection', (event) => {
}); });
// Development helpers // Development helpers
if (typeof process !== 'undefined' && process?.env?.NODE_ENV === 'development') { window.debug = {
window.debug = { dashboard: () => window.vnaDashboard,
dashboard: () => window.vnaDashboard, websocket: () => window.vnaDashboard?.websocket,
websocket: () => window.vnaDashboard?.websocket, charts: () => window.vnaDashboard?.charts,
charts: () => window.vnaDashboard?.charts, ui: () => window.vnaDashboard?.ui,
ui: () => window.vnaDashboard?.ui settings: () => window.vnaDashboard?.settings,
}; acquisition: () => window.vnaDashboard?.acquisition
} else { };
window.debug = {
dashboard: () => window.vnaDashboard,
websocket: () => window.vnaDashboard?.websocket,
charts: () => window.vnaDashboard?.charts,
ui: () => window.vnaDashboard?.ui,
settings: () => window.vnaDashboard?.settings
};
}

View File

@ -3,6 +3,8 @@
* Handles VNA data acquisition control via REST API * Handles VNA data acquisition control via REST API
*/ */
import { setButtonLoading } from './utils.js';
export class AcquisitionManager { export class AcquisitionManager {
constructor(notifications) { constructor(notifications) {
this.notifications = notifications; this.notifications = notifications;
@ -51,7 +53,7 @@ export class AcquisitionManager {
async handleStartClick() { async handleStartClick() {
try { try {
this.setButtonLoading(this.elements.startBtn, true); const originalState = setButtonLoading(this.elements.startBtn, true);
const response = await fetch('/api/v1/acquisition/start', { const response = await fetch('/api/v1/acquisition/start', {
method: 'POST', method: 'POST',
@ -70,13 +72,13 @@ export class AcquisitionManager {
console.error('Error starting acquisition:', error); console.error('Error starting acquisition:', error);
this.notifications.show({type: 'error', message: 'Failed to start acquisition'}); this.notifications.show({type: 'error', message: 'Failed to start acquisition'});
} finally { } finally {
this.setButtonLoading(this.elements.startBtn, false); setButtonLoading(this.elements.startBtn, false, originalState);
} }
} }
async handleStopClick() { async handleStopClick() {
try { try {
this.setButtonLoading(this.elements.stopBtn, true); const originalState = setButtonLoading(this.elements.stopBtn, true);
const response = await fetch('/api/v1/acquisition/stop', { const response = await fetch('/api/v1/acquisition/stop', {
method: 'POST', method: 'POST',
@ -95,13 +97,13 @@ export class AcquisitionManager {
console.error('Error stopping acquisition:', error); console.error('Error stopping acquisition:', error);
this.notifications.show({type: 'error', message: 'Failed to stop acquisition'}); this.notifications.show({type: 'error', message: 'Failed to stop acquisition'});
} finally { } finally {
this.setButtonLoading(this.elements.stopBtn, false); setButtonLoading(this.elements.stopBtn, false, originalState);
} }
} }
async handleSingleSweepClick() { async handleSingleSweepClick() {
try { try {
this.setButtonLoading(this.elements.singleSweepBtn, true); const originalState = setButtonLoading(this.elements.singleSweepBtn, true);
const response = await fetch('/api/v1/acquisition/single-sweep', { const response = await fetch('/api/v1/acquisition/single-sweep', {
method: 'POST', method: 'POST',
@ -120,7 +122,7 @@ export class AcquisitionManager {
console.error('Error triggering single sweep:', error); console.error('Error triggering single sweep:', error);
this.notifications.show({type: 'error', message: 'Failed to trigger single sweep'}); this.notifications.show({type: 'error', message: 'Failed to trigger single sweep'});
} finally { } 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 // Public method to trigger single sweep programmatically
async triggerSingleSweep() { async triggerSingleSweep() {
return await this.handleSingleSweepClick(); return await this.handleSingleSweepClick();

View File

@ -3,6 +3,8 @@
* Handles Plotly.js chart creation, updates, and management * Handles Plotly.js chart creation, updates, and management
*/ */
import { formatProcessorName, createParameterControl, safeClone, downloadJSON } from './utils.js';
export class ChartManager { export class ChartManager {
constructor(config, notifications) { constructor(config, notifications) {
this.config = config; this.config = config;
@ -116,7 +118,7 @@ export class ChartManager {
const plotContainer = card.querySelector('.chart-card__plot'); const plotContainer = card.querySelector('.chart-card__plot');
const layout = { const layout = {
...this.plotlyLayout, ...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, width: plotContainer.clientWidth || 500,
height: plotContainer.clientHeight || 420 height: plotContainer.clientHeight || 420
}; };
@ -150,7 +152,7 @@ export class ChartManager {
const updateLayout = { const updateLayout = {
...this.plotlyLayout, ...this.plotlyLayout,
...(plotlyConfig.layout || {}), ...(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.width;
delete updateLayout.height; delete updateLayout.height;
@ -200,7 +202,7 @@ export class ChartManager {
<div class="chart-card__header"> <div class="chart-card__header">
<div class="chart-card__title"> <div class="chart-card__title">
<i data-lucide="bar-chart-3" class="chart-card__icon"></i> <i data-lucide="bar-chart-3" class="chart-card__icon"></i>
${this.formatProcessorName(processorId)} ${formatProcessorName(processorId)}
</div> </div>
<div class="chart-card__actions"> <div class="chart-card__actions">
<button class="chart-card__action" data-action="fullscreen" title="Fullscreen"> <button class="chart-card__action" data-action="fullscreen" title="Fullscreen">
@ -275,10 +277,6 @@ export class ChartManager {
} }
} }
formatProcessorName(n) {
return n.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
}
/** /**
* Update chart settings with current UI parameters * Update chart settings with current UI parameters
*/ */
@ -317,7 +315,7 @@ export class ChartManager {
if (processor?.uiParameters && Array.isArray(processor.uiParameters) && processor.uiParameters.length > 0) { if (processor?.uiParameters && Array.isArray(processor.uiParameters) && processor.uiParameters.length > 0) {
const settingsHtml = processor.uiParameters.map(param => const settingsHtml = processor.uiParameters.map(param =>
this.createSettingControl(param, processorId) createParameterControl(param, processorId, 'chart')
).join(''); ).join('');
settingsContainer.innerHTML = settingsHtml; settingsContainer.innerHTML = settingsHtml;
this.setupSettingsEvents(settingsContainer, processorId); this.setupSettingsEvents(settingsContainer, processorId);
@ -330,7 +328,7 @@ export class ChartManager {
// Generate settings HTML from chart data // Generate settings HTML from chart data
const settingsHtml = uiParameters.map(param => const settingsHtml = uiParameters.map(param =>
this.createSettingControl(param, processorId) createParameterControl(param, processorId, 'chart')
).join(''); ).join('');
settingsContainer.innerHTML = settingsHtml; settingsContainer.innerHTML = settingsHtml;
@ -352,94 +350,6 @@ export class ChartManager {
} }
} }
/**
* Create setting control HTML
*/
createSettingControl(param, processorId) {
const paramId = `chart_${processorId}_${param.name}`;
const value = param.value;
const opts = param.options || {};
switch (param.type) {
case 'slider':
case 'range':
return `
<div class="chart-setting" data-param="${param.name}">
<label class="chart-setting__label">
${param.label}
<span class="chart-setting__value">${value}</span>
</label>
<input
type="range"
class="chart-setting__slider"
id="${paramId}"
min="${opts.min ?? 0}"
max="${opts.max ?? 100}"
step="${opts.step ?? 1}"
value="${value}"
>
</div>
`;
case 'toggle':
case 'boolean':
return `
<div class="chart-setting" data-param="${param.name}">
<label class="chart-setting__toggle">
<input type="checkbox" id="${paramId}" ${value ? 'checked' : ''}>
<span class="chart-setting__toggle-slider"></span>
<span class="chart-setting__label">${param.label}</span>
</label>
</div>
`;
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 =>
`<option value="${option}" ${option === value ? 'selected' : ''}>${option}</option>`
).join('');
return `
<div class="chart-setting" data-param="${param.name}">
<label class="chart-setting__label">${param.label}</label>
<select class="chart-setting__select" id="${paramId}">
${optionsHtml}
</select>
</div>
`;
}
case 'button':
const buttonText = param.label || 'Click';
const actionDesc = opts.action ? `title="${opts.action}"` : '';
return `
<div class="chart-setting" data-param="${param.name}">
<button
type="button"
class="chart-setting__button"
id="${paramId}"
data-processor="${processorId}"
data-param="${param.name}"
${actionDesc}
>
${buttonText}
</button>
</div>
`;
default:
return `
<div class="chart-setting" data-param="${param.name}">
<label class="chart-setting__label">${param.label}</label>
<input
type="text"
class="chart-setting__input"
id="${paramId}"
value="${value ?? ''}"
>
</div>
`;
}
}
/** /**
* Setup event listeners for settings * Setup event listeners for settings
*/ */
@ -624,13 +534,13 @@ export class ChartManager {
// Prepare and download processor data // Prepare and download processor data
const processorData = this.prepareProcessorDownloadData(id); const processorData = this.prepareProcessorDownloadData(id);
if (processorData) { if (processorData) {
this.downloadJSON(processorData, `${baseFilename}_data.json`); downloadJSON(processorData, `${baseFilename}_data.json`);
} }
this.notifications?.show?.({ this.notifications?.show?.({
type: 'success', type: 'success',
title: 'Download Complete', title: 'Download Complete',
message: `Downloaded ${this.formatProcessorName(id)} plot and data` message: `Downloaded ${formatProcessorName(id)} plot and data`
}); });
} catch (e) { } catch (e) {
console.error('❌ Chart download failed:', e); console.error('❌ Chart download failed:', e);
@ -648,34 +558,10 @@ export class ChartManager {
if (!chart || !latestData) return null; 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 { return {
processor_info: { processor_info: {
processor_id: processorId, processor_id: processorId,
processor_name: this.formatProcessorName(processorId), processor_name: formatProcessorName(processorId),
download_timestamp: new Date().toISOString(), download_timestamp: new Date().toISOString(),
is_visible: chart.isVisible is_visible: chart.isVisible
}, },
@ -689,7 +575,7 @@ export class ChartManager {
ui_parameters: safeClone(this.getProcessorSettings(processorId)), ui_parameters: safeClone(this.getProcessorSettings(processorId)),
raw_sweep_data: this.extractProcessorRawData(latestData), raw_sweep_data: this.extractProcessorRawData(latestData),
metadata: { metadata: {
description: `VNA processor data export - ${this.formatProcessorName(processorId)}`, description: `VNA processor data export - ${formatProcessorName(processorId)}`,
format_version: "1.0", format_version: "1.0",
exported_by: "VNA System Dashboard", exported_by: "VNA System Dashboard",
export_type: "processor_data", export_type: "processor_data",
@ -741,28 +627,9 @@ export class ChartManager {
safeStringify(obj) { safeStringify(obj) {
try { try {
// Simple objects - try direct JSON
return JSON.parse(JSON.stringify(obj)); return JSON.parse(JSON.stringify(obj));
} catch (e) { } catch (e) {
// If that fails, create a safe representation return safeClone(obj);
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;
} }
} }
@ -778,22 +645,6 @@ export class ChartManager {
return 'unknown'; 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) { toggleFullscreen(id) {
const c = this.charts.get(id); const c = this.charts.get(id);
if (!c?.element) return; if (!c?.element) return;

View File

@ -1,86 +1,22 @@
/** /**
* Settings Manager Module * Settings Manager Module
* - Управление пресетами VNA * - Manage VNA presets
* - Управление калибровками (рабочая/текущая) * - Manage calibrations (working/current)
* - Построение графиков эталонов (Plotly) * - Build calibration standard plots (Plotly)
* - Защита от многократных запросов: debounce + runExclusive (мьютексы) * - Protect from multiple requests: debounce + runExclusive (mutexes)
* - Корректная подписка/отписка на WebSocket событие одним и тем же обработчиком * - Correct subscription/unsubscription to WebSocket events with the same handler
*/ */
/* --------------------------------------------------------- import { Debouncer, RequestGuard, ButtonState, downloadJSON } from './utils.js';
* 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 = `<i data-lucide="${icon || 'loader'}"></i> ${text || 'Loading...'}`;
break;
case 'normal':
el.disabled = !!disabled;
el.innerHTML = icon ? `<i data-lucide="${icon}"></i> ${text}` : text;
break;
case 'disabled':
el.disabled = true;
el.innerHTML = icon ? `<i data-lucide="${icon}"></i> ${text}` : text;
break;
default:
el.disabled = !!disabled;
el.innerHTML = icon ? `<i data-lucide="${icon}"></i> ${text}` : text;
}
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
/* ---------------------------------------------------------
* Settings Manager
* --------------------------------------------------------- */
export class SettingsManager { export class SettingsManager {
/** /**
* @param {object} notifications — объект с .show({type,title,message}) * @param {object} notifications - Object with .show({type,title,message})
* @param {object} websocket — объект с .on(event, handler) / .off(event, handler) * @param {object} websocket - Object with .on(event, handler) / .off(event, handler)
* @param {object} acquisition — объект с .isRunning() / .triggerSingleSweep() * @param {object} acquisition - Object with .isRunning() / .triggerSingleSweep()
*/ */
constructor(notifications, websocket, acquisition) { constructor(notifications, websocket, acquisition) {
// DI // Dependencies
this.notifications = notifications; this.notifications = notifications;
this.websocket = websocket; this.websocket = websocket;
this.acquisition = acquisition; this.acquisition = acquisition;
@ -93,7 +29,7 @@ export class SettingsManager {
this.availableReferences = []; this.availableReferences = [];
this.currentReference = null; this.currentReference = null;
// Калибровка: состояние захвата // Calibration: capture state
this.disabledStandards = new Set(); this.disabledStandards = new Set();
// DOM cache // DOM cache
@ -103,8 +39,6 @@ export class SettingsManager {
this.debouncer = new Debouncer(); this.debouncer = new Debouncer();
this.reqGuard = new RequestGuard(); this.reqGuard = new RequestGuard();
// Единственный bound-обработчик, чтобы корректно отписываться
// Bind UI handlers // Bind UI handlers
this.handlePresetChange = this.handlePresetChange.bind(this); this.handlePresetChange = this.handlePresetChange.bind(this);
this.handleSetPreset = this.handleSetPreset.bind(this); this.handleSetPreset = this.handleSetPreset.bind(this);
@ -123,7 +57,7 @@ export class SettingsManager {
this.handleClearReference = this.handleClearReference.bind(this); this.handleClearReference = this.handleClearReference.bind(this);
this.handleDeleteReference = this.handleDeleteReference.bind(this); this.handleDeleteReference = this.handleDeleteReference.bind(this);
// Пакет данных для модалки с графиками // Data package for plots modal
this.currentPlotsData = null; this.currentPlotsData = null;
} }
@ -145,7 +79,7 @@ export class SettingsManager {
} }
destroy() { destroy() {
// Чистим состояние и подписки // Clean state and subscriptions
this._resetCalibrationCaptureState(); this._resetCalibrationCaptureState();
this._detachEvents(); this._detachEvents();
this.isInitialized = false; this.isInitialized = false;
@ -595,7 +529,7 @@ export class SettingsManager {
this._notify('info', 'Capturing Standard', `Capturing ${standard.toUpperCase()} standard...`); 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', { const r = await fetch('/api/v1/settings/calibration/add-standard', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -607,10 +541,10 @@ export class SettingsManager {
this._notify('success', 'Standard Captured', result.message); this._notify('success', 'Standard Captured', result.message);
// Сброс состояния // Reset state
this._resetCalibrationCaptureState(); this._resetCalibrationCaptureState();
// Обновить рабочую калибровку // Reload working calibration
await this._loadWorkingCalibration(); await this._loadWorkingCalibration();
} catch (e) { } catch (e) {
console.error('Capture standard failed:', e); console.error('Capture standard failed:', e);
@ -640,7 +574,7 @@ export class SettingsManager {
this._notify('success', 'Calibration Saved', result.message); this._notify('success', 'Calibration Saved', result.message);
// Очистить рабочую калибровку в UI // Clear working calibration in UI
this._hideCalibrationSteps(); this._hideCalibrationSteps();
this.elements.calibrationNameInput.value = ''; this.elements.calibrationNameInput.value = '';
@ -1025,7 +959,7 @@ export class SettingsManager {
} }
const data = this._prepareCalibrationDownloadData(standardName); 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`); this._notify('success', 'Download Complete', `Downloaded ${standardName.toUpperCase()} standard plot and data`);
} catch (e) { } catch (e) {
@ -1046,7 +980,7 @@ export class SettingsManager {
if (btn) ButtonState.set(btn, { state: 'loading', icon: 'loader', text: 'Downloading...' }); if (btn) ButtonState.set(btn, { state: 'loading', icon: 'loader', text: 'Downloading...' });
const complete = this._prepareCompleteCalibrationData(); const complete = this._prepareCompleteCalibrationData();
this._downloadJSON(complete, `${base}.json`); downloadJSON(complete, `${base}.json`);
await this._downloadAllPlotImages(base); await this._downloadAllPlotImages(base);
@ -1197,18 +1131,6 @@ export class SettingsManager {
await Promise.all(jobs); 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 ----------------------------- */ /* ----------------------------- Reference Management ----------------------------- */
async _loadReferences() { async _loadReferences() {

View File

@ -3,6 +3,8 @@
* Handles user interface interactions and state management * Handles user interface interactions and state management
*/ */
import { formatProcessorName, debounce } from './utils.js';
export class UIManager { export class UIManager {
constructor(notifications, websocket, charts) { constructor(notifications, websocket, charts) {
this.notifications = notifications; this.notifications = notifications;
@ -22,7 +24,7 @@ export class UIManager {
// State // State
this.currentView = 'dashboard'; this.currentView = 'dashboard';
this.connectionStatus = 'disconnected'; this.connectionStatus = 'disconnected';
// processorId -> { enabled, count, uiParameters, config } // processorId -> { enabled, uiParameters, config }
this.processors = new Map(); this.processors = new Map();
// Event handlers // Event handlers
@ -30,7 +32,6 @@ export class UIManager {
viewChange: [], viewChange: [],
processorToggle: [], processorToggle: [],
}; };
} }
/** /**
@ -58,7 +59,6 @@ export class UIManager {
this.websocket.on('processor_result', (payload) => this.onProcessorResult(payload)); this.websocket.on('processor_result', (payload) => this.onProcessorResult(payload));
} }
console.log('✅ UI Manager initialized'); console.log('✅ UI Manager initialized');
} }
@ -103,7 +103,7 @@ export class UIManager {
// Window resize // 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 || {}; proc.config = metadata?.config || {};
this.processors.set(processor_id, proc); this.processors.set(processor_id, proc);
// Refresh toggles and settings // Refresh toggles
this.refreshProcessorToggles(); this.refreshProcessorToggles();
this.updateProcessorSettings();
// No more statistics tracking needed
// Pass to charts // Pass to charts
if (this.charts) { if (this.charts) {
@ -216,7 +213,7 @@ export class UIManager {
toggle.dataset.processor = name; toggle.dataset.processor = name;
toggle.innerHTML = ` toggle.innerHTML = `
<div class="processor-toggle__checkbox"></div> <div class="processor-toggle__checkbox"></div>
<div class="processor-toggle__label">${this.formatProcessorName(name)}</div> <div class="processor-toggle__label">${formatProcessorName(name)}</div>
`; `;
container.appendChild(toggle); container.appendChild(toggle);
} }
@ -250,128 +247,6 @@ export class UIManager {
this.emitEvent('processorToggle', processorId, enabled); 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 `
<div class="processor-param" data-param="${param.name}">
<div class="processor-param__label">
${param.label}
<span class="processor-param__value">${value}</span>
</div>
<input
type="range"
class="processor-param__slider"
id="${paramId}"
min="${opts.min ?? 0}"
max="${opts.max ?? 100}"
step="${opts.step ?? 1}"
value="${value}"
>
</div>
`;
case 'toggle':
case 'boolean':
return `
<div class="processor-param" data-param="${param.name}">
<div class="processor-param__label">${param.label}</div>
<label class="processor-param__toggle">
<input type="checkbox" id="${paramId}" ${value ? 'checked' : ''}>
<span class="processor-param__toggle-slider"></span>
</label>
</div>
`;
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 =>
`<option value="${option}" ${option === value ? 'selected' : ''}>${option}</option>`
).join('');
return `
<div class="processor-param" data-param="${param.name}">
<div class="processor-param__label">${param.label}</div>
<select class="processor-param__select" id="${paramId}">
${optionsHtml}
</select>
</div>
`;
}
default:
return `
<div class="processor-param" data-param="${param.name}">
<div class="processor-param__label">${param.label}</div>
<input
type="text"
class="form-input"
id="${paramId}"
value="${value ?? ''}"
>
</div>
`;
}
}
/**
* 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 * Public API
*/ */
@ -387,13 +262,6 @@ export class UIManager {
this.toggleProcessor(processorId, enabled); this.toggleProcessor(processorId, enabled);
} }
formatProcessorName(processorName) {
return processorName
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
handleResize() { handleResize() {
console.log('📱 Window resized'); console.log('📱 Window resized');
// Charts handle their own resize // 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() { getStats() {
return { return {
currentView: this.currentView, currentView: this.currentView,

View File

@ -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 = `<i data-lucide="${icon || 'loader'}"></i> ${text || 'Loading...'}`;
break;
case 'disabled':
element.disabled = true;
element.innerHTML = icon ? `<i data-lucide="${icon}"></i> ${text}` : text;
break;
case 'normal':
default:
element.disabled = !!disabled;
element.innerHTML = icon ? `<i data-lucide="${icon}"></i> ${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 `
<div class="chart-setting" data-param="${param.name}">
<label class="chart-setting__label">
${param.label}
<span class="chart-setting__value">${value}</span>
</label>
<input
type="range"
class="chart-setting__slider"
id="${paramId}"
min="${opts.min ?? 0}"
max="${opts.max ?? 100}"
step="${opts.step ?? 1}"
value="${value}"
>
</div>
`;
case 'toggle':
case 'boolean':
return `
<div class="chart-setting" data-param="${param.name}">
<label class="chart-setting__toggle">
<input type="checkbox" id="${paramId}" ${value ? 'checked' : ''}>
<span class="chart-setting__toggle-slider"></span>
<span class="chart-setting__label">${param.label}</span>
</label>
</div>
`;
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 =>
`<option value="${option}" ${option === value ? 'selected' : ''}>${option}</option>`
).join('');
return `
<div class="chart-setting" data-param="${param.name}">
<label class="chart-setting__label">${param.label}</label>
<select class="chart-setting__select" id="${paramId}">
${optionsHtml}
</select>
</div>
`;
}
case 'button':
const buttonText = param.label || 'Click';
const actionDesc = opts.action ? `title="${opts.action}"` : '';
return `
<div class="chart-setting" data-param="${param.name}">
<button
type="button"
class="chart-setting__button"
id="${paramId}"
data-processor="${processorId}"
data-param="${param.name}"
${actionDesc}
>
${buttonText}
</button>
</div>
`;
default:
return `
<div class="chart-setting" data-param="${param.name}">
<label class="chart-setting__label">${param.label}</label>
<input
type="text"
class="chart-setting__input"
id="${paramId}"
value="${value ?? ''}"
>
</div>
`;
}
}
/**
* 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
/**
* 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];
}