first refactoring step
This commit is contained in:
@ -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
|
||||
settings: () => window.vnaDashboard?.settings,
|
||||
acquisition: () => window.vnaDashboard?.acquisition
|
||||
};
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 {
|
||||
<div class="chart-card__header">
|
||||
<div class="chart-card__title">
|
||||
<i data-lucide="bar-chart-3" class="chart-card__icon"></i>
|
||||
${this.formatProcessorName(processorId)}
|
||||
${formatProcessorName(processorId)}
|
||||
</div>
|
||||
<div class="chart-card__actions">
|
||||
<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
|
||||
*/
|
||||
@ -317,7 +315,7 @@ export class ChartManager {
|
||||
|
||||
if (processor?.uiParameters && Array.isArray(processor.uiParameters) && processor.uiParameters.length > 0) {
|
||||
const settingsHtml = processor.uiParameters.map(param =>
|
||||
this.createSettingControl(param, processorId)
|
||||
createParameterControl(param, processorId, 'chart')
|
||||
).join('');
|
||||
settingsContainer.innerHTML = settingsHtml;
|
||||
this.setupSettingsEvents(settingsContainer, processorId);
|
||||
@ -330,7 +328,7 @@ export class ChartManager {
|
||||
|
||||
// Generate settings HTML from chart data
|
||||
const settingsHtml = uiParameters.map(param =>
|
||||
this.createSettingControl(param, processorId)
|
||||
createParameterControl(param, processorId, 'chart')
|
||||
).join('');
|
||||
|
||||
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
|
||||
*/
|
||||
@ -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;
|
||||
|
||||
@ -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 = `<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
|
||||
* --------------------------------------------------------- */
|
||||
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() {
|
||||
|
||||
@ -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 = `
|
||||
<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);
|
||||
}
|
||||
@ -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 `
|
||||
<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
|
||||
*/
|
||||
@ -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,
|
||||
|
||||
344
vna_system/web_ui/static/js/modules/utils.js
Normal file
344
vna_system/web_ui/static/js/modules/utils.js
Normal 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, "&")
|
||||
.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];
|
||||
}
|
||||
Reference in New Issue
Block a user