Files
vna_system/vna_system/web_ui/static/js/modules/charts.js
2025-09-26 13:00:05 +03:00

950 lines
36 KiB
JavaScript

/**
* Chart Manager
* Handles Plotly.js chart creation, updates, and management
*/
export class ChartManager {
constructor(config, notifications) {
this.config = config;
this.notifications = notifications;
this.charts = new Map(); // id -> { element, plotContainer, isVisible }
this.chartData = new Map(); // id -> [{ timestamp, metadata, data, plotly_config }]
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
};
// Debounce timers for settings updates
this.settingDebounceTimers = {};
// Track last setting values to prevent feedback loops
this.lastSettingValues = {};
this.plotlyConfig = {
displayModeBar: true,
modeBarButtonsToRemove: ['select2d','lasso2d','hoverClosestCartesian','hoverCompareCartesian','toggleSpikelines'],
displaylogo: false,
responsive: false,
doubleClick: 'reset',
toImageButtonOptions: { format: 'png', filename: 'vna_chart', height: 600, width: 800, scale: 1 }
};
this.plotlyLayout = {
plot_bgcolor: 'transparent',
paper_bgcolor: 'transparent',
font: { family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif', size: 12, color: '#f1f5f9' },
colorway: ['#3b82f6','#22c55e','#eab308','#ef4444','#8b5cf6','#06b6d4','#f59e0b','#10b981'],
margin: { l: 60, r: 50, t: 50, b: 60 }, // Increased right and top margins
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: { gridcolor: '#334155', zerolinecolor: '#475569', color: '#cbd5e1', fixedrange: false },
yaxis: { gridcolor: '#334155', zerolinecolor: '#475569', color: '#cbd5e1', fixedrange: false },
autosize: true, width: null, height: null
};
}
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');
}
/** Новый входной формат — прямо payload от WS: processor_result */
addResult(payload) {
try {
const { processor_id, timestamp, plotly_config, metadata, data } = payload;
if (!processor_id) {
console.warn('⚠️ Invalid result - missing processor_id:', payload);
return;
}
if (this.disabledProcessors.has(processor_id)) {
console.log(`⏸️ Skipping disabled processor: ${processor_id}`);
return;
}
// Store only the latest data (no history needed)
this.chartData.set(processor_id, {
timestamp: new Date((timestamp ?? Date.now()) * 1000), // если приходит epoch seconds
metadata: metadata || {},
data: data || {},
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: 'Chart Error', message: `Failed to update chart`
});
}
}
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 layout = {
...this.plotlyLayout,
title: { text: this.formatProcessorName(processorId), font: { size: 16, color: '#f1f5f9' } },
width: plotContainer.clientWidth || 500,
height: plotContainer.clientHeight || 420
};
Plotly.newPlot(plotContainer, [], layout, this.plotlyConfig);
if (window.ResizeObserver) {
const ro = new ResizeObserver(() => {
if (plotContainer && plotContainer.clientWidth > 0) Plotly.Plots.resize(plotContainer);
});
ro.observe(plotContainer);
plotContainer._resizeObserver = ro;
}
this.charts.set(processorId, { element: card, plotContainer, isVisible: true });
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 updateLayout = {
...this.plotlyLayout,
...(plotlyConfig.layout || {}),
title: { text: this.formatProcessorName(processorId), font: { size: 16, color: '#f1f5f9' } }
};
delete updateLayout.width;
delete updateLayout.height;
await Plotly.react(chart.plotContainer, plotlyConfig.data || [], updateLayout, this.plotlyConfig);
this.updateChartMetadata(processorId);
// Only update settings if chart is newly created or if parameters actually changed
if (!chart.settingsInitialized) {
this.updateChartSettings(processorId);
chart.settingsInitialized = true;
} else {
// Check if settings need updating without forcing it
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">
<i data-lucide="bar-chart-3" class="chart-card__icon"></i>
${this.formatProcessorName(processorId)}
</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>
<button class="chart-card__action" data-action="hide" title="Hide">
<i data-lucide="eye-off"></i>
</button>
</div>
</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="">Info: --</div>
</div>
`;
this.setupChartCardEvents(card, processorId);
// Initialize settings immediately
this.updateChartSettings(processorId);
if (typeof lucide !== 'undefined') lucide.createIcons({ attrs: { 'stroke-width': 1.5 } });
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 'download': this.downloadChart(processorId); break;
case 'hide':
this.hideChart(processorId);
if (window.vnaDashboard?.ui) window.vnaDashboard.ui.setProcessorEnabled(processorId, false);
break;
}
});
}
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]');
const infoEl = chart.element.querySelector('[data-sweep]');
if (tsEl) {
const dt = latestData.timestamp instanceof Date ? latestData.timestamp : new Date();
tsEl.textContent = `Last update: ${dt.toLocaleTimeString()}`;
tsEl.dataset.timestamp = dt.toISOString();
}
if (infoEl) {
// Hide history count since we no longer track it
infoEl.textContent = '';
}
}
formatProcessorName(n) {
return n.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
}
/**
* Update chart settings with current UI parameters
*/
updateChartSettings(processorId) {
const chart = this.charts.get(processorId);
const settingsContainer = chart?.element?.querySelector('.chart-settings__controls');
if (!settingsContainer) return;
// Get UI parameters from the latest chart data metadata
const latestData = this.chartData.get(processorId);
const uiParameters = latestData?.metadata?.ui_parameters;
// Store current parameters to avoid unnecessary updates
if (!chart.lastUiParameters) chart.lastUiParameters = null;
// Check if parameters have actually changed
const parametersChanged = !chart.lastUiParameters ||
JSON.stringify(uiParameters) !== JSON.stringify(chart.lastUiParameters);
if (!parametersChanged) {
console.log(`⚪ No parameter changes for ${processorId}, skipping settings update`);
return; // No need to update if parameters haven't changed
}
console.log(`🔄 Updating settings for ${processorId}:`, {
old: chart.lastUiParameters,
new: uiParameters
});
chart.lastUiParameters = uiParameters ? JSON.parse(JSON.stringify(uiParameters)) : null;
// Fallback to UI manager if no data available
if (!uiParameters || !Array.isArray(uiParameters) || uiParameters.length === 0) {
const uiManager = window.vnaDashboard?.ui;
const processor = uiManager?.processors?.get(processorId);
if (processor?.uiParameters && Array.isArray(processor.uiParameters) && processor.uiParameters.length > 0) {
const settingsHtml = processor.uiParameters.map(param =>
this.createSettingControl(param, processorId)
).join('');
settingsContainer.innerHTML = settingsHtml;
this.setupSettingsEvents(settingsContainer, processorId);
return;
}
settingsContainer.innerHTML = '<div class="settings-empty">No settings available</div>';
return;
}
// Generate settings HTML from chart data
const settingsHtml = uiParameters.map(param =>
this.createSettingControl(param, processorId)
).join('');
settingsContainer.innerHTML = settingsHtml;
// Add event listeners
this.setupSettingsEvents(settingsContainer, processorId);
// Initialize last values from current UI state to prevent immediate updates
if (uiParameters) {
uiParameters.forEach(param => {
const settingKey = `${processorId}_${param.name}`;
this.lastSettingValues[settingKey] = param.value;
console.log(`💾 Initialized setting value: ${settingKey} = ${param.value}`);
});
}
if (typeof lucide !== 'undefined') {
lucide.createIcons({ attrs: { 'stroke-width': 1.5 } });
}
}
/**
* Create setting control HTML
*/
createSettingControl(param, processorId) {
const paramId = `chart_${processorId}_${param.name}`;
const value = param.value;
const opts = param.options || {};
switch (param.type) {
case 'slider':
case 'range':
return `
<div class="chart-setting" data-param="${param.name}">
<label class="chart-setting__label">
${param.label}
<span class="chart-setting__value">${value}</span>
</label>
<input
type="range"
class="chart-setting__slider"
id="${paramId}"
min="${opts.min ?? 0}"
max="${opts.max ?? 100}"
step="${opts.step ?? 1}"
value="${value}"
>
</div>
`;
case 'toggle':
case 'boolean':
return `
<div class="chart-setting" data-param="${param.name}">
<label class="chart-setting__toggle">
<input type="checkbox" id="${paramId}" ${value ? 'checked' : ''}>
<span class="chart-setting__toggle-slider"></span>
<span class="chart-setting__label">${param.label}</span>
</label>
</div>
`;
case 'select':
case 'dropdown': {
let choices = [];
if (Array.isArray(opts)) choices = opts;
else if (Array.isArray(opts.choices)) choices = opts.choices;
const optionsHtml = choices.map(option =>
`<option value="${option}" ${option === value ? 'selected' : ''}>${option}</option>`
).join('');
return `
<div class="chart-setting" data-param="${param.name}">
<label class="chart-setting__label">${param.label}</label>
<select class="chart-setting__select" id="${paramId}">
${optionsHtml}
</select>
</div>
`;
}
case 'button':
const buttonText = param.label || 'Click';
const actionDesc = opts.action ? `title="${opts.action}"` : '';
return `
<div class="chart-setting" data-param="${param.name}">
<button
type="button"
class="chart-setting__button"
id="${paramId}"
data-processor="${processorId}"
data-param="${param.name}"
${actionDesc}
>
${buttonText}
</button>
</div>
`;
default:
return `
<div class="chart-setting" data-param="${param.name}">
<label class="chart-setting__label">${param.label}</label>
<input
type="text"
class="chart-setting__input"
id="${paramId}"
value="${value ?? ''}"
>
</div>
`;
}
}
/**
* Setup event listeners for settings
*/
setupSettingsEvents(settingsContainer, processorId) {
const onParamChange = (e) => {
if (!e.target.closest('.chart-setting')) return;
this.handleSettingChange(e, processorId);
};
const onButtonClick = (e) => {
if (!e.target.classList.contains('chart-setting__button')) return;
this.handleButtonClick(e, processorId);
};
settingsContainer.addEventListener('input', onParamChange);
settingsContainer.addEventListener('change', onParamChange);
settingsContainer.addEventListener('click', onButtonClick);
}
/**
* Handle setting parameter change
*/
handleSettingChange(event, processorId) {
const settingElement = event.target.closest('.chart-setting');
if (!settingElement) return;
const paramName = settingElement.dataset.param;
const input = event.target;
let value;
if (input.type === 'checkbox') {
value = input.checked;
} else if (input.type === 'range') {
value = parseFloat(input.value);
// Update display value
const valueDisplay = settingElement.querySelector('.chart-setting__value');
if (valueDisplay) valueDisplay.textContent = value;
} else {
value = input.value;
}
// Normalize boolean values to prevent comparison issues
if (typeof value === 'string' && (value === 'true' || value === 'false')) {
value = value === 'true';
}
console.log(`🔧 Chart setting changed: ${processorId}.${paramName} = ${value}`);
// Store the current setting value to prevent loops
if (!this.lastSettingValues) this.lastSettingValues = {};
const settingKey = `${processorId}_${paramName}`;
// Normalize saved value for comparison
let lastValue = this.lastSettingValues[settingKey];
if (typeof lastValue === 'string' && (lastValue === 'true' || lastValue === 'false')) {
lastValue = lastValue === 'true';
}
// Check if this is the same value we just set to prevent feedback loop
if (lastValue === value) {
console.log(`🔄 Skipping duplicate setting: ${settingKey} = ${value} (was ${this.lastSettingValues[settingKey]})`);
return;
}
this.lastSettingValues[settingKey] = value;
// Debounce setting updates to prevent rapid firing
const debounceKey = `${processorId}_${paramName}`;
if (this.settingDebounceTimers) {
clearTimeout(this.settingDebounceTimers[debounceKey]);
} else {
this.settingDebounceTimers = {};
}
this.settingDebounceTimers[debounceKey] = setTimeout(() => {
console.log(`📤 Sending setting update: ${processorId}.${paramName} = ${value}`);
// Send update via WebSocket
const websocket = window.vnaDashboard?.websocket;
if (websocket && websocket.recalculate) {
websocket.recalculate(processorId, { [paramName]: value });
} else {
console.warn('⚠️ WebSocket not available for settings update');
}
delete this.settingDebounceTimers[debounceKey];
}, 300); // 300ms delay to prevent rapid updates
}
/**
* Handle button click in settings
*/
handleButtonClick(event, processorId) {
const button = event.target;
const paramName = button.dataset.param;
if (!paramName) {
console.warn('⚠️ Button missing param data:', button);
return;
}
// Prevent multiple clicks while processing
if (button.disabled) {
console.log('🔘 Button already processing, ignoring click');
return;
}
console.log(`🔘 Button clicked: ${processorId}.${paramName}`);
// Temporarily disable button and show feedback
const originalText = button.textContent;
button.disabled = true;
button.textContent = '...';
// Send button action via WebSocket (set to true to trigger action) - only once
const websocket = window.vnaDashboard?.websocket;
if (websocket && websocket.recalculate) {
console.log(`📤 Sending button action: ${processorId}.${paramName} = true`);
websocket.recalculate(processorId, { [paramName]: true });
} else {
console.warn('⚠️ WebSocket not available for button action');
}
// Re-enable button after a short delay
setTimeout(() => {
button.disabled = false;
button.textContent = originalText;
}, 1000);
}
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) {
if (c.plotContainer?._resizeObserver) { c.plotContainer._resizeObserver.disconnect(); c.plotContainer._resizeObserver = null; }
if (c.plotContainer) Plotly.purge(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;
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const baseFilename = `${id}_${timestamp}`;
// Download image
await Plotly.downloadImage(c.plotContainer, {
format: 'png',
width: 1200,
height: 800,
filename: `${baseFilename}_plot`
});
// Prepare and download processor data
const processorData = this.prepareProcessorDownloadData(id);
if (processorData) {
this.downloadJSON(processorData, `${baseFilename}_data.json`);
}
this.notifications?.show?.({
type: 'success',
title: 'Download Complete',
message: `Downloaded ${this.formatProcessorName(id)} plot and data`
});
} catch (e) {
console.error('❌ Chart download failed:', e);
this.notifications?.show?.({
type: 'error',
title: 'Download Failed',
message: 'Failed to download chart data'
});
}
}
prepareProcessorDownloadData(processorId) {
const chart = this.charts.get(processorId);
const latestData = this.chartData.get(processorId);
if (!chart || !latestData) return null;
// Safe copy function to avoid circular references
const safeClone = (obj, seen = new WeakSet()) => {
if (obj === null || typeof obj !== 'object') return obj;
if (seen.has(obj)) return '[Circular Reference]';
seen.add(obj);
if (Array.isArray(obj)) {
return obj.map(item => safeClone(item, seen));
}
const cloned = {};
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
try {
cloned[key] = safeClone(obj[key], seen);
} catch (e) {
cloned[key] = `[Error: ${e.message}]`;
}
}
}
return cloned;
};
return {
processor_info: {
processor_id: processorId,
processor_name: this.formatProcessorName(processorId),
download_timestamp: new Date().toISOString(),
is_visible: chart.isVisible
},
current_data: {
data: safeClone(latestData.data),
metadata: safeClone(latestData.metadata),
timestamp: latestData.timestamp instanceof Date ? latestData.timestamp.toISOString() : latestData.timestamp,
plotly_config: safeClone(latestData.plotly_config)
},
plot_config: this.getCurrentPlotlyDataSafe(processorId),
ui_parameters: safeClone(this.getProcessorSettings(processorId)),
raw_sweep_data: this.extractProcessorRawData(latestData),
metadata: {
description: `VNA processor data export - ${this.formatProcessorName(processorId)}`,
format_version: "1.0",
exported_by: "VNA System Dashboard",
export_type: "processor_data",
contains: [
"Current processor data and metadata",
"Plot configuration (Plotly format)",
"UI parameter settings",
"Raw measurement data if available",
"Processing results and statistics"
]
}
};
}
extractProcessorRawData(latestData) {
// Extract raw data from processor results
if (!latestData || !latestData.data) return null;
try {
const rawData = {
processor_results: this.safeStringify(latestData.data),
metadata: this.safeStringify(latestData.metadata)
};
// If this is magnitude processor, extract frequency/magnitude data
if (latestData.plotly_config && latestData.plotly_config.data) {
const plotData = latestData.plotly_config.data[0];
if (plotData && plotData.x && plotData.y) {
rawData.processed_measurements = [];
const maxPoints = Math.min(plotData.x.length, plotData.y.length, 1000); // Limit to 1000 points
for (let i = 0; i < maxPoints; i++) {
rawData.processed_measurements.push({
point_index: i,
x_value: plotData.x[i],
y_value: plotData.y[i],
x_unit: this.getAxisUnit(plotData, 'x'),
y_unit: this.getAxisUnit(plotData, 'y')
});
}
}
}
return rawData;
} catch (error) {
console.warn('⚠️ Error extracting processor raw data:', error);
return { error: 'Failed to extract raw data' };
}
}
safeStringify(obj) {
try {
// Simple objects - try direct JSON
return JSON.parse(JSON.stringify(obj));
} catch (e) {
// If that fails, create a safe representation
if (obj === null || typeof obj !== 'object') return obj;
if (Array.isArray(obj)) return obj.slice(0, 100); // Limit array size
const safe = {};
Object.keys(obj).forEach(key => {
try {
if (typeof obj[key] === 'function') {
safe[key] = '[Function]';
} else if (typeof obj[key] === 'object') {
safe[key] = '[Object]';
} else {
safe[key] = obj[key];
}
} catch (err) {
safe[key] = '[Error accessing property]';
}
});
return safe;
}
}
getAxisUnit(plotData, axis) {
// Try to determine units from plot data or layout
if (axis === 'x') {
// For frequency data, usually GHz
return plotData.name && plotData.name.includes('Frequency') ? 'GHz' : 'unknown';
} else if (axis === 'y') {
// For magnitude data, usually dB
return plotData.name && plotData.name.includes('Magnitude') ? 'dB' : 'unknown';
}
return 'unknown';
}
downloadJSON(data, filename) {
const jsonString = JSON.stringify(data, null, 2);
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up the URL object
URL.revokeObjectURL(url);
}
toggleFullscreen(id) {
const c = this.charts.get(id);
if (!c?.element) return;
if (!document.fullscreenElement) {
c.element.requestFullscreen()?.then(() => {
setTimeout(() => {
if (c.plotContainer) {
const r = c.plotContainer.getBoundingClientRect();
Plotly.relayout(c.plotContainer, { width: r.width, height: r.height });
Plotly.Plots.resize(c.plotContainer);
}
}, 200);
}).catch(console.error);
} else {
document.exitFullscreen()?.then(() => {
setTimeout(() => c.plotContainer && Plotly.Plots.resize(c.plotContainer), 100);
});
}
}
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(); }
/**
* Get current Plotly data/layout for a processor
*/
getCurrentPlotlyData(processorId) {
const chart = this.charts.get(processorId);
if (!chart?.plotContainer?._fullData || !chart?.plotContainer?._fullLayout) {
return null;
}
try {
return {
data: chart.plotContainer._fullData,
layout: {
title: chart.plotContainer._fullLayout.title,
xaxis: chart.plotContainer._fullLayout.xaxis,
yaxis: chart.plotContainer._fullLayout.yaxis,
showlegend: chart.plotContainer._fullLayout.showlegend,
legend: chart.plotContainer._fullLayout.legend
}
};
} catch (error) {
console.warn(`⚠️ Could not extract Plotly data for ${processorId}:`, error);
return null;
}
}
/**
* Safe version of getCurrentPlotlyData that avoids circular references
*/
getCurrentPlotlyDataSafe(processorId) {
const chart = this.charts.get(processorId);
if (!chart?.plotContainer?._fullData || !chart?.plotContainer?._fullLayout) {
return null;
}
try {
// Extract only essential plot data to avoid circular references
const data = [];
if (chart.plotContainer._fullData) {
chart.plotContainer._fullData.forEach(trace => {
data.push({
x: trace.x ? Array.from(trace.x) : null,
y: trace.y ? Array.from(trace.y) : null,
type: trace.type,
mode: trace.mode,
name: trace.name,
line: trace.line ? {
color: trace.line.color,
width: trace.line.width
} : null,
marker: trace.marker ? {
color: trace.marker.color,
size: trace.marker.size
} : null
});
});
}
const layout = {};
if (chart.plotContainer._fullLayout) {
const fullLayout = chart.plotContainer._fullLayout;
layout.title = typeof fullLayout.title === 'string' ? fullLayout.title : fullLayout.title?.text;
layout.xaxis = {
title: fullLayout.xaxis?.title,
range: fullLayout.xaxis?.range
};
layout.yaxis = {
title: fullLayout.yaxis?.title,
range: fullLayout.yaxis?.range
};
layout.showlegend = fullLayout.showlegend;
}
return { data, layout };
} catch (error) {
console.warn(`⚠️ Could not extract safe Plotly data for ${processorId}:`, error);
return null;
}
}
/**
* Get current processor settings (UI parameters)
*/
getProcessorSettings(processorId) {
const latestData = this.chartData.get(processorId);
if (!latestData) {
return null;
}
return latestData.metadata?.ui_parameters || null;
}
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
};
}
destroy() {
console.log('🧹 Cleaning up Chart Manager...');
this.clearAll();
this.updateQueue.clear();
this.isUpdating = false;
this.isPaused = true;
console.log('✅ Chart Manager cleanup complete');
}
}