829 lines
33 KiB
JavaScript
829 lines
33 KiB
JavaScript
/**
|
|
* Chart Manager
|
|
* Handles Plotly.js chart creation, updates, and management
|
|
*/
|
|
|
|
import { formatProcessorName, safeClone, downloadJSON } from './utils.js';
|
|
import { renderIcons } from './icons.js';
|
|
import { ChartSettingsManager } from './charts/chart-settings.js';
|
|
import { BScanClickHandler } from './charts/bscan-click-handler.js';
|
|
import {
|
|
defaultPlotlyLayout,
|
|
defaultPlotlyConfig,
|
|
createPlotlyPlot,
|
|
updatePlotlyPlot,
|
|
togglePlotlyFullscreen,
|
|
downloadPlotlyImage,
|
|
cleanupPlotly
|
|
} from './plotly-utils.js';
|
|
|
|
export class ChartManager {
|
|
constructor(config, notifications, websocket = null) {
|
|
this.config = config;
|
|
this.notifications = notifications;
|
|
this.websocket = websocket;
|
|
|
|
this.charts = new Map();
|
|
this.chartData = new Map();
|
|
this.disabledProcessors = new Set();
|
|
|
|
this.chartsGrid = null;
|
|
this.emptyState = null;
|
|
|
|
this.updateQueue = new Map();
|
|
this.isUpdating = false;
|
|
this.isPaused = false;
|
|
|
|
this.performanceStats = {
|
|
chartsCreated: 0,
|
|
updatesProcessed: 0,
|
|
avgUpdateTime: 0,
|
|
lastUpdateTime: null
|
|
};
|
|
|
|
this.settingsManager = new ChartSettingsManager();
|
|
this.bscanClickHandler = new BScanClickHandler(websocket, notifications);
|
|
}
|
|
|
|
async init() {
|
|
console.log('Initializing Chart Manager...');
|
|
this.chartsGrid = document.getElementById('chartsGrid');
|
|
this.emptyState = document.getElementById('emptyState');
|
|
if (!this.chartsGrid || !this.emptyState) throw new Error('Required DOM elements not found');
|
|
if (typeof Plotly === 'undefined') throw new Error('Plotly.js not loaded');
|
|
console.log('Chart Manager initialized');
|
|
}
|
|
|
|
addResult(payload) {
|
|
try {
|
|
const { processor_id, timestamp, plotly_config, metadata } = payload;
|
|
if (!processor_id) {
|
|
console.warn('Invalid result - missing processor_id:', payload);
|
|
return;
|
|
}
|
|
|
|
if (this.disabledProcessors.has(processor_id)) {
|
|
return;
|
|
}
|
|
|
|
// Note: 'data' field is no longer included in broadcasts to reduce traffic
|
|
// Use get_processor_state command to retrieve full data when needed
|
|
this.chartData.set(processor_id, {
|
|
timestamp: new Date((timestamp ?? Date.now()) * 1000),
|
|
metadata: metadata || {},
|
|
plotly_config: plotly_config || { data: [], layout: {} }
|
|
});
|
|
|
|
if (!this.charts.has(processor_id)) this.createChart(processor_id);
|
|
|
|
this.updateChart(processor_id, plotly_config || { data: [], layout: {} });
|
|
this.hideEmptyState();
|
|
} catch (e) {
|
|
console.error('Error adding chart result:', e);
|
|
this.notifications?.show?.({
|
|
type: 'error',
|
|
title: 'Ошибка графика',
|
|
message: 'Не удалось обновить график'
|
|
});
|
|
}
|
|
}
|
|
|
|
createChart(processorId) {
|
|
console.log(`Creating chart for processor: ${processorId}`);
|
|
const card = this.createChartCard(processorId);
|
|
this.chartsGrid.appendChild(card);
|
|
|
|
const plotContainer = card.querySelector('.chart-card__plot');
|
|
const layoutOverrides = {
|
|
title: { text: formatProcessorName(processorId), font: { size: 16, color: '#f1f5f9' } },
|
|
width: plotContainer.clientWidth || 500,
|
|
height: plotContainer.clientHeight || 420
|
|
};
|
|
|
|
// Keep interactivity for bscan processor but disable some features
|
|
const configOverrides = processorId === 'bscan' ? {
|
|
displayModeBar: true,
|
|
modeBarButtonsToRemove: ['select2d', 'lasso2d'],
|
|
scrollZoom: false
|
|
} : {};
|
|
|
|
createPlotlyPlot(plotContainer, [], layoutOverrides, configOverrides);
|
|
|
|
this.charts.set(processorId, { element: card, plotContainer, isVisible: true, settingsInitialized: false });
|
|
this.performanceStats.chartsCreated++;
|
|
|
|
// Attach click handler for bscan processor
|
|
if (processorId === 'bscan') {
|
|
this.bscanClickHandler.attachClickHandler(processorId, plotContainer);
|
|
}
|
|
|
|
if (this.config.animation) {
|
|
setTimeout(() => card.classList.add('chart-card--animated'), 50);
|
|
}
|
|
}
|
|
|
|
updateChart(processorId, plotlyConfig) {
|
|
if (this.isPaused) return;
|
|
|
|
const chart = this.charts.get(processorId);
|
|
if (!chart?.plotContainer) {
|
|
console.warn(`Chart not found for processor: ${processorId}`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const start = performance.now();
|
|
this.queueUpdate(processorId, async () => {
|
|
const layoutOverrides = {
|
|
...(plotlyConfig.layout || {}),
|
|
title: { text: formatProcessorName(processorId), font: { size: 16, color: '#f1f5f9' } }
|
|
};
|
|
|
|
// Keep interactivity for bscan processor but disable some features
|
|
const configOverrides = processorId === 'bscan' ? {
|
|
displayModeBar: true,
|
|
modeBarButtonsToRemove: ['select2d', 'lasso2d'],
|
|
scrollZoom: false
|
|
} : {};
|
|
|
|
await updatePlotlyPlot(chart.plotContainer, plotlyConfig.data || [], layoutOverrides, configOverrides);
|
|
|
|
this.updateChartMetadata(processorId);
|
|
|
|
if (!chart.settingsInitialized) {
|
|
this.updateChartSettings(processorId);
|
|
chart.settingsInitialized = true;
|
|
} else {
|
|
this.updateChartSettings(processorId);
|
|
}
|
|
|
|
// Clear selection for bscan when data updates
|
|
if (processorId === 'bscan') {
|
|
this.bscanClickHandler.onDataUpdate(processorId);
|
|
}
|
|
|
|
const dt = performance.now() - start;
|
|
this.updatePerformanceStats(dt);
|
|
});
|
|
} catch (e) {
|
|
console.error(`Error updating chart ${processorId}:`, e);
|
|
}
|
|
}
|
|
|
|
queueUpdate(id, fn) {
|
|
this.updateQueue.set(id, fn);
|
|
if (!this.isUpdating) this.processUpdateQueue();
|
|
}
|
|
|
|
async processUpdateQueue() {
|
|
if (this.isPaused) return;
|
|
this.isUpdating = true;
|
|
while (this.updateQueue.size > 0 && !this.isPaused) {
|
|
const [id, fn] = this.updateQueue.entries().next().value;
|
|
this.updateQueue.delete(id);
|
|
try { await fn(); } catch (e) { console.error(`Error in queued update for ${id}:`, e); }
|
|
await new Promise(r => setTimeout(r, 0));
|
|
}
|
|
this.isUpdating = false;
|
|
}
|
|
|
|
createChartCard(processorId) {
|
|
const card = document.createElement('div');
|
|
card.className = 'chart-card';
|
|
card.dataset.processor = processorId;
|
|
|
|
card.innerHTML = `
|
|
<div class="chart-card__header">
|
|
<div class="chart-card__title">
|
|
<span data-icon="bar-chart-3" class="chart-card__icon"></span>
|
|
${formatProcessorName(processorId)}
|
|
</div>
|
|
<div class="chart-card__actions">
|
|
<button class="chart-card__action" data-action="fullscreen" title="Fullscreen">
|
|
<span data-icon="expand"></span>
|
|
</button>
|
|
<button class="chart-card__action" data-action="upload" title="Load History">
|
|
<span data-icon="upload"></span>
|
|
</button>
|
|
<button class="chart-card__action" data-action="append" title="Append History">
|
|
<span data-icon="plus"></span>
|
|
</button>
|
|
<button class="chart-card__action" data-action="download" title="Download JSON">
|
|
<span data-icon="download"></span>
|
|
</button>
|
|
<button class="chart-card__action" data-action="export-sweeps" title="Export Sweeps (TSV)">
|
|
<span data-icon="database"></span>
|
|
</button>
|
|
<button class="chart-card__action" data-action="hide" title="Hide">
|
|
<span data-icon="eye-off"></span>
|
|
</button>
|
|
</div>
|
|
<input type="file" id="historyFileInput_${processorId}" accept=".json" style="display: none;">
|
|
<input type="file" id="appendFileInput_${processorId}" accept=".json" style="display: none;">
|
|
</div>
|
|
<div class="chart-card__content">
|
|
<div class="chart-card__plot" id="plot-${processorId}"></div>
|
|
<div class="chart-card__settings" id="settings-${processorId}">
|
|
<div class="chart-settings">
|
|
<div class="chart-settings__header">Settings</div>
|
|
<div class="chart-settings__controls">
|
|
<!-- Settings will be populated here -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="chart-card__meta">
|
|
<div class="chart-card__timestamp" data-timestamp="">Last update: --</div>
|
|
<div class="chart-card__sweep" data-sweep=""></div>
|
|
${processorId === 'bscan' ? `
|
|
<div class="chart-card__shortcuts" style="font-size: 11px; color: #94a3b8; margin-top: 4px;">
|
|
Клавиши: <kbd>Клик</kbd> - выбрать | <kbd>D</kbd> - удалить | <kbd>P</kbd> - предпросмотр | <kbd>Esc</kbd> - отмена
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
`;
|
|
|
|
this.setupChartCardEvents(card, processorId);
|
|
this.updateChartSettings(processorId);
|
|
|
|
renderIcons(card);
|
|
return card;
|
|
}
|
|
|
|
setupChartCardEvents(card, processorId) {
|
|
card.addEventListener('click', (e) => {
|
|
const action = e.target.closest('[data-action]')?.dataset.action;
|
|
if (!action) return;
|
|
e.stopPropagation();
|
|
switch (action) {
|
|
case 'fullscreen': this.toggleFullscreen(processorId); break;
|
|
case 'upload': this.uploadHistory(processorId); break;
|
|
case 'append': this.appendHistory(processorId); break;
|
|
case 'download': this.downloadChart(processorId); break;
|
|
case 'export-sweeps': this.exportSweeps(processorId); break;
|
|
case 'hide':
|
|
this.hideChart(processorId);
|
|
if (window.vnaDashboard?.ui) window.vnaDashboard.ui.setProcessorEnabled(processorId, false);
|
|
break;
|
|
}
|
|
});
|
|
|
|
// Setup file input handler for load history
|
|
const fileInput = card.querySelector(`#historyFileInput_${processorId}`);
|
|
if (fileInput) {
|
|
fileInput.addEventListener('change', async (e) => {
|
|
await this.handleHistoryUpload(processorId, e);
|
|
});
|
|
}
|
|
|
|
// Setup file input handler for append history
|
|
const appendFileInput = card.querySelector(`#appendFileInput_${processorId}`);
|
|
if (appendFileInput) {
|
|
appendFileInput.addEventListener('change', async (e) => {
|
|
await this.handleHistoryAppend(processorId, e);
|
|
});
|
|
}
|
|
}
|
|
|
|
updateChartMetadata(processorId) {
|
|
const chart = this.charts.get(processorId);
|
|
const latestData = this.chartData.get(processorId);
|
|
if (!chart || !latestData) return;
|
|
|
|
const tsEl = chart.element.querySelector('[data-timestamp]');
|
|
if (tsEl) {
|
|
const dt = latestData.timestamp instanceof Date ? latestData.timestamp : new Date();
|
|
tsEl.textContent = `Last update: ${dt.toLocaleTimeString()}`;
|
|
tsEl.dataset.timestamp = dt.toISOString();
|
|
}
|
|
}
|
|
|
|
updateChartSettings(processorId) {
|
|
const chart = this.charts.get(processorId);
|
|
const settingsContainer = chart?.element?.querySelector('.chart-settings__controls');
|
|
const latestData = this.chartData.get(processorId);
|
|
|
|
if (settingsContainer && latestData) {
|
|
this.settingsManager.updateSettings(processorId, settingsContainer, latestData);
|
|
}
|
|
}
|
|
|
|
toggleProcessor(id, enabled) { enabled ? this.showChart(id) : this.hideChart(id); }
|
|
|
|
showChart(id) {
|
|
const c = this.charts.get(id);
|
|
if (c) {
|
|
c.element.classList.remove('chart-card--hidden');
|
|
c.isVisible = true;
|
|
setTimeout(() => c.plotContainer && Plotly.Plots.resize(c.plotContainer), 100);
|
|
}
|
|
this.updateEmptyStateVisibility();
|
|
}
|
|
|
|
hideChart(id) {
|
|
const c = this.charts.get(id);
|
|
if (c) { c.element.classList.add('chart-card--hidden'); c.isVisible = false; }
|
|
this.updateEmptyStateVisibility();
|
|
}
|
|
|
|
removeChart(id) {
|
|
const c = this.charts.get(id);
|
|
if (c) {
|
|
// Cleanup bscan click handler if applicable
|
|
if (id === 'bscan') {
|
|
this.bscanClickHandler.detachClickHandler(id, c.plotContainer);
|
|
}
|
|
|
|
cleanupPlotly(c.plotContainer);
|
|
c.element.remove();
|
|
this.charts.delete(id);
|
|
this.chartData.delete(id);
|
|
this.disabledProcessors.delete(id);
|
|
}
|
|
this.updateEmptyStateVisibility();
|
|
}
|
|
|
|
clearAll() {
|
|
for (const [id] of this.charts) this.removeChart(id);
|
|
this.charts.clear();
|
|
this.chartData.clear();
|
|
this.updateQueue.clear();
|
|
this.updateEmptyStateVisibility();
|
|
}
|
|
|
|
async downloadChart(id) {
|
|
const c = this.charts.get(id);
|
|
if (!c?.plotContainer) return;
|
|
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const baseFilename = `${id}_${timestamp}`;
|
|
|
|
try {
|
|
// Download plot image
|
|
await downloadPlotlyImage(c.plotContainer, `${baseFilename}_plot`);
|
|
|
|
// Request full processor state from backend via WebSocket
|
|
const websocket = window.vnaDashboard?.websocket;
|
|
if (websocket && websocket.getProcessorState) {
|
|
// Set up one-time listener for processor_state response
|
|
const stateHandler = (payload) => {
|
|
if (payload.processor_id === id) {
|
|
websocket.off('processor_state', stateHandler);
|
|
|
|
const processorData = this.prepareDownloadDataFromState(id, payload);
|
|
if (processorData) {
|
|
downloadJSON(processorData, `${baseFilename}_data.json`);
|
|
}
|
|
|
|
this.notifications?.show?.({
|
|
type: 'success',
|
|
title: 'Скачивание завершено',
|
|
message: `Скачаны график и данные ${formatProcessorName(id)}`
|
|
});
|
|
}
|
|
};
|
|
|
|
websocket.on('processor_state', stateHandler);
|
|
websocket.getProcessorState(id);
|
|
|
|
// Timeout fallback in case server doesn't respond
|
|
setTimeout(() => {
|
|
websocket.off('processor_state', stateHandler);
|
|
}, 10000);
|
|
} else {
|
|
console.warn('WebSocket not available, downloading limited data');
|
|
const processorData = this.prepareDownloadDataFallback(id);
|
|
if (processorData) {
|
|
downloadJSON(processorData, `${baseFilename}_data.json`);
|
|
}
|
|
|
|
this.notifications?.show?.({
|
|
type: 'warning',
|
|
title: 'Скачивание завершено',
|
|
message: `График скачан. Данные ограничены (нет подключения к серверу)`
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.error('Chart download failed:', e);
|
|
this.notifications?.show?.({
|
|
type: 'error',
|
|
title: 'Ошибка скачивания',
|
|
message: 'Не удалось скачать данные графика'
|
|
});
|
|
}
|
|
}
|
|
|
|
prepareDownloadDataFromState(processorId, statePayload) {
|
|
if (!statePayload || !statePayload.state) return null;
|
|
|
|
const { state, current_data } = statePayload;
|
|
|
|
return {
|
|
processor_info: {
|
|
processor_id: processorId,
|
|
processor_name: formatProcessorName(processorId),
|
|
download_timestamp: new Date().toISOString()
|
|
},
|
|
state: {
|
|
config: state.config,
|
|
history_count: state.history_count,
|
|
max_history: state.max_history
|
|
},
|
|
current_data: current_data ? {
|
|
data: safeClone(current_data.data),
|
|
plotly_config: safeClone(current_data.plotly_config),
|
|
timestamp: current_data.timestamp
|
|
} : null,
|
|
sweep_history: state.sweep_history || [],
|
|
active_reference: state.active_reference || null
|
|
};
|
|
}
|
|
|
|
prepareDownloadDataFallback(processorId) {
|
|
const chart = this.charts.get(processorId);
|
|
const latestData = this.chartData.get(processorId);
|
|
|
|
if (!chart || !latestData) return null;
|
|
|
|
// Fallback when WebSocket is not available - limited data only
|
|
return {
|
|
processor_info: {
|
|
processor_id: processorId,
|
|
processor_name: formatProcessorName(processorId),
|
|
download_timestamp: new Date().toISOString()
|
|
},
|
|
current_data: {
|
|
metadata: safeClone(latestData.metadata),
|
|
timestamp: latestData.timestamp instanceof Date ? latestData.timestamp.toISOString() : latestData.timestamp,
|
|
plotly_config: safeClone(latestData.plotly_config)
|
|
},
|
|
note: "Limited data - sweep history not available without server connection"
|
|
};
|
|
}
|
|
|
|
async toggleFullscreen(id) {
|
|
const c = this.charts.get(id);
|
|
if (!c?.element) return;
|
|
await togglePlotlyFullscreen(c.element, c.plotContainer);
|
|
}
|
|
|
|
async exportSweeps(id) {
|
|
const c = this.charts.get(id);
|
|
if (!c?.plotContainer) return;
|
|
|
|
try {
|
|
// Request full processor state from backend via WebSocket
|
|
const websocket = window.vnaDashboard?.websocket;
|
|
if (!websocket || !websocket.getProcessorState) {
|
|
this.notifications?.show?.({
|
|
type: 'error',
|
|
title: 'Ошибка экспорта',
|
|
message: 'WebSocket не доступен'
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Set up one-time listener for processor_state response
|
|
const stateHandler = async (payload) => {
|
|
if (payload.processor_id === id) {
|
|
websocket.off('processor_state', stateHandler);
|
|
|
|
try {
|
|
await this.performSweepExport(id, payload);
|
|
} catch (e) {
|
|
console.error('Sweep export failed:', e);
|
|
this.notifications?.show?.({
|
|
type: 'error',
|
|
title: 'Ошибка экспорта',
|
|
message: `Не удалось экспортировать свипы: ${e.message}`
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
websocket.on('processor_state', stateHandler);
|
|
websocket.getProcessorState(id);
|
|
|
|
// Timeout fallback
|
|
setTimeout(() => {
|
|
websocket.off('processor_state', stateHandler);
|
|
}, 10000);
|
|
|
|
} catch (e) {
|
|
console.error('Export sweeps failed:', e);
|
|
this.notifications?.show?.({
|
|
type: 'error',
|
|
title: 'Ошибка экспорта',
|
|
message: 'Не удалось экспортировать данные свипов'
|
|
});
|
|
}
|
|
}
|
|
|
|
async performSweepExport(processorId, statePayload) {
|
|
if (!statePayload || !statePayload.state) {
|
|
throw new Error('Нет данных состояния процессора');
|
|
}
|
|
|
|
const { state } = statePayload;
|
|
const sweepHistory = state.sweep_history || [];
|
|
|
|
console.log('performSweepExport: sweepHistory length:', sweepHistory.length);
|
|
|
|
if (sweepHistory.length === 0) {
|
|
throw new Error('Нет данных свипов для экспорта');
|
|
}
|
|
|
|
// Get latest sweep (most recent)
|
|
const latestSweep = sweepHistory[sweepHistory.length - 1];
|
|
console.log('performSweepExport: latestSweep keys:', Object.keys(latestSweep));
|
|
console.log('performSweepExport: sweep_points length:', latestSweep.sweep_points?.length);
|
|
console.log('performSweepExport: calibrated_points length:', latestSweep.calibrated_points?.length);
|
|
console.log('performSweepExport: calibration_standards:', latestSweep.calibration_standards);
|
|
console.log('performSweepExport: raw_reference_points length:', latestSweep.raw_reference_points?.length);
|
|
console.log('performSweepExport: reference_points length:', latestSweep.reference_points?.length);
|
|
|
|
// Prepare filename with timestamp and preset info
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const presetMode = latestSweep.vna_config?.mode || 'unknown';
|
|
const baseFilename = `${processorId}_${presetMode}_${timestamp}`;
|
|
|
|
let exportedCount = 0;
|
|
|
|
// Export raw sweep
|
|
if (latestSweep.sweep_points && latestSweep.sweep_points.length > 0) {
|
|
console.log('Exporting raw sweep, points:', latestSweep.sweep_points.length);
|
|
this.exportPointsToTSV(latestSweep.sweep_points, latestSweep.vna_config, `${baseFilename}_raw`);
|
|
exportedCount++;
|
|
}
|
|
|
|
// Export calibrated sweep
|
|
if (latestSweep.calibrated_points && latestSweep.calibrated_points.length > 0) {
|
|
console.log('Exporting calibrated sweep, points:', latestSweep.calibrated_points.length);
|
|
this.exportPointsToTSV(latestSweep.calibrated_points, latestSweep.vna_config, `${baseFilename}_calibrated`);
|
|
exportedCount++;
|
|
}
|
|
|
|
// Export calibration standards if present
|
|
// COMMENTED OUT: Don't export calibration files (may be needed later)
|
|
// if (latestSweep.calibration_standards) {
|
|
// console.log('Exporting calibration standards:', Object.keys(latestSweep.calibration_standards));
|
|
// for (const [standardName, standardData] of Object.entries(latestSweep.calibration_standards)) {
|
|
// if (standardData && standardData.points && standardData.points.length > 0) {
|
|
// this.exportPointsToTSV(standardData.points, latestSweep.vna_config, `${baseFilename}_cal_${standardName}`);
|
|
// exportedCount++;
|
|
// }
|
|
// }
|
|
// }
|
|
|
|
// Export raw reference if present
|
|
if (latestSweep.raw_reference_points && latestSweep.raw_reference_points.length > 0) {
|
|
const refName = latestSweep.reference_info?.name || 'reference';
|
|
console.log('Exporting raw reference:', refName);
|
|
this.exportPointsToTSV(latestSweep.raw_reference_points, latestSweep.vna_config, `${baseFilename}_ref_${refName.replace(/\s/g, '_')}_raw`);
|
|
exportedCount++;
|
|
}
|
|
|
|
// Export calibrated reference if present (for comparison)
|
|
if (latestSweep.reference_points && latestSweep.reference_points.length > 0) {
|
|
const refName = latestSweep.reference_info?.name || 'reference';
|
|
console.log('Exporting calibrated reference:', refName);
|
|
this.exportPointsToTSV(latestSweep.reference_points, latestSweep.vna_config, `${baseFilename}_ref_${refName.replace(/\s/g, '_')}_calibrated`);
|
|
exportedCount++;
|
|
}
|
|
|
|
console.log('Total exported files:', exportedCount);
|
|
|
|
this.notifications?.show?.({
|
|
type: 'success',
|
|
title: 'Экспорт завершён',
|
|
message: `Данные свипов экспортированы для ${processorId}`
|
|
});
|
|
}
|
|
|
|
exportPointsToTSV(points, vnaConfig, filename) {
|
|
console.log('exportPointsToTSV called with filename:', filename);
|
|
console.log('exportPointsToTSV points length:', points?.length);
|
|
|
|
if (!points || points.length === 0) {
|
|
console.warn('No points to export');
|
|
return;
|
|
}
|
|
|
|
const numPoints = points.length;
|
|
console.log('exportPointsToTSV: numPoints =', numPoints);
|
|
|
|
// Generate frequency array
|
|
const startFreq = vnaConfig?.start_freq || 100e6;
|
|
const stopFreq = vnaConfig?.stop_freq || 8.8e9;
|
|
|
|
let frequencies;
|
|
if (numPoints === 1) {
|
|
frequencies = [startFreq];
|
|
} else {
|
|
const step = (stopFreq - startFreq) / (numPoints - 1);
|
|
frequencies = Array.from({ length: numPoints }, (_, i) => startFreq + i * step);
|
|
}
|
|
|
|
// Build TSV content
|
|
let tsv = 'Frequency(Hz)\tReal\tImaginary\n';
|
|
for (let i = 0; i < numPoints; i++) {
|
|
const point = points[i];
|
|
const freq = frequencies[i];
|
|
const real = point[0];
|
|
const imag = point[1];
|
|
tsv += `${freq}\t${real}\t${imag}\n`;
|
|
}
|
|
|
|
console.log('exportPointsToTSV: TSV size =', tsv.length, 'chars');
|
|
|
|
// Download file
|
|
const blob = new Blob([tsv], { type: 'text/tab-separated-values;charset=utf-8' });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = `${filename}.tsv`;
|
|
document.body.appendChild(link);
|
|
console.log('exportPointsToTSV: Clicking download link for', filename);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
hideEmptyState() {
|
|
if (this.emptyState) this.emptyState.classList.add('empty-state--hidden');
|
|
}
|
|
|
|
updateEmptyStateVisibility() {
|
|
if (!this.emptyState) return;
|
|
const hasVisible = Array.from(this.charts.values()).some(c => c.isVisible);
|
|
this.emptyState.classList.toggle('empty-state--hidden', hasVisible);
|
|
}
|
|
|
|
updatePerformanceStats(dt) {
|
|
this.performanceStats.updatesProcessed++;
|
|
this.performanceStats.lastUpdateTime = new Date();
|
|
const total = this.performanceStats.avgUpdateTime * (this.performanceStats.updatesProcessed - 1) + dt;
|
|
this.performanceStats.avgUpdateTime = total / this.performanceStats.updatesProcessed;
|
|
}
|
|
|
|
pause() { this.isPaused = true; console.log('Chart updates paused'); }
|
|
resume() { this.isPaused = false; console.log('Chart updates resumed'); if (this.updateQueue.size) this.processUpdateQueue(); }
|
|
|
|
getDisabledProcessors() { return Array.from(this.disabledProcessors); }
|
|
|
|
getStats() {
|
|
return {
|
|
...this.performanceStats,
|
|
totalCharts: this.charts.size,
|
|
visibleCharts: Array.from(this.charts.values()).filter(c => c.isVisible).length,
|
|
disabledProcessors: this.disabledProcessors.size,
|
|
queuedUpdates: this.updateQueue.size,
|
|
isPaused: this.isPaused
|
|
};
|
|
}
|
|
|
|
uploadHistory(processorId) {
|
|
const chart = this.charts.get(processorId);
|
|
if (!chart) return;
|
|
|
|
const fileInput = chart.element.querySelector(`#historyFileInput_${processorId}`);
|
|
if (fileInput) {
|
|
fileInput.click();
|
|
}
|
|
}
|
|
|
|
appendHistory(processorId) {
|
|
const chart = this.charts.get(processorId);
|
|
if (!chart) return;
|
|
|
|
const fileInput = chart.element.querySelector(`#appendFileInput_${processorId}`);
|
|
if (fileInput) {
|
|
fileInput.click();
|
|
}
|
|
}
|
|
|
|
async handleHistoryUpload(processorId, event) {
|
|
const file = event.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
try {
|
|
const text = await file.text();
|
|
const jsonData = JSON.parse(text);
|
|
|
|
// Extract sweep_history and processor config from the saved JSON file
|
|
const sweepHistory = jsonData.sweep_history || [];
|
|
const processorConfig = jsonData.state?.config || null;
|
|
|
|
if (!sweepHistory || sweepHistory.length === 0) {
|
|
this.notifications?.show?.({
|
|
type: 'error',
|
|
title: 'Ошибка загрузки',
|
|
message: 'Файл не содержит истории свипов'
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Send load_history message via WebSocket
|
|
const websocket = window.vnaDashboard?.websocket;
|
|
if (websocket && websocket.ws && websocket.ws.readyState === WebSocket.OPEN) {
|
|
websocket.ws.send(JSON.stringify({
|
|
type: 'load_history',
|
|
processor_id: processorId,
|
|
history_data: sweepHistory,
|
|
config: processorConfig
|
|
}));
|
|
|
|
this.notifications?.show?.({
|
|
type: 'success',
|
|
title: 'История загружена',
|
|
message: `Загружено ${sweepHistory.length} записей для ${formatProcessorName(processorId)}`
|
|
});
|
|
} else {
|
|
this.notifications?.show?.({
|
|
type: 'error',
|
|
title: 'Ошибка подключения',
|
|
message: 'WebSocket не подключен'
|
|
});
|
|
}
|
|
|
|
} catch (err) {
|
|
console.error('Error loading history:', err);
|
|
this.notifications?.show?.({
|
|
type: 'error',
|
|
title: 'Ошибка загрузки',
|
|
message: `Не удалось прочитать файл: ${err.message}`
|
|
});
|
|
}
|
|
|
|
// Reset file input
|
|
event.target.value = '';
|
|
}
|
|
|
|
async handleHistoryAppend(processorId, event) {
|
|
const file = event.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
try {
|
|
const text = await file.text();
|
|
const jsonData = JSON.parse(text);
|
|
|
|
// Extract sweep_history from the saved JSON file
|
|
// Note: We do NOT use the config - only append history
|
|
const sweepHistory = jsonData.sweep_history || [];
|
|
|
|
if (!sweepHistory || sweepHistory.length === 0) {
|
|
this.notifications?.show?.({
|
|
type: 'error',
|
|
title: 'Ошибка дополнения',
|
|
message: 'Файл не содержит истории свипов'
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Send append_history message via WebSocket
|
|
const websocket = window.vnaDashboard?.websocket;
|
|
if (websocket && websocket.ws && websocket.ws.readyState === WebSocket.OPEN) {
|
|
websocket.ws.send(JSON.stringify({
|
|
type: 'append_history',
|
|
processor_id: processorId,
|
|
history_data: sweepHistory
|
|
}));
|
|
|
|
this.notifications?.show?.({
|
|
type: 'success',
|
|
title: 'История дополнена',
|
|
message: `Добавлено ${sweepHistory.length} записей к истории ${formatProcessorName(processorId)}`
|
|
});
|
|
} else {
|
|
this.notifications?.show?.({
|
|
type: 'error',
|
|
title: 'Ошибка подключения',
|
|
message: 'WebSocket не подключен'
|
|
});
|
|
}
|
|
|
|
} catch (err) {
|
|
console.error('Error appending history:', err);
|
|
this.notifications?.show?.({
|
|
type: 'error',
|
|
title: 'Ошибка дополнения',
|
|
message: `Не удалось прочитать файл: ${err.message}`
|
|
});
|
|
}
|
|
|
|
// Reset file input
|
|
event.target.value = '';
|
|
}
|
|
|
|
destroy() {
|
|
console.log('Cleaning up Chart Manager...');
|
|
this.clearAll();
|
|
this.settingsManager.destroy();
|
|
this.bscanClickHandler.destroy();
|
|
this.updateQueue.clear();
|
|
this.isUpdating = false;
|
|
this.isPaused = true;
|
|
console.log('Chart Manager cleanup complete');
|
|
}
|
|
}
|