refactoring step 3
This commit is contained in:
@ -3,15 +3,16 @@
|
||||
* Handles Plotly.js chart creation, updates, and management
|
||||
*/
|
||||
|
||||
import { formatProcessorName, createParameterControl, safeClone, downloadJSON } from './utils.js';
|
||||
import { formatProcessorName, safeClone, downloadJSON } from './utils.js';
|
||||
import { ChartSettingsManager } from './charts/chart-settings.js';
|
||||
|
||||
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.charts = new Map();
|
||||
this.chartData = new Map();
|
||||
this.disabledProcessors = new Set();
|
||||
|
||||
this.chartsGrid = null;
|
||||
@ -28,11 +29,7 @@ export class ChartManager {
|
||||
lastUpdateTime: null
|
||||
};
|
||||
|
||||
// Debounce timers for settings updates
|
||||
this.settingDebounceTimers = {};
|
||||
|
||||
// Track last setting values to prevent feedback loops
|
||||
this.lastSettingValues = {};
|
||||
this.settingsManager = new ChartSettingsManager();
|
||||
|
||||
this.plotlyConfig = {
|
||||
displayModeBar: true,
|
||||
@ -48,7 +45,7 @@ export class ChartManager {
|
||||
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
|
||||
margin: { l: 60, r: 50, t: 50, b: 60 },
|
||||
showlegend: true,
|
||||
legend: {
|
||||
orientation: 'v',
|
||||
@ -76,7 +73,6 @@ export class ChartManager {
|
||||
console.log('Chart Manager initialized');
|
||||
}
|
||||
|
||||
/** New input format - direct payload from WS: processor_result */
|
||||
addResult(payload) {
|
||||
try {
|
||||
const { processor_id, timestamp, plotly_config, metadata, data } = payload;
|
||||
@ -86,13 +82,11 @@ export class ChartManager {
|
||||
}
|
||||
|
||||
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), // if timestamp is in epoch seconds
|
||||
timestamp: new Date((timestamp ?? Date.now()) * 1000),
|
||||
metadata: metadata || {},
|
||||
data: data || {},
|
||||
plotly_config: plotly_config || { data: [], layout: {} }
|
||||
@ -111,7 +105,7 @@ export class ChartManager {
|
||||
}
|
||||
|
||||
createChart(processorId) {
|
||||
console.log(` Creating chart for processor: ${processorId}`);
|
||||
console.log(`Creating chart for processor: ${processorId}`);
|
||||
const card = this.createChartCard(processorId);
|
||||
this.chartsGrid.appendChild(card);
|
||||
|
||||
@ -132,7 +126,7 @@ export class ChartManager {
|
||||
plotContainer._resizeObserver = ro;
|
||||
}
|
||||
|
||||
this.charts.set(processorId, { element: card, plotContainer, isVisible: true });
|
||||
this.charts.set(processorId, { element: card, plotContainer, isVisible: true, settingsInitialized: false });
|
||||
this.performanceStats.chartsCreated++;
|
||||
|
||||
if (this.config.animation) {
|
||||
@ -144,7 +138,10 @@ export class ChartManager {
|
||||
if (this.isPaused) return;
|
||||
|
||||
const chart = this.charts.get(processorId);
|
||||
if (!chart?.plotContainer) { console.warn(` Chart not found for processor: ${processorId}`); return; }
|
||||
if (!chart?.plotContainer) {
|
||||
console.warn(`Chart not found for processor: ${processorId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const start = performance.now();
|
||||
@ -160,19 +157,19 @@ export class ChartManager {
|
||||
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);
|
||||
console.error(`Error updating chart ${processorId}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
@ -187,7 +184,7 @@ export class ChartManager {
|
||||
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); }
|
||||
try { await fn(); } catch (e) { console.error(`Error in queued update for ${id}:`, e); }
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
}
|
||||
this.isUpdating = false;
|
||||
@ -229,13 +226,11 @@ export class ChartManager {
|
||||
</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 class="chart-card__sweep" data-sweep=""></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.setupChartCardEvents(card, processorId);
|
||||
|
||||
// Initialize settings immediately
|
||||
this.updateChartSettings(processorId);
|
||||
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons({ attrs: { 'stroke-width': 1.5 } });
|
||||
@ -262,223 +257,27 @@ export class ChartManager {
|
||||
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]');
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
if (infoEl) {
|
||||
// Hide history count since we no longer track it
|
||||
infoEl.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
if (settingsContainer && latestData) {
|
||||
this.settingsManager.updateSettings(processorId, settingsContainer, latestData);
|
||||
}
|
||||
|
||||
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 =>
|
||||
createParameterControl(param, processorId, 'chart')
|
||||
).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 =>
|
||||
createParameterControl(param, processorId, 'chart')
|
||||
).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 } });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
@ -488,6 +287,7 @@ export class ChartManager {
|
||||
}
|
||||
this.updateEmptyStateVisibility();
|
||||
}
|
||||
|
||||
hideChart(id) {
|
||||
const c = this.charts.get(id);
|
||||
if (c) { c.element.classList.add('chart-card--hidden'); c.isVisible = false; }
|
||||
@ -523,7 +323,6 @@ export class ChartManager {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const baseFilename = `${id}_${timestamp}`;
|
||||
|
||||
// Download image
|
||||
await Plotly.downloadImage(c.plotContainer, {
|
||||
format: 'png',
|
||||
width: 1200,
|
||||
@ -531,8 +330,7 @@ export class ChartManager {
|
||||
filename: `${baseFilename}_plot`
|
||||
});
|
||||
|
||||
// Prepare and download processor data
|
||||
const processorData = this.prepareProcessorDownloadData(id);
|
||||
const processorData = this.prepareDownloadData(id);
|
||||
if (processorData) {
|
||||
downloadJSON(processorData, `${baseFilename}_data.json`);
|
||||
}
|
||||
@ -552,7 +350,7 @@ export class ChartManager {
|
||||
}
|
||||
}
|
||||
|
||||
prepareProcessorDownloadData(processorId) {
|
||||
prepareDownloadData(processorId) {
|
||||
const chart = this.charts.get(processorId);
|
||||
const latestData = this.chartData.get(processorId);
|
||||
|
||||
@ -562,89 +360,17 @@ export class ChartManager {
|
||||
processor_info: {
|
||||
processor_id: processorId,
|
||||
processor_name: formatProcessorName(processorId),
|
||||
download_timestamp: new Date().toISOString(),
|
||||
is_visible: chart.isVisible
|
||||
download_timestamp: new Date().toISOString()
|
||||
},
|
||||
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 - ${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 {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
} catch (e) {
|
||||
return safeClone(obj);
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
toggleFullscreen(id) {
|
||||
const c = this.charts.get(id);
|
||||
if (!c?.element) return;
|
||||
@ -668,6 +394,7 @@ export class ChartManager {
|
||||
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);
|
||||
@ -684,100 +411,8 @@ export class ChartManager {
|
||||
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,
|
||||
@ -792,9 +427,10 @@ export class ChartManager {
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
174
vna_system/web_ui/static/js/modules/charts/chart-settings.js
Normal file
174
vna_system/web_ui/static/js/modules/charts/chart-settings.js
Normal file
@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Chart Settings Manager
|
||||
* Handles chart parameter controls and WebSocket communication
|
||||
*/
|
||||
|
||||
import { createParameterControl } from '../utils.js';
|
||||
|
||||
export class ChartSettingsManager {
|
||||
constructor() {
|
||||
this.lastSettingValues = {};
|
||||
this.settingDebounceTimers = {};
|
||||
this.lastUiParameters = new Map();
|
||||
}
|
||||
|
||||
updateSettings(processorId, settingsContainer, latestData) {
|
||||
if (!settingsContainer) return;
|
||||
|
||||
const uiParameters = latestData?.metadata?.ui_parameters;
|
||||
|
||||
// Check if parameters have changed
|
||||
const lastParams = this.lastUiParameters.get(processorId);
|
||||
const parametersChanged = !lastParams || JSON.stringify(uiParameters) !== JSON.stringify(lastParams);
|
||||
|
||||
if (!parametersChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Updating settings for ${processorId}`);
|
||||
this.lastUiParameters.set(processorId, 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 =>
|
||||
createParameterControl(param, processorId, 'chart')
|
||||
).join('');
|
||||
settingsContainer.innerHTML = settingsHtml;
|
||||
this.setupEvents(settingsContainer, processorId);
|
||||
return;
|
||||
}
|
||||
|
||||
settingsContainer.innerHTML = '<div class="settings-empty">No settings available</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate settings HTML
|
||||
const settingsHtml = uiParameters.map(param =>
|
||||
createParameterControl(param, processorId, 'chart')
|
||||
).join('');
|
||||
|
||||
settingsContainer.innerHTML = settingsHtml;
|
||||
this.setupEvents(settingsContainer, processorId);
|
||||
|
||||
// Initialize last values
|
||||
if (uiParameters) {
|
||||
uiParameters.forEach(param => {
|
||||
const settingKey = `${processorId}_${param.name}`;
|
||||
this.lastSettingValues[settingKey] = param.value;
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof lucide !== 'undefined') {
|
||||
lucide.createIcons({ attrs: { 'stroke-width': 1.5 } });
|
||||
}
|
||||
}
|
||||
|
||||
setupEvents(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);
|
||||
}
|
||||
|
||||
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);
|
||||
const valueDisplay = settingElement.querySelector('.chart-setting__value');
|
||||
if (valueDisplay) valueDisplay.textContent = value;
|
||||
} else {
|
||||
value = input.value;
|
||||
}
|
||||
|
||||
// Normalize boolean values
|
||||
if (typeof value === 'string' && (value === 'true' || value === 'false')) {
|
||||
value = value === 'true';
|
||||
}
|
||||
|
||||
const settingKey = `${processorId}_${paramName}`;
|
||||
let lastValue = this.lastSettingValues[settingKey];
|
||||
if (typeof lastValue === 'string' && (lastValue === 'true' || lastValue === 'false')) {
|
||||
lastValue = lastValue === 'true';
|
||||
}
|
||||
|
||||
// Check for duplicate
|
||||
if (lastValue === value) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastSettingValues[settingKey] = value;
|
||||
|
||||
// Debounce updates
|
||||
const debounceKey = `${processorId}_${paramName}`;
|
||||
if (this.settingDebounceTimers[debounceKey]) {
|
||||
clearTimeout(this.settingDebounceTimers[debounceKey]);
|
||||
}
|
||||
|
||||
this.settingDebounceTimers[debounceKey] = setTimeout(() => {
|
||||
console.log(`Sending setting update: ${processorId}.${paramName} = ${value}`);
|
||||
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);
|
||||
}
|
||||
|
||||
handleButtonClick(event, processorId) {
|
||||
const button = event.target;
|
||||
const paramName = button.dataset.param;
|
||||
|
||||
if (!paramName || button.disabled) return;
|
||||
|
||||
console.log(`Button clicked: ${processorId}.${paramName}`);
|
||||
|
||||
const originalText = button.textContent;
|
||||
button.disabled = true;
|
||||
button.textContent = '...';
|
||||
|
||||
const websocket = window.vnaDashboard?.websocket;
|
||||
if (websocket && websocket.recalculate) {
|
||||
websocket.recalculate(processorId, { [paramName]: true });
|
||||
} else {
|
||||
console.warn('WebSocket not available for button action');
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
button.disabled = false;
|
||||
button.textContent = originalText;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// Clear timers
|
||||
Object.keys(this.settingDebounceTimers).forEach(key => {
|
||||
clearTimeout(this.settingDebounceTimers[key]);
|
||||
});
|
||||
this.settingDebounceTimers = {};
|
||||
this.lastSettingValues = {};
|
||||
this.lastUiParameters.clear();
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,8 @@
|
||||
* Handles toast notifications and user feedback
|
||||
*/
|
||||
|
||||
import { escapeHtml } from './utils.js';
|
||||
|
||||
export class NotificationManager {
|
||||
constructor() {
|
||||
this.container = null;
|
||||
@ -183,10 +185,10 @@ export class NotificationManager {
|
||||
const iconHtml = `<i data-lucide="${notification.config.icon}" class="notification__icon"></i>`;
|
||||
|
||||
const titleHtml = notification.title ?
|
||||
`<div class="notification__title">${this.escapeHtml(notification.title)}</div>` : '';
|
||||
`<div class="notification__title">${escapeHtml(notification.title)}</div>` : '';
|
||||
|
||||
const messageHtml = notification.message ?
|
||||
`<div class="notification__message">${this.escapeHtml(notification.message)}</div>` : '';
|
||||
`<div class="notification__message">${escapeHtml(notification.message)}</div>` : '';
|
||||
|
||||
const actionsHtml = notification.actions.length > 0 ?
|
||||
this.createActionsHtml(notification.actions) : '';
|
||||
@ -224,7 +226,7 @@ export class NotificationManager {
|
||||
return `
|
||||
<button class="${buttonClass}" data-action="custom" data-action-id="${action.id}">
|
||||
${action.icon ? `<i data-lucide="${action.icon}"></i>` : ''}
|
||||
${this.escapeHtml(action.label)}
|
||||
${escapeHtml(action.label)}
|
||||
</button>
|
||||
`;
|
||||
}).join('');
|
||||
@ -471,18 +473,6 @@ export class NotificationManager {
|
||||
return Math.max(...notifications.map(n => n.createdAt.getTime()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
escapeHtml(unsafe) {
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup
|
||||
*/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,371 @@
|
||||
/**
|
||||
* Calibration Manager
|
||||
* Handles VNA calibration workflow
|
||||
*/
|
||||
|
||||
import { Debouncer, RequestGuard, ButtonState } from '../utils.js';
|
||||
|
||||
export class CalibrationManager {
|
||||
constructor(notifications) {
|
||||
this.notifications = notifications;
|
||||
this.workingCalibration = null;
|
||||
this.currentCalibration = null;
|
||||
this.disabledStandards = new Set();
|
||||
this.elements = {};
|
||||
this.debouncer = new Debouncer();
|
||||
this.reqGuard = new RequestGuard();
|
||||
this.currentPreset = null;
|
||||
|
||||
this.handleStartCalibration = this.handleStartCalibration.bind(this);
|
||||
this.handleSaveCalibration = this.handleSaveCalibration.bind(this);
|
||||
this.handleSetCalibration = this.handleSetCalibration.bind(this);
|
||||
this.handleCalibrationChange = this.handleCalibrationChange.bind(this);
|
||||
}
|
||||
|
||||
init(elements) {
|
||||
this.elements = elements;
|
||||
this.elements.startCalibrationBtn?.addEventListener('click', this.handleStartCalibration);
|
||||
this.elements.saveCalibrationBtn?.addEventListener('click', this.handleSaveCalibration);
|
||||
this.elements.calibrationDropdown?.addEventListener('change', this.handleCalibrationChange);
|
||||
this.elements.setCalibrationBtn?.addEventListener('click', this.handleSetCalibration);
|
||||
|
||||
this.elements.calibrationNameInput?.addEventListener('input', () => {
|
||||
const hasName = this.elements.calibrationNameInput.value.trim().length > 0;
|
||||
const isComplete = this.workingCalibration && this.workingCalibration.is_complete;
|
||||
this.elements.saveCalibrationBtn.disabled = !hasName || !isComplete;
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.elements.startCalibrationBtn?.removeEventListener('click', this.handleStartCalibration);
|
||||
this.elements.saveCalibrationBtn?.removeEventListener('click', this.handleSaveCalibration);
|
||||
this.elements.calibrationDropdown?.removeEventListener('change', this.handleCalibrationChange);
|
||||
this.elements.setCalibrationBtn?.removeEventListener('click', this.handleSetCalibration);
|
||||
this.resetCaptureState();
|
||||
}
|
||||
|
||||
setCurrentPreset(preset) {
|
||||
this.currentPreset = preset;
|
||||
if (preset) {
|
||||
this.elements.startCalibrationBtn.disabled = false;
|
||||
this.loadCalibrations();
|
||||
} else {
|
||||
this.elements.startCalibrationBtn.disabled = true;
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
async loadWorkingCalibration() {
|
||||
try {
|
||||
const r = await fetch('/api/v1/settings/working-calibration');
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const working = await r.json();
|
||||
this.updateWorking(working);
|
||||
} catch (e) {
|
||||
console.error('Working calibration load failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async loadCalibrations() {
|
||||
if (!this.currentPreset) return;
|
||||
try {
|
||||
const r = await fetch(`/api/v1/settings/calibrations?preset_filename=${encodeURIComponent(this.currentPreset.filename)}`);
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const calibrations = await r.json();
|
||||
this.populateDropdown(calibrations);
|
||||
} catch (e) {
|
||||
console.error('Calibrations load failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
populateDropdown(calibrations) {
|
||||
const dd = this.elements.calibrationDropdown;
|
||||
dd.innerHTML = '';
|
||||
|
||||
if (!calibrations.length) {
|
||||
dd.innerHTML = '<option value="">No calibrations available</option>';
|
||||
dd.disabled = true;
|
||||
this.elements.setCalibrationBtn.disabled = true;
|
||||
this.elements.viewPlotsBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
dd.innerHTML = '<option value="">Select calibration...</option>';
|
||||
calibrations.forEach(c => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = c.name;
|
||||
opt.textContent = `${c.name} ${c.is_complete ? '✔' : '?'}`;
|
||||
dd.appendChild(opt);
|
||||
});
|
||||
|
||||
dd.disabled = false;
|
||||
this.elements.setCalibrationBtn.disabled = true;
|
||||
this.elements.viewPlotsBtn.disabled = true;
|
||||
}
|
||||
|
||||
updateWorking(working) {
|
||||
this.workingCalibration = working;
|
||||
if (working.active) {
|
||||
this.showSteps(working);
|
||||
} else {
|
||||
this.hideSteps();
|
||||
}
|
||||
}
|
||||
|
||||
showSteps(working) {
|
||||
this.elements.calibrationSteps.style.display = 'block';
|
||||
this.elements.progressText.textContent = working.progress || '0/0';
|
||||
|
||||
this.renderStandardButtons(working);
|
||||
|
||||
const hasName = this.elements.calibrationNameInput.value.trim().length > 0;
|
||||
this.elements.saveCalibrationBtn.disabled = !hasName || !working.is_complete;
|
||||
this.elements.calibrationNameInput.disabled = false;
|
||||
|
||||
const hasCompleted = (working.completed_standards || []).length > 0;
|
||||
if (this.elements.viewCurrentPlotsBtn) {
|
||||
this.elements.viewCurrentPlotsBtn.disabled = !hasCompleted;
|
||||
}
|
||||
}
|
||||
|
||||
hideSteps() {
|
||||
this.elements.calibrationSteps.style.display = 'none';
|
||||
this.elements.calibrationStandards.innerHTML = '';
|
||||
if (this.elements.viewCurrentPlotsBtn) {
|
||||
this.elements.viewCurrentPlotsBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
renderStandardButtons(working) {
|
||||
const container = this.elements.calibrationStandards;
|
||||
container.innerHTML = '';
|
||||
|
||||
const all = this.getStandardsForMode();
|
||||
const completed = working.completed_standards || [];
|
||||
const missing = working.missing_standards || [];
|
||||
|
||||
all.forEach(std => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn calibration-standard-btn';
|
||||
btn.dataset.standard = std;
|
||||
|
||||
const isCompleted = completed.includes(std);
|
||||
const isMissing = missing.includes(std);
|
||||
const capturing = this.disabledStandards.has(std);
|
||||
|
||||
if (capturing) {
|
||||
btn.classList.add('btn--warning');
|
||||
btn.innerHTML = `<i data-lucide="clock"></i> Capturing ${std.toUpperCase()}...`;
|
||||
btn.disabled = true;
|
||||
btn.title = 'Standard is currently being captured';
|
||||
} else if (isCompleted) {
|
||||
btn.classList.add('btn--success');
|
||||
btn.innerHTML = `<i data-lucide="check"></i> ${std.toUpperCase()}`;
|
||||
btn.disabled = false;
|
||||
btn.title = 'Click to recapture this standard';
|
||||
} else if (isMissing) {
|
||||
btn.classList.add('btn--primary');
|
||||
btn.innerHTML = `<i data-lucide="radio"></i> Capture ${std.toUpperCase()}`;
|
||||
btn.disabled = false;
|
||||
btn.title = 'Click to capture this standard';
|
||||
} else {
|
||||
btn.classList.add('btn--secondary');
|
||||
btn.innerHTML = `${std.toUpperCase()}`;
|
||||
btn.disabled = true;
|
||||
}
|
||||
|
||||
btn.addEventListener('click', () => this.captureStandard(std));
|
||||
container.appendChild(btn);
|
||||
});
|
||||
|
||||
if (typeof lucide !== 'undefined') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
getStandardsForMode() {
|
||||
if (!this.currentPreset) return [];
|
||||
if (this.currentPreset.mode === 's11') return ['open', 'short', 'load'];
|
||||
if (this.currentPreset.mode === 's21') return ['through'];
|
||||
return [];
|
||||
}
|
||||
|
||||
handleCalibrationChange() {
|
||||
const v = this.elements.calibrationDropdown.value;
|
||||
this.elements.setCalibrationBtn.disabled = !v;
|
||||
this.elements.viewPlotsBtn.disabled = !v;
|
||||
}
|
||||
|
||||
async handleStartCalibration() {
|
||||
if (!this.currentPreset) return;
|
||||
|
||||
this.debouncer.debounce('start-calibration', () =>
|
||||
this.reqGuard.runExclusive('start-calibration', async () => {
|
||||
try {
|
||||
ButtonState.set(this.elements.startCalibrationBtn, { state: 'loading', icon: 'loader', text: 'Starting...' });
|
||||
|
||||
const r = await fetch('/api/v1/settings/calibration/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ preset_filename: this.currentPreset.filename })
|
||||
});
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const result = await r.json();
|
||||
|
||||
this.notify('info', 'Calibration Started', `Started calibration for ${result.preset}`);
|
||||
|
||||
await this.loadWorkingCalibration();
|
||||
} catch (e) {
|
||||
console.error('Start calibration failed:', e);
|
||||
this.notify('error', 'Calibration Error', 'Failed to start calibration');
|
||||
} finally {
|
||||
ButtonState.set(this.elements.startCalibrationBtn, { state: 'normal', icon: 'play', text: 'Start Calibration' });
|
||||
}
|
||||
}), 400
|
||||
);
|
||||
}
|
||||
|
||||
async captureStandard(standard) {
|
||||
const key = `calibrate-${standard}`;
|
||||
if (this.disabledStandards.has(standard)) return;
|
||||
|
||||
this.debouncer.debounce(key, () =>
|
||||
this.reqGuard.runExclusive(key, async () => {
|
||||
try {
|
||||
this.disabledStandards.add(standard);
|
||||
|
||||
const btn = document.querySelector(`[data-standard="${standard}"]`);
|
||||
ButtonState.set(btn, { state: 'loading', icon: 'upload', text: 'Capturing...' });
|
||||
|
||||
this.notify('info', 'Capturing Standard', `Capturing ${standard.toUpperCase()} standard...`);
|
||||
|
||||
const r = await fetch('/api/v1/settings/calibration/add-standard', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ standard })
|
||||
});
|
||||
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const result = await r.json();
|
||||
|
||||
this.notify('success', 'Standard Captured', result.message);
|
||||
|
||||
this.resetCaptureState();
|
||||
await this.loadWorkingCalibration();
|
||||
} catch (e) {
|
||||
console.error('Capture standard failed:', e);
|
||||
this.notify('error', 'Calibration Error', 'Failed to capture calibration standard');
|
||||
this.resetCaptureState(standard);
|
||||
}
|
||||
}), 500
|
||||
);
|
||||
}
|
||||
|
||||
async handleSaveCalibration() {
|
||||
const name = this.elements.calibrationNameInput.value.trim();
|
||||
if (!name) return;
|
||||
|
||||
this.debouncer.debounce('save-calibration', () =>
|
||||
this.reqGuard.runExclusive('save-calibration', async () => {
|
||||
try {
|
||||
ButtonState.set(this.elements.saveCalibrationBtn, { state: 'loading', icon: 'loader', text: 'Saving...' });
|
||||
|
||||
const r = await fetch('/api/v1/settings/calibration/save', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const result = await r.json();
|
||||
|
||||
this.notify('success', 'Calibration Saved', result.message);
|
||||
|
||||
this.hideSteps();
|
||||
this.elements.calibrationNameInput.value = '';
|
||||
|
||||
await this.loadWorkingCalibration();
|
||||
await this.loadCalibrations();
|
||||
|
||||
if (this.onCalibrationSaved) {
|
||||
await this.onCalibrationSaved();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Save calibration failed:', e);
|
||||
this.notify('error', 'Calibration Error', 'Failed to save calibration');
|
||||
} finally {
|
||||
ButtonState.set(this.elements.saveCalibrationBtn, { state: 'disabled', icon: 'save', text: 'Save Calibration' });
|
||||
}
|
||||
}), 400
|
||||
);
|
||||
}
|
||||
|
||||
async handleSetCalibration() {
|
||||
this.debouncer.debounce('set-calibration', () =>
|
||||
this.reqGuard.runExclusive('set-calibration', async () => {
|
||||
const name = this.elements.calibrationDropdown.value;
|
||||
if (!name || !this.currentPreset) return;
|
||||
|
||||
try {
|
||||
ButtonState.set(this.elements.setCalibrationBtn, { state: 'loading', icon: 'loader', text: 'Setting...' });
|
||||
|
||||
const r = await fetch('/api/v1/settings/calibration/set', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, preset_filename: this.currentPreset.filename })
|
||||
});
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const result = await r.json();
|
||||
|
||||
this.notify('success', 'Calibration Set', result.message);
|
||||
|
||||
if (this.onCalibrationSet) {
|
||||
await this.onCalibrationSet();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Set calibration failed:', e);
|
||||
this.notify('error', 'Calibration Error', 'Failed to set active calibration');
|
||||
} finally {
|
||||
ButtonState.set(this.elements.setCalibrationBtn, { state: 'normal', icon: 'check', text: 'Set Active' });
|
||||
}
|
||||
}), 300
|
||||
);
|
||||
}
|
||||
|
||||
updateStatus(status) {
|
||||
if (status.current_calibration) {
|
||||
this.currentCalibration = status.current_calibration;
|
||||
this.elements.currentCalibration.textContent = status.current_calibration.calibration_name;
|
||||
} else {
|
||||
this.currentCalibration = null;
|
||||
this.elements.currentCalibration.textContent = 'None';
|
||||
}
|
||||
}
|
||||
|
||||
resetCaptureState(standard = null) {
|
||||
if (standard) this.disabledStandards.delete(standard);
|
||||
else this.disabledStandards.clear();
|
||||
|
||||
if (this.workingCalibration) {
|
||||
this.renderStandardButtons(this.workingCalibration);
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.workingCalibration = null;
|
||||
this.hideSteps();
|
||||
if (this.elements.calibrationNameInput) {
|
||||
this.elements.calibrationNameInput.value = '';
|
||||
this.elements.calibrationNameInput.disabled = true;
|
||||
}
|
||||
if (this.elements.saveCalibrationBtn) this.elements.saveCalibrationBtn.disabled = true;
|
||||
if (this.elements.progressText) this.elements.progressText.textContent = '0/0';
|
||||
}
|
||||
|
||||
getWorkingCalibration() {
|
||||
return this.workingCalibration;
|
||||
}
|
||||
|
||||
notify(type, title, message) {
|
||||
this.notifications?.show?.({ type, title, message });
|
||||
}
|
||||
}
|
||||
132
vna_system/web_ui/static/js/modules/settings/preset-manager.js
Normal file
132
vna_system/web_ui/static/js/modules/settings/preset-manager.js
Normal file
@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Preset Manager
|
||||
* Handles VNA configuration presets
|
||||
*/
|
||||
|
||||
import { Debouncer, RequestGuard, ButtonState } from '../utils.js';
|
||||
|
||||
export class PresetManager {
|
||||
constructor(notifications) {
|
||||
this.notifications = notifications;
|
||||
this.currentPreset = null;
|
||||
this.elements = {};
|
||||
this.debouncer = new Debouncer();
|
||||
this.reqGuard = new RequestGuard();
|
||||
|
||||
this.handlePresetChange = this.handlePresetChange.bind(this);
|
||||
this.handleSetPreset = this.handleSetPreset.bind(this);
|
||||
}
|
||||
|
||||
init(elements) {
|
||||
this.elements = elements;
|
||||
this.elements.presetDropdown?.addEventListener('change', this.handlePresetChange);
|
||||
this.elements.setPresetBtn?.addEventListener('click', this.handleSetPreset);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.elements.presetDropdown?.removeEventListener('change', this.handlePresetChange);
|
||||
this.elements.setPresetBtn?.removeEventListener('click', this.handleSetPreset);
|
||||
}
|
||||
|
||||
async loadPresets() {
|
||||
try {
|
||||
const r = await fetch('/api/v1/settings/presets');
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const presets = await r.json();
|
||||
this.populateDropdown(presets);
|
||||
} catch (e) {
|
||||
console.error('Presets load failed:', e);
|
||||
this.notify('error', 'Load Error', 'Failed to load configuration presets');
|
||||
}
|
||||
}
|
||||
|
||||
populateDropdown(presets) {
|
||||
const dd = this.elements.presetDropdown;
|
||||
dd.innerHTML = '';
|
||||
|
||||
if (!presets.length) {
|
||||
dd.innerHTML = '<option value="">No presets available</option>';
|
||||
dd.disabled = true;
|
||||
this.elements.setPresetBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
dd.innerHTML = '<option value="">Select preset...</option>';
|
||||
presets.forEach(p => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.filename;
|
||||
opt.textContent = this.formatDisplay(p);
|
||||
dd.appendChild(opt);
|
||||
});
|
||||
|
||||
dd.disabled = false;
|
||||
this.elements.setPresetBtn.disabled = true;
|
||||
}
|
||||
|
||||
formatDisplay(p) {
|
||||
let s = `${p.filename} (${p.mode})`;
|
||||
if (p.start_freq && p.stop_freq) {
|
||||
const startMHz = (p.start_freq / 1e6).toFixed(0);
|
||||
const stopMHz = (p.stop_freq / 1e6).toFixed(0);
|
||||
s += ` - ${startMHz}-${stopMHz}MHz`;
|
||||
}
|
||||
if (p.points) s += `, ${p.points}pts`;
|
||||
return s;
|
||||
}
|
||||
|
||||
handlePresetChange() {
|
||||
const v = this.elements.presetDropdown.value;
|
||||
this.elements.setPresetBtn.disabled = !v;
|
||||
}
|
||||
|
||||
async handleSetPreset() {
|
||||
const filename = this.elements.presetDropdown.value;
|
||||
if (!filename) return;
|
||||
|
||||
this.debouncer.debounce('set-preset', () =>
|
||||
this.reqGuard.runExclusive('set-preset', async () => {
|
||||
try {
|
||||
ButtonState.set(this.elements.setPresetBtn, { state: 'loading', icon: 'loader', text: 'Setting...' });
|
||||
|
||||
const r = await fetch('/api/v1/settings/preset/set', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filename })
|
||||
});
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const result = await r.json();
|
||||
|
||||
this.notify('success', 'Preset Set', result.message);
|
||||
this.currentPreset = { filename };
|
||||
|
||||
if (this.onPresetChanged) {
|
||||
await this.onPresetChanged();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Set preset failed:', e);
|
||||
this.notify('error', 'Preset Error', 'Failed to set configuration preset');
|
||||
} finally {
|
||||
ButtonState.set(this.elements.setPresetBtn, { state: 'normal', icon: 'check', text: 'Set Active' });
|
||||
}
|
||||
}), 300
|
||||
);
|
||||
}
|
||||
|
||||
updateStatus(status) {
|
||||
if (status.current_preset) {
|
||||
this.currentPreset = status.current_preset;
|
||||
this.elements.currentPreset.textContent = status.current_preset.filename;
|
||||
} else {
|
||||
this.currentPreset = null;
|
||||
this.elements.currentPreset.textContent = 'None';
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentPreset() {
|
||||
return this.currentPreset;
|
||||
}
|
||||
|
||||
notify(type, title, message) {
|
||||
this.notifications?.show?.({ type, title, message });
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Reference Manager
|
||||
* Handles VNA measurement references
|
||||
*/
|
||||
|
||||
import { Debouncer, RequestGuard, ButtonState } from '../utils.js';
|
||||
|
||||
export class ReferenceManager {
|
||||
constructor(notifications) {
|
||||
this.notifications = notifications;
|
||||
this.availableReferences = [];
|
||||
this.currentReference = null;
|
||||
this.currentPreset = null;
|
||||
this.elements = {};
|
||||
this.debouncer = new Debouncer();
|
||||
this.reqGuard = new RequestGuard();
|
||||
|
||||
this.handleCreateReference = this.handleCreateReference.bind(this);
|
||||
this.handleReferenceChange = this.handleReferenceChange.bind(this);
|
||||
this.handleSetReference = this.handleSetReference.bind(this);
|
||||
this.handleClearReference = this.handleClearReference.bind(this);
|
||||
this.handleDeleteReference = this.handleDeleteReference.bind(this);
|
||||
}
|
||||
|
||||
init(elements) {
|
||||
this.elements = elements;
|
||||
this.elements.createReferenceBtn?.addEventListener('click', this.handleCreateReference);
|
||||
this.elements.referenceDropdown?.addEventListener('change', this.handleReferenceChange);
|
||||
this.elements.setReferenceBtn?.addEventListener('click', this.handleSetReference);
|
||||
this.elements.clearReferenceBtn?.addEventListener('click', this.handleClearReference);
|
||||
this.elements.deleteReferenceBtn?.addEventListener('click', this.handleDeleteReference);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.elements.createReferenceBtn?.removeEventListener('click', this.handleCreateReference);
|
||||
this.elements.referenceDropdown?.removeEventListener('change', this.handleReferenceChange);
|
||||
this.elements.setReferenceBtn?.removeEventListener('click', this.handleSetReference);
|
||||
this.elements.clearReferenceBtn?.removeEventListener('click', this.handleClearReference);
|
||||
this.elements.deleteReferenceBtn?.removeEventListener('click', this.handleDeleteReference);
|
||||
}
|
||||
|
||||
setCurrentPreset(preset) {
|
||||
this.currentPreset = preset;
|
||||
if (preset) {
|
||||
this.loadReferences();
|
||||
} else {
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
async loadReferences() {
|
||||
try {
|
||||
if (!this.currentPreset) {
|
||||
this.renderDropdown([]);
|
||||
this.updateInfo(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const referencesResponse = await fetch(`/api/v1/settings/references?preset_filename=${encodeURIComponent(this.currentPreset.filename)}`);
|
||||
if (!referencesResponse.ok) throw new Error(`HTTP ${referencesResponse.status}`);
|
||||
this.availableReferences = await referencesResponse.json();
|
||||
|
||||
const currentResponse = await fetch(`/api/v1/settings/reference/current?preset_filename=${encodeURIComponent(this.currentPreset.filename)}`);
|
||||
if (!currentResponse.ok) throw new Error(`HTTP ${currentResponse.status}`);
|
||||
this.currentReference = await currentResponse.json();
|
||||
|
||||
this.renderDropdown(this.availableReferences);
|
||||
this.updateInfo(this.currentReference);
|
||||
} catch (error) {
|
||||
console.error('Failed to load references:', error);
|
||||
this.renderDropdown([]);
|
||||
this.updateInfo(null);
|
||||
}
|
||||
}
|
||||
|
||||
renderDropdown(references) {
|
||||
if (!this.elements.referenceDropdown) return;
|
||||
|
||||
this.elements.referenceDropdown.innerHTML = '';
|
||||
|
||||
if (references.length === 0) {
|
||||
this.elements.referenceDropdown.innerHTML = '<option value="">No references available</option>';
|
||||
this.elements.referenceDropdown.disabled = true;
|
||||
this.elements.setReferenceBtn.disabled = true;
|
||||
this.elements.clearReferenceBtn.disabled = true;
|
||||
this.elements.deleteReferenceBtn.disabled = true;
|
||||
} else {
|
||||
this.elements.referenceDropdown.innerHTML = '<option value="">Select a reference...</option>';
|
||||
|
||||
references.forEach(ref => {
|
||||
const option = document.createElement('option');
|
||||
option.value = ref.name;
|
||||
option.textContent = `${ref.name} (${new Date(ref.timestamp).toLocaleDateString()})`;
|
||||
this.elements.referenceDropdown.appendChild(option);
|
||||
});
|
||||
|
||||
this.elements.referenceDropdown.disabled = false;
|
||||
|
||||
if (this.currentReference) {
|
||||
this.elements.referenceDropdown.value = this.currentReference.name;
|
||||
}
|
||||
}
|
||||
|
||||
this.updateButtons();
|
||||
}
|
||||
|
||||
updateButtons() {
|
||||
const hasSelection = this.elements.referenceDropdown.value !== '';
|
||||
const hasCurrent = this.currentReference !== null;
|
||||
|
||||
this.elements.setReferenceBtn.disabled = !hasSelection;
|
||||
this.elements.deleteReferenceBtn.disabled = !hasSelection;
|
||||
this.elements.clearReferenceBtn.disabled = !hasCurrent;
|
||||
}
|
||||
|
||||
updateInfo(reference) {
|
||||
if (!this.elements.currentReferenceInfo) return;
|
||||
|
||||
if (!reference) {
|
||||
this.elements.currentReferenceInfo.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
this.elements.currentReferenceInfo.style.display = 'block';
|
||||
|
||||
if (this.elements.currentReferenceName) {
|
||||
this.elements.currentReferenceName.textContent = reference.name;
|
||||
}
|
||||
|
||||
if (this.elements.currentReferenceTimestamp) {
|
||||
this.elements.currentReferenceTimestamp.textContent = new Date(reference.timestamp).toLocaleString();
|
||||
}
|
||||
|
||||
if (this.elements.currentReferenceDescription) {
|
||||
this.elements.currentReferenceDescription.textContent = reference.description || '';
|
||||
}
|
||||
}
|
||||
|
||||
async handleCreateReference() {
|
||||
const name = this.elements.referenceNameInput.value.trim();
|
||||
if (!name) {
|
||||
this.notify('warning', 'Missing Name', 'Please enter a name for the reference');
|
||||
return;
|
||||
}
|
||||
|
||||
const description = this.elements.referenceDescriptionInput.value.trim();
|
||||
|
||||
this.debouncer.debounce('create-reference', () =>
|
||||
this.reqGuard.runExclusive('create-reference', async () => {
|
||||
try {
|
||||
ButtonState.set(this.elements.createReferenceBtn, { state: 'loading', icon: 'loader', text: 'Creating...' });
|
||||
|
||||
const response = await fetch('/api/v1/settings/reference/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, description })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const result = await response.json();
|
||||
|
||||
this.notify('success', 'Reference Created', result.message);
|
||||
|
||||
this.elements.referenceNameInput.value = '';
|
||||
this.elements.referenceDescriptionInput.value = '';
|
||||
|
||||
await this.loadReferences();
|
||||
} catch (error) {
|
||||
console.error('Create reference failed:', error);
|
||||
this.notify('error', 'Reference Error', 'Failed to create reference');
|
||||
} finally {
|
||||
ButtonState.set(this.elements.createReferenceBtn, { state: 'normal', icon: 'target', text: 'Capture Reference' });
|
||||
}
|
||||
}), 500
|
||||
);
|
||||
}
|
||||
|
||||
handleReferenceChange() {
|
||||
this.updateButtons();
|
||||
}
|
||||
|
||||
async handleSetReference() {
|
||||
const referenceName = this.elements.referenceDropdown.value;
|
||||
if (!referenceName) return;
|
||||
|
||||
this.debouncer.debounce('set-reference', () =>
|
||||
this.reqGuard.runExclusive('set-reference', async () => {
|
||||
try {
|
||||
ButtonState.set(this.elements.setReferenceBtn, { state: 'loading', icon: 'loader', text: 'Setting...' });
|
||||
|
||||
const response = await fetch('/api/v1/settings/reference/set', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: referenceName })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const result = await response.json();
|
||||
|
||||
this.notify('success', 'Reference Set', result.message);
|
||||
|
||||
await this.loadReferences();
|
||||
} catch (error) {
|
||||
console.error('Set reference failed:', error);
|
||||
this.notify('error', 'Reference Error', 'Failed to set reference');
|
||||
} finally {
|
||||
ButtonState.set(this.elements.setReferenceBtn, { state: 'normal', icon: 'check', text: 'Set Active' });
|
||||
}
|
||||
}), 400
|
||||
);
|
||||
}
|
||||
|
||||
async handleClearReference() {
|
||||
this.debouncer.debounce('clear-reference', () =>
|
||||
this.reqGuard.runExclusive('clear-reference', async () => {
|
||||
try {
|
||||
ButtonState.set(this.elements.clearReferenceBtn, { state: 'loading', icon: 'loader', text: 'Clearing...' });
|
||||
|
||||
const response = await fetch('/api/v1/settings/reference/current', {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const result = await response.json();
|
||||
|
||||
this.notify('success', 'Reference Cleared', result.message);
|
||||
|
||||
await this.loadReferences();
|
||||
} catch (error) {
|
||||
console.error('Clear reference failed:', error);
|
||||
this.notify('error', 'Reference Error', 'Failed to clear reference');
|
||||
} finally {
|
||||
ButtonState.set(this.elements.clearReferenceBtn, { state: 'normal', icon: 'x', text: 'Clear' });
|
||||
}
|
||||
}), 400
|
||||
);
|
||||
}
|
||||
|
||||
async handleDeleteReference() {
|
||||
const referenceName = this.elements.referenceDropdown.value;
|
||||
if (!referenceName) return;
|
||||
|
||||
if (!confirm(`Are you sure you want to delete reference "${referenceName}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.debouncer.debounce('delete-reference', () =>
|
||||
this.reqGuard.runExclusive('delete-reference', async () => {
|
||||
try {
|
||||
ButtonState.set(this.elements.deleteReferenceBtn, { state: 'loading', icon: 'loader', text: 'Deleting...' });
|
||||
|
||||
const response = await fetch(`/api/v1/settings/reference/${encodeURIComponent(referenceName)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const result = await response.json();
|
||||
|
||||
this.notify('success', 'Reference Deleted', result.message);
|
||||
|
||||
await this.loadReferences();
|
||||
} catch (error) {
|
||||
console.error('Delete reference failed:', error);
|
||||
this.notify('error', 'Reference Error', 'Failed to delete reference');
|
||||
} finally {
|
||||
ButtonState.set(this.elements.deleteReferenceBtn, { state: 'normal', icon: 'trash-2', text: 'Delete' });
|
||||
}
|
||||
}), 400
|
||||
);
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.availableReferences = [];
|
||||
this.currentReference = null;
|
||||
this.renderDropdown([]);
|
||||
this.updateInfo(null);
|
||||
}
|
||||
|
||||
notify(type, title, message) {
|
||||
this.notifications?.show?.({ type, title, message });
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,8 @@
|
||||
* Handles localStorage persistence and user preferences
|
||||
*/
|
||||
|
||||
import { formatBytes } from './utils.js';
|
||||
|
||||
export class StorageManager {
|
||||
constructor() {
|
||||
this.prefix = 'vna_dashboard_';
|
||||
@ -271,7 +273,7 @@ export class StorageManager {
|
||||
return {
|
||||
available: true,
|
||||
totalSize,
|
||||
totalSizeFormatted: this.formatBytes(totalSize),
|
||||
totalSizeFormatted: formatBytes(totalSize),
|
||||
itemSizes,
|
||||
itemCount: Object.keys(itemSizes).length,
|
||||
storageQuotaMB: this.estimateStorageQuota()
|
||||
@ -291,19 +293,6 @@ export class StorageManager {
|
||||
return 5; // MB
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human readable format
|
||||
*/
|
||||
formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup old or unnecessary data
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user