524 lines
19 KiB
JavaScript
524 lines
19 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 {
|
|
defaultPlotlyLayout,
|
|
defaultPlotlyConfig,
|
|
createPlotlyPlot,
|
|
updatePlotlyPlot,
|
|
togglePlotlyFullscreen,
|
|
downloadPlotlyImage,
|
|
cleanupPlotly
|
|
} from './plotly-utils.js';
|
|
|
|
export class ChartManager {
|
|
constructor(config, notifications) {
|
|
this.config = config;
|
|
this.notifications = notifications;
|
|
|
|
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();
|
|
}
|
|
|
|
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
|
|
};
|
|
|
|
createPlotlyPlot(plotContainer, [], layoutOverrides);
|
|
|
|
this.charts.set(processorId, { element: card, plotContainer, isVisible: true, settingsInitialized: false });
|
|
this.performanceStats.chartsCreated++;
|
|
|
|
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' } }
|
|
};
|
|
|
|
await updatePlotlyPlot(chart.plotContainer, plotlyConfig.data || [], layoutOverrides);
|
|
|
|
this.updateChartMetadata(processorId);
|
|
|
|
if (!chart.settingsInitialized) {
|
|
this.updateChartSettings(processorId);
|
|
chart.settingsInitialized = true;
|
|
} else {
|
|
this.updateChartSettings(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="download" title="Download">
|
|
<span data-icon="download"></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;">
|
|
</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>
|
|
</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 'download': this.downloadChart(processorId); break;
|
|
case 'hide':
|
|
this.hideChart(processorId);
|
|
if (window.vnaDashboard?.ui) window.vnaDashboard.ui.setProcessorEnabled(processorId, false);
|
|
break;
|
|
}
|
|
});
|
|
|
|
// Setup file input handler
|
|
const fileInput = card.querySelector(`#historyFileInput_${processorId}`);
|
|
if (fileInput) {
|
|
fileInput.addEventListener('change', async (e) => {
|
|
await this.handleHistoryUpload(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) {
|
|
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);
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
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 from the saved JSON file
|
|
const sweepHistory = jsonData.sweep_history || [];
|
|
|
|
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
|
|
}));
|
|
|
|
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 = '';
|
|
}
|
|
|
|
destroy() {
|
|
console.log('Cleaning up Chart Manager...');
|
|
this.clearAll();
|
|
this.settingsManager.destroy();
|
|
this.updateQueue.clear();
|
|
this.isUpdating = false;
|
|
this.isPaused = true;
|
|
console.log('Chart Manager cleanup complete');
|
|
}
|
|
}
|