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.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,
|
|
||||||
websocket: () => window.vnaDashboard?.websocket,
|
|
||||||
charts: () => window.vnaDashboard?.charts,
|
|
||||||
ui: () => window.vnaDashboard?.ui
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
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
|
settings: () => window.vnaDashboard?.settings,
|
||||||
};
|
acquisition: () => window.vnaDashboard?.acquisition
|
||||||
}
|
};
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
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