950 lines
36 KiB
JavaScript
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');
|
|
}
|
|
}
|