1256 lines
44 KiB
JavaScript
1256 lines
44 KiB
JavaScript
/**
|
||
* 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);
|
||
}
|
||
}
|