Files
vna_system/vna_system/web_ui/static/js/modules/settings.js
T
2025-09-29 16:02:06 +03:00

1256 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Settings Manager Module
* - Управление пресетами VNA
* - Управление калибровками (рабочая/текущая)
* - Построение графиков эталонов (Plotly)
* - Защита от многократных запросов: debounce + runExclusive (мьютексы)
* - Корректная подписка/отписка на WebSocket событие одним и тем же обработчиком
*/
/* ---------------------------------------------------------
* 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 {
/**
* @param {object} notifications — объект с .show({type,title,message})
* @param {object} websocket — объект с .on(event, handler) / .off(event, handler)
* @param {object} acquisition — объект с .isRunning() / .triggerSingleSweep()
*/
constructor(notifications, websocket, acquisition) {
// DI
this.notifications = notifications;
this.websocket = websocket;
this.acquisition = acquisition;
// State
this.isInitialized = false;
this.currentPreset = null;
this.currentCalibration = null;
this.workingCalibration = null;
// Калибровка: ожидание свипа
this.waitingForSweep = false;
this.pendingStandard = null;
this.disabledStandards = new Set();
this.calibrationTimeout = null;
// DOM cache
this.elements = {};
// Guards
this.debouncer = new Debouncer();
this.reqGuard = new RequestGuard();
// Единственный bound-обработчик, чтобы корректно отписываться
this._boundHandleSweepForCalibration = this.handleSweepForCalibration.bind(this);
// Bind UI handlers
this.handlePresetChange = this.handlePresetChange.bind(this);
this.handleSetPreset = this.handleSetPreset.bind(this);
this.handleStartCalibration = this.handleStartCalibration.bind(this);
this.handleCalibrateStandard = this.handleCalibrateStandard.bind(this);
this.handleSaveCalibration = this.handleSaveCalibration.bind(this);
this.handleSetCalibration = this.handleSetCalibration.bind(this);
this.handleCalibrationChange = this.handleCalibrationChange.bind(this);
this.handleViewPlots = this.handleViewPlots.bind(this);
this.handleViewCurrentPlots = this.handleViewCurrentPlots.bind(this);
// Пакет данных для модалки с графиками
this.currentPlotsData = null;
}
/* ----------------------------- Lifecycle ----------------------------- */
async init() {
try {
this._cacheDom();
this._attachEvents();
await this._loadInitialData();
this.isInitialized = true;
console.log('✅ Settings Manager initialized');
} catch (err) {
console.error('❌ Settings Manager init failed:', err);
this._notify('error', 'Settings Error', 'Failed to initialize settings');
}
}
destroy() {
// Чистим состояние и подписки
this._resetCalibrationCaptureState();
this._detachEvents();
this.isInitialized = false;
console.log('🧹 Settings Manager destroyed');
}
async refresh() {
if (!this.isInitialized) return;
await this._loadInitialData();
}
/* ----------------------------- DOM ----------------------------- */
_cacheDom() {
this.elements = {
// Presets
presetDropdown: document.getElementById('presetDropdown'),
setPresetBtn: document.getElementById('setPresetBtn'),
currentPreset: document.getElementById('currentPreset'),
// Calibration
currentCalibration: document.getElementById('currentCalibration'),
startCalibrationBtn: document.getElementById('startCalibrationBtn'),
calibrationSteps: document.getElementById('calibrationSteps'),
calibrationStandards: document.getElementById('calibrationStandards'),
progressText: document.getElementById('progressText'),
calibrationNameInput: document.getElementById('calibrationNameInput'),
saveCalibrationBtn: document.getElementById('saveCalibrationBtn'),
calibrationDropdown: document.getElementById('calibrationDropdown'),
setCalibrationBtn: document.getElementById('setCalibrationBtn'),
viewPlotsBtn: document.getElementById('viewPlotsBtn'),
viewCurrentPlotsBtn: document.getElementById('viewCurrentPlotsBtn'),
// Modal
plotsModal: document.getElementById('plotsModal'),
plotsGrid: document.getElementById('plotsGrid'),
downloadAllBtn: document.getElementById('downloadAllBtn'),
// Status
presetCount: document.getElementById('presetCount'),
calibrationCount: document.getElementById('calibrationCount'),
systemStatus: document.getElementById('systemStatus')
};
}
_attachEvents() {
// Presets
this.elements.presetDropdown?.addEventListener('change', this.handlePresetChange);
this.elements.setPresetBtn?.addEventListener('click', this.handleSetPreset);
// Calibration
this.elements.startCalibrationBtn?.addEventListener('click', this.handleStartCalibration);
this.elements.saveCalibrationBtn?.addEventListener('click', this.handleSaveCalibration);
this.elements.calibrationDropdown?.addEventListener('change', this.handleCalibrationChange);
this.elements.setCalibrationBtn?.addEventListener('click', this.handleSetCalibration);
this.elements.viewPlotsBtn?.addEventListener('click', this.handleViewPlots);
this.elements.viewCurrentPlotsBtn?.addEventListener('click', this.handleViewCurrentPlots);
// Name input → enables Save
this.elements.calibrationNameInput?.addEventListener('input', () => {
const hasName = this.elements.calibrationNameInput.value.trim().length > 0;
const isComplete = this.workingCalibration && this.workingCalibration.is_complete;
this.elements.saveCalibrationBtn.disabled = !hasName || !isComplete;
});
}
_detachEvents() {
this.elements.presetDropdown?.removeEventListener('change', this.handlePresetChange);
this.elements.setPresetBtn?.removeEventListener('click', this.handleSetPreset);
this.elements.startCalibrationBtn?.removeEventListener('click', this.handleStartCalibration);
this.elements.saveCalibrationBtn?.removeEventListener('click', this.handleSaveCalibration);
this.elements.calibrationDropdown?.removeEventListener('change', this.handleCalibrationChange);
this.elements.setCalibrationBtn?.removeEventListener('click', this.handleSetCalibration);
this.elements.viewPlotsBtn?.removeEventListener('click', this.handleViewPlots);
this.elements.viewCurrentPlotsBtn?.removeEventListener('click', this.handleViewCurrentPlots);
// WebSocket
if (this.websocket) {
this.websocket.off?.('processor_result', this._boundHandleSweepForCalibration);
}
}
/* ----------------------------- Data Loading ----------------------------- */
async _loadInitialData() {
await Promise.all([
this._loadPresets(),
this._loadStatus(),
this._loadWorkingCalibration()
]);
}
async _loadPresets() {
try {
const r = await fetch('/api/v1/settings/presets');
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const presets = await r.json();
this._populatePresetDropdown(presets);
} catch (e) {
console.error('Presets load failed:', e);
this._notify('error', 'Load Error', 'Failed to load configuration presets');
}
}
async _loadStatus() {
try {
const r = await fetch('/api/v1/settings/status');
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const status = await r.json();
this._updateStatusDisplay(status);
} catch (e) {
console.error('Status load failed:', e);
}
}
async _loadWorkingCalibration() {
try {
const r = await fetch('/api/v1/settings/working-calibration');
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const working = await r.json();
this._updateWorkingCalibration(working);
} catch (e) {
console.error('Working calibration load failed:', e);
}
}
async _loadCalibrations() {
if (!this.currentPreset) return;
try {
const r = await fetch(`/api/v1/settings/calibrations?preset_filename=${encodeURIComponent(this.currentPreset.filename)}`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const calibrations = await r.json();
this._populateCalibrationDropdown(calibrations);
} catch (e) {
console.error('Calibrations load failed:', e);
}
}
/* ----------------------------- UI Populate ----------------------------- */
_populatePresetDropdown(presets) {
const dd = this.elements.presetDropdown;
dd.innerHTML = '';
if (!presets.length) {
dd.innerHTML = '<option value="">No presets available</option>';
dd.disabled = true;
this.elements.setPresetBtn.disabled = true;
return;
}
dd.innerHTML = '<option value="">Select preset...</option>';
presets.forEach(p => {
const opt = document.createElement('option');
opt.value = p.filename;
opt.textContent = this._formatPresetDisplay(p);
dd.appendChild(opt);
});
dd.disabled = false;
this.elements.setPresetBtn.disabled = true;
}
_populateCalibrationDropdown(calibrations) {
const dd = this.elements.calibrationDropdown;
dd.innerHTML = '';
if (!calibrations.length) {
dd.innerHTML = '<option value="">No calibrations available</option>';
dd.disabled = true;
this.elements.setCalibrationBtn.disabled = true;
this.elements.viewPlotsBtn.disabled = true;
return;
}
dd.innerHTML = '<option value="">Select calibration...</option>';
calibrations.forEach(c => {
const opt = document.createElement('option');
opt.value = c.name;
opt.textContent = `${c.name} ${c.is_complete ? '✓' : '⚠'}`;
dd.appendChild(opt);
});
dd.disabled = false;
this.elements.setCalibrationBtn.disabled = true;
this.elements.viewPlotsBtn.disabled = true;
}
_formatPresetDisplay(p) {
let s = `${p.filename} (${p.mode})`;
if (p.start_freq && p.stop_freq) {
const startMHz = (p.start_freq / 1e6).toFixed(0);
const stopMHz = (p.stop_freq / 1e6).toFixed(0);
s += ` - ${startMHz}-${stopMHz}MHz`;
}
if (p.points) s += `, ${p.points}pts`;
return s;
}
_updateStatusDisplay(status) {
// preset
if (status.current_preset) {
this.currentPreset = status.current_preset;
this.elements.currentPreset.textContent = status.current_preset.filename;
this.elements.startCalibrationBtn.disabled = false;
this._loadCalibrations();
} else {
this.currentPreset = null;
this.elements.currentPreset.textContent = 'None';
this.elements.startCalibrationBtn.disabled = true;
}
// active calibration
if (status.current_calibration) {
this.currentCalibration = status.current_calibration;
this.elements.currentCalibration.textContent = status.current_calibration.calibration_name;
} else {
this.currentCalibration = null;
this.elements.currentCalibration.textContent = 'None';
}
// counts
this.elements.presetCount.textContent = status.available_presets || 0;
this.elements.calibrationCount.textContent = status.available_calibrations || 0;
this.elements.systemStatus.textContent = 'Ready';
}
_updateWorkingCalibration(working) {
this.workingCalibration = working;
if (working.active) {
this._showCalibrationSteps(working);
} else {
this._hideCalibrationSteps();
}
}
_showCalibrationSteps(working) {
this.elements.calibrationSteps.style.display = 'block';
this.elements.progressText.textContent = working.progress || '0/0';
this._renderStandardButtons(working);
const hasName = this.elements.calibrationNameInput.value.trim().length > 0;
this.elements.saveCalibrationBtn.disabled = !hasName || !working.is_complete;
this.elements.calibrationNameInput.disabled = false;
const hasCompleted = (working.completed_standards || []).length > 0;
if (this.elements.viewCurrentPlotsBtn) {
this.elements.viewCurrentPlotsBtn.disabled = !hasCompleted;
}
}
_hideCalibrationSteps() {
this.elements.calibrationSteps.style.display = 'none';
this.elements.calibrationStandards.innerHTML = '';
if (this.elements.viewCurrentPlotsBtn) {
this.elements.viewCurrentPlotsBtn.disabled = true;
}
}
_renderStandardButtons(working) {
const container = this.elements.calibrationStandards;
container.innerHTML = '';
const all = this._standardsForCurrentMode();
const completed = working.completed_standards || [];
const missing = working.missing_standards || [];
all.forEach(std => {
const btn = document.createElement('button');
btn.className = 'btn calibration-standard-btn';
btn.dataset.standard = std;
const isCompleted = completed.includes(std);
const isMissing = missing.includes(std);
const capturing = this.disabledStandards.has(std);
if (capturing) {
btn.classList.add('btn--warning');
btn.innerHTML = `<i data-lucide="clock"></i> Capturing ${std.toUpperCase()}...`;
btn.disabled = true;
btn.title = 'Standard is currently being captured';
} else if (isCompleted) {
btn.classList.add('btn--success');
btn.innerHTML = `<i data-lucide="check"></i> ${std.toUpperCase()} ✓`;
btn.disabled = false;
btn.title = 'Click to recapture this standard';
} else if (isMissing) {
btn.classList.add('btn--primary');
btn.innerHTML = `<i data-lucide="radio"></i> Capture ${std.toUpperCase()}`;
btn.disabled = false;
btn.title = 'Click to capture this standard';
} else {
btn.classList.add('btn--secondary');
btn.innerHTML = `${std.toUpperCase()}`;
btn.disabled = true;
}
btn.addEventListener('click', () => this.handleCalibrateStandard(std));
container.appendChild(btn);
});
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
}
_standardsForCurrentMode() {
if (!this.currentPreset) return [];
if (this.currentPreset.mode === 's11') return ['open', 'short', 'load'];
if (this.currentPreset.mode === 's21') return ['through'];
return [];
}
_resetCalibrationStateForPresetChange() {
this.workingCalibration = null;
this._hideCalibrationSteps();
if (this.elements.calibrationNameInput) {
this.elements.calibrationNameInput.value = '';
this.elements.calibrationNameInput.disabled = true;
}
if (this.elements.saveCalibrationBtn) this.elements.saveCalibrationBtn.disabled = true;
if (this.elements.progressText) this.elements.progressText.textContent = '0/0';
console.log('🔄 Calibration UI reset after preset change');
}
/* ----------------------------- Event Handlers (UI) ----------------------------- */
handlePresetChange() {
const v = this.elements.presetDropdown.value;
this.elements.setPresetBtn.disabled = !v;
}
handleCalibrationChange() {
const v = this.elements.calibrationDropdown.value;
this.elements.setCalibrationBtn.disabled = !v;
this.elements.viewPlotsBtn.disabled = !v;
}
async handleSetPreset() {
const filename = this.elements.presetDropdown.value;
if (!filename) return;
this.debouncer.debounce('set-preset', () =>
this.reqGuard.runExclusive('set-preset', async () => {
try {
ButtonState.set(this.elements.setPresetBtn, { state: 'loading', icon: 'loader', text: 'Setting...' });
const r = await fetch('/api/v1/settings/preset/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename })
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const result = await r.json();
this._notify('success', 'Preset Set', result.message);
// Сброс UI калибровки
this._resetCalibrationStateForPresetChange();
// Обновить статус
await this._loadStatus();
} catch (e) {
console.error('Set preset failed:', e);
this._notify('error', 'Preset Error', 'Failed to set configuration preset');
} finally {
ButtonState.set(this.elements.setPresetBtn, { state: 'normal', icon: 'check', text: 'Set Active' });
}
}), 300
);
}
async handleStartCalibration() {
if (!this.currentPreset) return;
this.debouncer.debounce('start-calibration', () =>
this.reqGuard.runExclusive('start-calibration', async () => {
try {
ButtonState.set(this.elements.startCalibrationBtn, { state: 'loading', icon: 'loader', text: 'Starting...' });
const r = await fetch('/api/v1/settings/calibration/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ preset_filename: this.currentPreset.filename })
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const result = await r.json();
this._notify('info', 'Calibration Started', `Started calibration for ${result.preset}`);
await this._loadWorkingCalibration();
} catch (e) {
console.error('Start calibration failed:', e);
this._notify('error', 'Calibration Error', 'Failed to start calibration');
} finally {
ButtonState.set(this.elements.startCalibrationBtn, { state: 'normal', icon: 'play', text: 'Start Calibration' });
}
}), 400
);
}
async handleCalibrateStandard(standard) {
const key = `calibrate-${standard}`;
if (this.disabledStandards.has(standard)) return;
this.debouncer.debounce(key, () =>
this.reqGuard.runExclusive(key, async () => {
try {
// Отметим стандарт как «занят»
this.disabledStandards.add(standard);
const btn = document.querySelector(`[data-standard="${standard}"]`);
ButtonState.set(btn, { state: 'loading', icon: 'clock', text: 'Waiting for next sweep...' });
// Если acquisition не работает — попросим один свип
const running = this.acquisition?.isRunning?.() ?? false;
if (!running) {
this._notify('info', 'Triggering Sweep', `Requesting single sweep for ${standard.toUpperCase()} standard`);
try {
await this.acquisition?.triggerSingleSweep?.();
} catch (e) {
console.error('Trigger sweep failed:', e);
this._notify('error', 'Sweep Error', 'Failed to trigger single sweep for calibration');
this._resetCalibrationCaptureState(standard);
return;
}
} else {
// Если уже работает — просим пользователя запустить новый свип (или просто ждём)
this._notify('info', 'Waiting for Sweep', `Please trigger a new sweep to capture ${standard.toUpperCase()} standard`);
}
// Ожидаем следующий свип через WebSocket; подписка единым обработчиком
this.waitingForSweep = true;
this.pendingStandard = standard;
this.websocket?.on?.('processor_result', this._boundHandleSweepForCalibration);
// Таймаут ожидания (5с)
this._clearCalibrationTimeout();
this.calibrationTimeout = setTimeout(() => {
this._notify('warning', 'Calibration Timeout', `No sweep received within 5 seconds for ${standard.toUpperCase()}. Please try again.`);
this._resetCalibrationCaptureState(standard);
}, 5000);
} catch (e) {
console.error('Start standard capture failed:', e);
this._notify('error', 'Calibration Error', 'Failed to start calibration standard capture');
this._resetCalibrationCaptureState(standard);
}
}), 500
);
}
async handleSaveCalibration() {
const name = this.elements.calibrationNameInput.value.trim();
if (!name) return;
this.debouncer.debounce('save-calibration', () =>
this.reqGuard.runExclusive('save-calibration', async () => {
try {
ButtonState.set(this.elements.saveCalibrationBtn, { state: 'loading', icon: 'loader', text: 'Saving...' });
const r = await fetch('/api/v1/settings/calibration/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const result = await r.json();
this._notify('success', 'Calibration Saved', result.message);
// Очистить рабочую калибровку в UI
this._hideCalibrationSteps();
this.elements.calibrationNameInput.value = '';
await Promise.all([
this._loadStatus(),
this._loadWorkingCalibration(),
this._loadCalibrations()
]);
} catch (e) {
console.error('Save calibration failed:', e);
this._notify('error', 'Calibration Error', 'Failed to save calibration');
} finally {
ButtonState.set(this.elements.saveCalibrationBtn, { state: 'disabled', icon: 'save', text: 'Save Calibration' });
}
}), 400
);
}
async handleSetCalibration() {
this.debouncer.debounce('set-calibration', () =>
this.reqGuard.runExclusive('set-calibration', async () => {
const name = this.elements.calibrationDropdown.value;
if (!name || !this.currentPreset) return;
try {
ButtonState.set(this.elements.setCalibrationBtn, { state: 'loading', icon: 'loader', text: 'Setting...' });
const r = await fetch('/api/v1/settings/calibration/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, preset_filename: this.currentPreset.filename })
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const result = await r.json();
this._notify('success', 'Calibration Set', result.message);
await this._loadStatus();
} catch (e) {
console.error('Set calibration failed:', e);
this._notify('error', 'Calibration Error', 'Failed to set active calibration');
} finally {
ButtonState.set(this.elements.setCalibrationBtn, { state: 'normal', icon: 'check', text: 'Set Active' });
}
}), 300
);
}
async handleViewPlots() {
this.debouncer.debounce('view-plots', () =>
this.reqGuard.runExclusive('view-plots', async () => {
const name = this.elements.calibrationDropdown.value;
if (!name || !this.currentPreset) return;
try {
ButtonState.set(this.elements.viewPlotsBtn, { state: 'loading', icon: 'loader', text: 'Loading...' });
const url = `/api/v1/settings/calibration/${encodeURIComponent(name)}/standards-plots?preset_filename=${encodeURIComponent(this.currentPreset.filename)}`;
const r = await fetch(url);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const plotsData = await r.json();
this._showPlotsModal(plotsData);
} catch (e) {
console.error('Load plots failed:', e);
this._notify('error', 'Plots Error', 'Failed to load calibration plots');
} finally {
ButtonState.set(this.elements.viewPlotsBtn, { state: 'normal', icon: 'bar-chart-3', text: 'View Plots' });
}
}), 300
);
}
async handleViewCurrentPlots() {
this.debouncer.debounce('view-current-plots', () =>
this.reqGuard.runExclusive('view-current-plots', async () => {
if (!this.workingCalibration || !this.workingCalibration.active) return;
try {
ButtonState.set(this.elements.viewCurrentPlotsBtn, { state: 'loading', icon: 'loader', text: 'Loading...' });
const r = await fetch('/api/v1/settings/working-calibration/standards-plots');
if (!r.ok) {
if (r.status === 404) {
this._notify('warning', 'No Data', 'No working calibration or standards available to plot');
return;
}
throw new Error(`HTTP ${r.status}`);
}
const plotsData = await r.json();
this._showPlotsModal(plotsData);
} catch (e) {
console.error('Load current plots failed:', e);
this._notify('error', 'Plots Error', 'Failed to load current calibration plots');
} finally {
ButtonState.set(this.elements.viewCurrentPlotsBtn, { state: 'normal', icon: 'bar-chart-3', text: 'View Current Plots' });
}
}), 300
);
}
/* ----------------------------- WebSocket sweep capture ----------------------------- */
async handleSweepForCalibration() {
// реагируем только если действительно ждём свип
if (!this.waitingForSweep || !this.pendingStandard) return;
try {
console.log(`📡 New sweep → capture ${this.pendingStandard}...`);
// Сразу отключаем подписку и таймер
this.websocket?.off?.('processor_result', this._boundHandleSweepForCalibration);
this._clearCalibrationTimeout();
const btn = document.querySelector(`[data-standard="${this.pendingStandard}"]`);
ButtonState.set(btn, { state: 'loading', icon: 'upload', text: 'Capturing...' });
const r = await fetch('/api/v1/settings/calibration/add-standard', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ standard: this.pendingStandard })
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const result = await r.json();
this._notify('success', 'Standard Captured', result.message);
// Сброс
this._resetCalibrationCaptureState();
// Обновить рабочую калибровку
await this._loadWorkingCalibration();
} catch (e) {
console.error('Capture standard failed:', e);
this._notify('error', 'Calibration Error', 'Failed to capture calibration standard');
this._resetCalibrationCaptureState();
}
}
_resetCalibrationCaptureState(standard = null) {
if (standard) this.disabledStandards.delete(standard);
else this.disabledStandards.clear();
if (!standard || standard === this.pendingStandard) {
this.waitingForSweep = false;
this.pendingStandard = null;
this.websocket?.off?.('processor_result', this._boundHandleSweepForCalibration);
this._clearCalibrationTimeout();
}
if (this.workingCalibration) {
this._renderStandardButtons(this.workingCalibration);
}
}
_clearCalibrationTimeout() {
if (this.calibrationTimeout) {
clearTimeout(this.calibrationTimeout);
this.calibrationTimeout = null;
}
}
/* ----------------------------- Plots Modal ----------------------------- */
_showPlotsModal(plotsData) {
const modal = this.elements.plotsModal;
if (!modal) return;
// Запомним пакет
this.currentPlotsData = plotsData;
// Рендер карточек/графиков
this._renderCalibrationPlots(plotsData.individual_plots, plotsData.preset);
// Заголовок
const title = modal.querySelector('.modal__title');
if (title) {
title.innerHTML = `
<i data-lucide="bar-chart-3"></i>
${plotsData.calibration_name} - ${plotsData.preset.mode.toUpperCase()} Standards
`;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
// Кнопки закрытия/скачивания
this._setupModalCloseHandlers(modal);
// Показ
modal.classList.add('modal--active');
document.body.style.overflow = 'hidden';
}
_setupModalCloseHandlers(modal) {
modal.querySelectorAll('[data-modal-close]').forEach(el => {
el.addEventListener('click', () => this.closePlotsModal());
});
const downloadAllBtn = modal.querySelector('#downloadAllBtn');
if (downloadAllBtn) {
downloadAllBtn.addEventListener('click', () =>
this.debouncer.debounce('download-all', () => this.downloadAllCalibrationData(), 600)
);
}
const escHandler = (e) => {
if (e.key === 'Escape') {
this.closePlotsModal();
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
}
_renderCalibrationPlots(individualPlots, preset) {
const container = this.elements.plotsGrid;
if (!container) return;
container.innerHTML = '';
if (!individualPlots || !Object.keys(individualPlots).length) {
container.innerHTML = '<div class="plot-error">No calibration plots available</div>';
return;
}
Object.entries(individualPlots).forEach(([name, plot]) => {
if (plot.error) {
const err = document.createElement('div');
err.className = 'chart-card';
err.innerHTML = `
<div class="chart-card__header">
<div class="chart-card__title">
<i data-lucide="alert-circle" class="chart-card__icon"></i>
${name.toUpperCase()} Standard
</div>
</div>
<div class="chart-card__content">
<div class="plot-error">Error: ${plot.error}</div>
</div>
`;
container.appendChild(err);
return;
}
const card = this._createCalibrationChartCard(name, plot, preset);
container.appendChild(card);
});
if (typeof lucide !== 'undefined') {
lucide.createIcons({ attrs: { 'stroke-width': 1.5 } });
}
}
_createCalibrationChartCard(standardName, plotConfig, preset) {
const card = document.createElement('div');
card.className = 'chart-card';
card.dataset.standard = standardName;
const title = `${standardName.toUpperCase()} Standard`;
card.innerHTML = `
<div class="chart-card__header">
<div class="chart-card__title">
<i data-lucide="bar-chart-3" class="chart-card__icon"></i>
${title}
</div>
<div class="chart-card__actions">
<button class="chart-card__action" data-action="fullscreen" title="Fullscreen">
<i data-lucide="expand"></i>
</button>
<button class="chart-card__action" data-action="download" title="Download">
<i data-lucide="download"></i>
</button>
</div>
</div>
<div class="chart-card__content">
<div class="chart-card__plot" id="calibration-plot-${standardName}"></div>
</div>
<div class="chart-card__meta">
<div class="chart-card__timestamp">Standard: ${standardName.toUpperCase()}</div>
<div class="chart-card__sweep">Preset: ${preset?.filename || 'Unknown'}</div>
</div>
`;
// Actions
card.addEventListener('click', (e) => {
const action = e.target.closest?.('[data-action]')?.dataset.action;
if (!action) return;
e.stopPropagation();
const plotEl = card.querySelector('.chart-card__plot');
if (action === 'fullscreen') this._toggleFullscreen(card);
if (action === 'download') this.downloadCalibrationStandard(standardName, plotEl);
});
// Plot
const plotEl = card.querySelector('.chart-card__plot');
this._renderPlotly(plotEl, plotConfig, title);
return card;
}
_renderPlotly(container, plotConfig, title) {
if (!container || !plotConfig || plotConfig.error) {
container.innerHTML = `<div class="plot-error">Failed to load plot: ${plotConfig?.error || 'Unknown error'}</div>`;
return;
}
const layout = {
...plotConfig.layout,
title: { text: title, font: { size: 16, color: '#f1f5f9' } },
plot_bgcolor: 'transparent',
paper_bgcolor: 'transparent',
font: { family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif', size: 12, color: '#f1f5f9' },
autosize: true,
width: null,
height: null,
margin: { l: 60, r: 50, t: 50, b: 60 },
showlegend: true,
legend: {
orientation: 'v',
x: 1.02,
y: 1,
xanchor: 'left',
yanchor: 'top',
bgcolor: 'rgba(30, 41, 59, 0.9)',
bordercolor: '#475569',
borderwidth: 1,
font: { size: 10, color: '#f1f5f9' }
},
xaxis: {
...plotConfig.layout.xaxis,
gridcolor: '#334155',
zerolinecolor: '#475569',
color: '#cbd5e1',
fixedrange: false
},
yaxis: {
...plotConfig.layout.yaxis,
gridcolor: '#334155',
zerolinecolor: '#475569',
color: '#cbd5e1',
fixedrange: false
}
};
const config = {
displayModeBar: true,
modeBarButtonsToRemove: ['select2d', 'lasso2d', 'hoverClosestCartesian', 'hoverCompareCartesian', 'toggleSpikelines'],
displaylogo: false,
responsive: false,
doubleClick: 'reset',
toImageButtonOptions: {
format: 'png',
filename: `calibration-plot-${Date.now()}`,
height: 600,
width: 800,
scale: 1
}
};
Plotly.newPlot(container, plotConfig.data, layout, config);
// Resize observer
if (window.ResizeObserver) {
const ro = new ResizeObserver(() => {
if (container && container.clientWidth > 0) {
Plotly.Plots.resize(container);
}
});
ro.observe(container);
container._resizeObserver = ro;
}
}
_toggleFullscreen(card) {
if (!document.fullscreenElement) {
card.requestFullscreen?.().then(() => {
setTimeout(() => {
const plot = card.querySelector('.chart-card__plot');
if (plot && typeof Plotly !== 'undefined') {
const rect = plot.getBoundingClientRect();
Plotly.relayout(plot, { width: rect.width, height: rect.height });
Plotly.Plots.resize(plot);
}
}, 200);
}).catch(console.error);
} else {
document.exitFullscreen?.().then(() => {
setTimeout(() => {
const plot = card.querySelector('.chart-card__plot');
if (plot && typeof Plotly !== 'undefined') {
Plotly.Plots.resize(plot);
}
}, 100);
});
}
}
closePlotsModal() {
const modal = this.elements.plotsModal;
if (!modal) return;
modal.classList.remove('modal--active');
document.body.style.overflow = '';
// Clean plots
if (typeof Plotly !== 'undefined') {
const containers = modal.querySelectorAll('[id^="calibration-plot-"]');
containers.forEach(c => {
if (c._resizeObserver) { c._resizeObserver.disconnect(); c._resizeObserver = null; }
if (c._fullData) Plotly.purge(c);
});
}
this.currentPlotsData = null;
}
/* ----------------------------- Downloads ----------------------------- */
async downloadCalibrationStandard(standardName, plotContainer) {
try {
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const calibrationName = this.currentPlotsData?.calibration_name || 'unknown';
const base = `${calibrationName}_${standardName}_${ts}`;
if (plotContainer && typeof Plotly !== 'undefined') {
await Plotly.downloadImage(plotContainer, {
format: 'png', width: 1200, height: 800, filename: `${base}_plot`
});
}
const data = this._prepareCalibrationDownloadData(standardName);
this._downloadJSON(data, `${base}_data.json`);
this._notify('success', 'Download Complete', `Downloaded ${standardName.toUpperCase()} standard plot and data`);
} catch (e) {
console.error('Download standard failed:', e);
this._notify('error', 'Download Failed', 'Failed to download calibration data');
}
}
async downloadAllCalibrationData() {
if (!this.currentPlotsData) return;
try {
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const calibrationName = this.currentPlotsData.calibration_name || 'unknown';
const base = `${calibrationName}_complete_${ts}`;
const btn = this.elements.downloadAllBtn;
if (btn) ButtonState.set(btn, { state: 'loading', icon: 'loader', text: 'Downloading...' });
const complete = this._prepareCompleteCalibrationData();
this._downloadJSON(complete, `${base}.json`);
await this._downloadAllPlotImages(base);
this._notify('success', 'Complete Download', `Downloaded complete calibration data and plots for ${calibrationName}`);
} catch (e) {
console.error('Download all failed:', e);
this._notify('error', 'Download Failed', 'Failed to download complete calibration data');
} finally {
const btn = this.elements.downloadAllBtn;
if (btn) ButtonState.set(btn, { state: 'normal', icon: 'download-cloud', text: 'Download All' });
}
}
_prepareCalibrationDownloadData(standardName) {
if (!this.currentPlotsData) return null;
const plot = this.currentPlotsData.individual_plots[standardName];
return {
calibration_info: {
calibration_name: this.currentPlotsData.calibration_name,
preset: this.currentPlotsData.preset,
standard_name: standardName,
download_timestamp: new Date().toISOString()
},
plot_data: plot ? { data: plot.data, layout: plot.layout, error: plot.error } : null,
raw_sweep_data: this._extractRawSweepData(standardName),
metadata: {
description: `VNA calibration standard data export - ${standardName.toUpperCase()}`,
format_version: '1.0',
exported_by: 'VNA System Dashboard',
contains: ['Calibration information', 'Plot configuration', 'Raw sweep measurements', 'Frequency & magnitude data']
}
};
}
_extractRawSweepData(standardName) {
const plot = this.currentPlotsData?.individual_plots?.[standardName];
if (!plot || !plot.raw_sweep_data) return null;
const raw = plot.raw_sweep_data;
const freqInfo = plot.frequency_info;
const points = [];
if (raw.points?.length) {
for (let i = 0; i < raw.points.length; i++) {
const [re, im] = raw.points[i];
const magLin = Math.sqrt(re * re + im * im);
const magDb = magLin > 0 ? 20 * Math.log10(magLin) : -120;
const phaseRad = Math.atan2(im, re);
const phaseDeg = phaseRad * (180 / Math.PI);
let fHz = 0;
if (freqInfo?.start_freq && freqInfo?.stop_freq) {
fHz = freqInfo.start_freq + (freqInfo.stop_freq - freqInfo.start_freq) * i / (raw.points.length - 1);
}
points.push({
point_index: i,
frequency_hz: fHz,
frequency_ghz: fHz / 1e9,
complex_data: { real: re, imaginary: im },
magnitude: { linear: magLin, db: magDb },
phase: { radians: phaseRad, degrees: phaseDeg }
});
}
}
return {
standard_name: standardName,
sweep_info: {
sweep_number: raw.sweep_number,
timestamp: raw.timestamp,
total_points: raw.total_points,
file_path: raw.file_path
},
frequency_info: freqInfo,
measurement_points: points,
statistics: {
total_points: points.length,
frequency_range: {
start_hz: points[0]?.frequency_hz || 0,
stop_hz: points[points.length - 1]?.frequency_hz || 0
},
magnitude_range_db: {
min: Math.min(...points.map(p => p.magnitude.db)),
max: Math.max(...points.map(p => p.magnitude.db))
}
}
};
}
_prepareCompleteCalibrationData() {
if (!this.currentPlotsData) return null;
const all = {};
Object.keys(this.currentPlotsData.individual_plots).forEach(name => {
all[name] = this._prepareCalibrationDownloadData(name);
});
return {
export_info: {
export_timestamp: new Date().toISOString(),
export_type: 'complete_calibration',
calibration_name: this.currentPlotsData.calibration_name,
preset: this.currentPlotsData.preset,
standards_included: Object.keys(all),
format_version: '1.0'
},
calibration_summary: {
name: this.currentPlotsData.calibration_name,
preset: this.currentPlotsData.preset,
total_standards: Object.keys(all).length,
standards: Object.keys(all).map(n => ({
name: n,
has_data: all[n] !== null,
has_error: !!all[n]?.plot_data?.error
}))
},
standards_data: all,
metadata: {
description: 'Complete VNA calibration data export including all standards',
exported_by: 'VNA System Dashboard',
contains: [
'Complete calibration information',
'All calibration standards data',
'Raw sweep measurements',
'Plot configurations (Plotly format)',
'Frequency and magnitude data',
'Complex impedance measurements'
],
usage_notes: [
'File contains all data for the calibration set',
"Individual standard data is in 'standards_data'",
'Raw complex measurements are in [real, imaginary]',
'Frequencies in Hz and GHz, magnitudes in linear and dB'
]
}
};
}
async _downloadAllPlotImages(base) {
const containers = this.elements.plotsModal.querySelectorAll('[id^="calibration-plot-"]');
const jobs = [];
containers.forEach(c => {
if (c && typeof Plotly !== 'undefined' && c._fullData) {
const name = c.id.replace('calibration-plot-', '');
jobs.push(Plotly.downloadImage(c, { format: 'png', width: 1200, height: 800, filename: `${base}_${name}_plot` }));
}
});
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 ----------------------------- */
_notify(type, title, message) {
this.notifications?.show?.({ type, title, message });
}
_resetCalibrationCaptureState(standard = null) {
// публичная версия для внешних вызовов
this._resetCalibrationCaptureStateInternal(standard);
}
_resetCalibrationCaptureStateInternal(standard = null) {
if (standard) this.disabledStandards.delete(standard);
else this.disabledStandards.clear();
if (!standard || standard === this.pendingStandard) {
this.waitingForSweep = false;
this.pendingStandard = null;
this.websocket?.off?.('processor_result', this._boundHandleSweepForCalibration);
this._clearCalibrationTimeout();
}
if (this.workingCalibration) this._renderStandardButtons(this.workingCalibration);
}
}