added functionality, save buttons etc
This commit is contained in:
@ -1,8 +1,10 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from typing import List
|
from typing import List
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import vna_system.core.singletons as singletons
|
import vna_system.core.singletons as singletons
|
||||||
from vna_system.core.settings.calibration_manager import CalibrationStandard
|
from vna_system.core.settings.calibration_manager import CalibrationStandard
|
||||||
|
from vna_system.core.visualization.magnitude_chart import generate_standards_magnitude_plots, generate_combined_standards_plot
|
||||||
from vna_system.api.models.settings import (
|
from vna_system.api.models.settings import (
|
||||||
PresetModel,
|
PresetModel,
|
||||||
CalibrationModel,
|
CalibrationModel,
|
||||||
@ -299,3 +301,118 @@ async def get_current_calibration():
|
|||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/calibration/{calibration_name}/standards-plots")
|
||||||
|
async def get_calibration_standards_plots(calibration_name: str, preset_filename: str = None):
|
||||||
|
"""Get magnitude plots for all standards in a calibration set"""
|
||||||
|
try:
|
||||||
|
# Get preset
|
||||||
|
preset = None
|
||||||
|
if preset_filename:
|
||||||
|
presets = singletons.settings_manager.get_available_presets()
|
||||||
|
preset = next((p for p in presets if p.filename == preset_filename), None)
|
||||||
|
if not preset:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Preset not found: {preset_filename}")
|
||||||
|
else:
|
||||||
|
preset = singletons.settings_manager.get_current_preset()
|
||||||
|
if not preset:
|
||||||
|
raise HTTPException(status_code=400, detail="No current preset selected")
|
||||||
|
|
||||||
|
# Get calibration directory
|
||||||
|
calibration_manager = singletons.settings_manager.calibration_manager
|
||||||
|
calibration_dir = calibration_manager._get_preset_calibration_dir(preset) / calibration_name
|
||||||
|
|
||||||
|
if not calibration_dir.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"Calibration not found: {calibration_name}")
|
||||||
|
|
||||||
|
# Generate plots for each standard
|
||||||
|
individual_plots = generate_standards_magnitude_plots(calibration_dir, preset)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"calibration_name": calibration_name,
|
||||||
|
"preset": {
|
||||||
|
"filename": preset.filename,
|
||||||
|
"mode": preset.mode.value
|
||||||
|
},
|
||||||
|
"individual_plots": individual_plots
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/working-calibration/standards-plots")
|
||||||
|
async def get_working_calibration_standards_plots():
|
||||||
|
"""Get magnitude plots for standards in current working calibration"""
|
||||||
|
try:
|
||||||
|
working_calib = singletons.settings_manager.get_current_working_calibration()
|
||||||
|
|
||||||
|
if not working_calib:
|
||||||
|
raise HTTPException(status_code=404, detail="No working calibration active")
|
||||||
|
|
||||||
|
# Check if there are any standards captured
|
||||||
|
if not working_calib.standards:
|
||||||
|
raise HTTPException(status_code=404, detail="No standards captured in working calibration")
|
||||||
|
|
||||||
|
# Generate plots directly from in-memory sweep data
|
||||||
|
from vna_system.core.visualization.magnitude_chart import generate_magnitude_plot_from_sweep_data
|
||||||
|
|
||||||
|
individual_plots = {}
|
||||||
|
standard_colors = {
|
||||||
|
'open': '#2ca02c', # Green
|
||||||
|
'short': '#d62728', # Red
|
||||||
|
'load': '#ff7f0e', # Orange
|
||||||
|
'through': '#1f77b4' # Blue
|
||||||
|
}
|
||||||
|
|
||||||
|
for standard, sweep_data in working_calib.standards.items():
|
||||||
|
try:
|
||||||
|
# Generate plot for this standard
|
||||||
|
plot_config = generate_magnitude_plot_from_sweep_data(sweep_data, working_calib.preset)
|
||||||
|
|
||||||
|
if 'error' not in plot_config:
|
||||||
|
# Customize color and title for this standard
|
||||||
|
if plot_config.get('data'):
|
||||||
|
plot_config['data'][0]['line']['color'] = standard_colors.get(standard.value, '#1f77b4')
|
||||||
|
plot_config['data'][0]['name'] = f'{standard.value.upper()} Standard'
|
||||||
|
plot_config['layout']['title'] = f'{standard.value.upper()} Standard Magnitude (Working)'
|
||||||
|
|
||||||
|
# Include raw sweep data for download
|
||||||
|
plot_config['raw_sweep_data'] = {
|
||||||
|
'sweep_number': sweep_data.sweep_number,
|
||||||
|
'timestamp': sweep_data.timestamp,
|
||||||
|
'total_points': sweep_data.total_points,
|
||||||
|
'points': sweep_data.points, # Raw complex data points
|
||||||
|
'file_path': None # No file path for working calibration
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add frequency information
|
||||||
|
plot_config['frequency_info'] = {
|
||||||
|
'start_freq': working_calib.preset.start_freq,
|
||||||
|
'stop_freq': working_calib.preset.stop_freq,
|
||||||
|
'points': working_calib.preset.points,
|
||||||
|
'bandwidth': working_calib.preset.bandwidth
|
||||||
|
}
|
||||||
|
|
||||||
|
individual_plots[standard.value] = plot_config
|
||||||
|
else:
|
||||||
|
individual_plots[standard.value] = plot_config
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
individual_plots[standard.value] = {'error': f'Failed to generate plot for {standard.value}: {str(e)}'}
|
||||||
|
|
||||||
|
if not individual_plots:
|
||||||
|
raise HTTPException(status_code=404, detail="No valid plots generated for working calibration")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"calibration_name": "Working Calibration",
|
||||||
|
"preset": {
|
||||||
|
"filename": working_calib.preset.filename,
|
||||||
|
"mode": working_calib.preset.mode.value
|
||||||
|
},
|
||||||
|
"individual_plots": individual_plots,
|
||||||
|
"is_working": True,
|
||||||
|
"is_complete": working_calib.is_complete()
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
@ -1 +1 @@
|
|||||||
config_inputs/s21_start100_stop8800_points1000_bw1khz.bin
|
config_inputs/s11_start100_stop8800_points1000_bw1khz.bin
|
||||||
1
vna_system/calibration/current_calibration
Symbolic link
1
vna_system/calibration/current_calibration
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
s11_start100_stop8800_points1000_bw1khz/lol
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"preset": {
|
||||||
|
"filename": "s11_start100_stop8800_points1000_bw1khz.bin",
|
||||||
|
"mode": "s11",
|
||||||
|
"start_freq": 100000000.0,
|
||||||
|
"stop_freq": 8800000000.0,
|
||||||
|
"points": 1000,
|
||||||
|
"bandwidth": 1000.0
|
||||||
|
},
|
||||||
|
"calibration_name": "sdbsd",
|
||||||
|
"standards": [
|
||||||
|
"open",
|
||||||
|
"short",
|
||||||
|
"load"
|
||||||
|
],
|
||||||
|
"created_timestamp": "2025-09-25T17:53:37.990976",
|
||||||
|
"is_complete": true
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"preset": {
|
||||||
|
"filename": "s11_start100_stop8800_points1000_bw1khz.bin",
|
||||||
|
"mode": "s11",
|
||||||
|
"start_freq": 100000000.0,
|
||||||
|
"stop_freq": 8800000000.0,
|
||||||
|
"points": 1000,
|
||||||
|
"bandwidth": 1000.0
|
||||||
|
},
|
||||||
|
"calibration_name": "sdbsd",
|
||||||
|
"standard": "load",
|
||||||
|
"sweep_number": 15,
|
||||||
|
"sweep_timestamp": 1758812011.9188073,
|
||||||
|
"created_timestamp": "2025-09-25T17:53:37.990927",
|
||||||
|
"total_points": 1000
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"preset": {
|
||||||
|
"filename": "s11_start100_stop8800_points1000_bw1khz.bin",
|
||||||
|
"mode": "s11",
|
||||||
|
"start_freq": 100000000.0,
|
||||||
|
"stop_freq": 8800000000.0,
|
||||||
|
"points": 1000,
|
||||||
|
"bandwidth": 1000.0
|
||||||
|
},
|
||||||
|
"calibration_name": "sdbsd",
|
||||||
|
"standard": "open",
|
||||||
|
"sweep_number": 13,
|
||||||
|
"sweep_timestamp": 1758811971.8226106,
|
||||||
|
"created_timestamp": "2025-09-25T17:53:37.986902",
|
||||||
|
"total_points": 1000
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"preset": {
|
||||||
|
"filename": "s11_start100_stop8800_points1000_bw1khz.bin",
|
||||||
|
"mode": "s11",
|
||||||
|
"start_freq": 100000000.0,
|
||||||
|
"stop_freq": 8800000000.0,
|
||||||
|
"points": 1000,
|
||||||
|
"bandwidth": 1000.0
|
||||||
|
},
|
||||||
|
"calibration_name": "sdbsd",
|
||||||
|
"standard": "short",
|
||||||
|
"sweep_number": 14,
|
||||||
|
"sweep_timestamp": 1758811996.130049,
|
||||||
|
"created_timestamp": "2025-09-25T17:53:37.988934",
|
||||||
|
"total_points": 1000
|
||||||
|
}
|
||||||
@ -13,7 +13,7 @@ from vna_system.core.settings.preset_manager import ConfigPreset
|
|||||||
class UIParameter:
|
class UIParameter:
|
||||||
name: str
|
name: str
|
||||||
label: str
|
label: str
|
||||||
type: str # 'slider', 'toggle', 'select', 'input'
|
type: str # 'slider', 'toggle', 'select', 'input', 'button'
|
||||||
value: Any
|
value: Any
|
||||||
options: Optional[Dict[str, Any]] = None # min/max for slider, choices for select, etc.
|
options: Optional[Dict[str, Any]] = None # min/max for slider, choices for select, etc.
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"y_min": -110,
|
"y_min": -80,
|
||||||
"y_max": 10,
|
"y_max": 15,
|
||||||
"smoothing_enabled": false,
|
"smoothing_enabled": false,
|
||||||
"smoothing_window": 17,
|
"smoothing_window": 5,
|
||||||
"marker_enabled": true,
|
"marker_enabled": true,
|
||||||
"marker_frequency": 1,
|
"marker_frequency": 100000009,
|
||||||
"grid_enabled": false
|
"grid_enabled": true
|
||||||
}
|
}
|
||||||
@ -2,11 +2,11 @@
|
|||||||
"y_min": -210,
|
"y_min": -210,
|
||||||
"y_max": 360,
|
"y_max": 360,
|
||||||
"unwrap_phase": false,
|
"unwrap_phase": false,
|
||||||
"phase_offset": -60,
|
"phase_offset": 0,
|
||||||
"smoothing_enabled": true,
|
"smoothing_enabled": true,
|
||||||
"smoothing_window": 5,
|
"smoothing_window": 5,
|
||||||
"marker_enabled": false,
|
"marker_enabled": false,
|
||||||
"marker_frequency": 2000000000,
|
"marker_frequency": "asdasd",
|
||||||
"reference_line_enabled": false,
|
"reference_line_enabled": false,
|
||||||
"reference_phase": 0,
|
"reference_phase": 0,
|
||||||
"grid_enabled": true
|
"grid_enabled": true
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"impedance_mode": true,
|
"impedance_mode": false,
|
||||||
"reference_impedance": 100,
|
"reference_impedance": 100,
|
||||||
"marker_enabled": true,
|
"marker_enabled": false,
|
||||||
"marker_frequency": 1000000000,
|
"marker_frequency": 1000000000,
|
||||||
"grid_circles": true,
|
"grid_circles": false,
|
||||||
"grid_radials": true,
|
"grid_radials": false,
|
||||||
"trace_color_mode": "solid"
|
"trace_color_mode": "solid"
|
||||||
}
|
}
|
||||||
@ -8,6 +8,8 @@ from ..base_processor import BaseProcessor, UIParameter
|
|||||||
class MagnitudeProcessor(BaseProcessor):
|
class MagnitudeProcessor(BaseProcessor):
|
||||||
def __init__(self, config_dir: Path):
|
def __init__(self, config_dir: Path):
|
||||||
super().__init__("magnitude", config_dir)
|
super().__init__("magnitude", config_dir)
|
||||||
|
# State for smoothing that can be reset by button
|
||||||
|
self._smoothing_history = []
|
||||||
|
|
||||||
def process_sweep(self, sweep_data: Any, calibrated_data: Any, vna_config: Dict[str, Any]) -> Dict[str, Any]:
|
def process_sweep(self, sweep_data: Any, calibrated_data: Any, vna_config: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
if not calibrated_data or not hasattr(calibrated_data, 'points'):
|
if not calibrated_data or not hasattr(calibrated_data, 'points'):
|
||||||
@ -141,6 +143,13 @@ class MagnitudeProcessor(BaseProcessor):
|
|||||||
label='Show Grid',
|
label='Show Grid',
|
||||||
type='toggle',
|
type='toggle',
|
||||||
value=self._config.get('grid_enabled', True)
|
value=self._config.get('grid_enabled', True)
|
||||||
|
),
|
||||||
|
UIParameter(
|
||||||
|
name='reset_smoothing',
|
||||||
|
label='Reset Smoothing',
|
||||||
|
type='button',
|
||||||
|
value=False, # Always False for buttons, will be set to True temporarily when clicked
|
||||||
|
options={'action': 'Reset the smoothing filter state'}
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -155,6 +164,34 @@ class MagnitudeProcessor(BaseProcessor):
|
|||||||
'grid_enabled': True
|
'grid_enabled': True
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def update_config(self, updates: Dict[str, Any]):
|
||||||
|
print(f"🔧 update_config called with: {updates}")
|
||||||
|
# Handle button parameters specially
|
||||||
|
button_actions = {}
|
||||||
|
config_updates = {}
|
||||||
|
|
||||||
|
for key, value in updates.items():
|
||||||
|
# Check if this is a button parameter
|
||||||
|
ui_params = {param.name: param for param in self.get_ui_parameters()}
|
||||||
|
if key in ui_params and ui_params[key].type == 'button':
|
||||||
|
if value: # Button was clicked
|
||||||
|
button_actions[key] = value
|
||||||
|
# Don't add button values to config
|
||||||
|
else:
|
||||||
|
config_updates[key] = value
|
||||||
|
|
||||||
|
# Update config with non-button parameters
|
||||||
|
if config_updates:
|
||||||
|
super().update_config(config_updates)
|
||||||
|
|
||||||
|
# Handle button actions
|
||||||
|
for action, pressed in button_actions.items():
|
||||||
|
if pressed and action == 'reset_smoothing':
|
||||||
|
# Reset smoothing state (could be a counter, filter state, etc.)
|
||||||
|
self._smoothing_history = [] # Reset any internal smoothing state
|
||||||
|
print(f"🔄 Smoothing state reset by button action")
|
||||||
|
# Note: recalculate() will be called automatically by the processing system
|
||||||
|
|
||||||
def _validate_config(self):
|
def _validate_config(self):
|
||||||
required_keys = ['y_min', 'y_max', 'smoothing_enabled', 'smoothing_window',
|
required_keys = ['y_min', 'y_max', 'smoothing_enabled', 'smoothing_window',
|
||||||
'marker_enabled', 'marker_frequency', 'grid_enabled']
|
'marker_enabled', 'marker_frequency', 'grid_enabled']
|
||||||
|
|||||||
264
vna_system/core/visualization/magnitude_chart.py
Normal file
264
vna_system/core/visualization/magnitude_chart.py
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
import numpy as np
|
||||||
|
from typing import Dict, Any, List, Tuple
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from vna_system.core.acquisition.sweep_buffer import SweepData
|
||||||
|
from vna_system.core.settings.preset_manager import ConfigPreset
|
||||||
|
|
||||||
|
|
||||||
|
def generate_magnitude_plot_from_sweep_data(sweep_data: SweepData, preset: ConfigPreset = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Generate Plotly configuration for magnitude plot from SweepData
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sweep_data: SweepData instance with points list of [real, imag] complex pairs
|
||||||
|
preset: Optional ConfigPreset with frequency info
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Plotly configuration dict for magnitude plot
|
||||||
|
"""
|
||||||
|
if not sweep_data or not sweep_data.points:
|
||||||
|
return {'error': 'Invalid sweep data'}
|
||||||
|
|
||||||
|
# Extract frequency range from preset or use defaults
|
||||||
|
start_freq = 100e6 # 100 MHz
|
||||||
|
stop_freq = 8.8e9 # 8.8 GHz
|
||||||
|
|
||||||
|
if preset:
|
||||||
|
start_freq = preset.start_freq or start_freq
|
||||||
|
stop_freq = preset.stop_freq or stop_freq
|
||||||
|
|
||||||
|
frequencies = []
|
||||||
|
magnitudes_db = []
|
||||||
|
|
||||||
|
# Calculate magnitude in dB for each point
|
||||||
|
for i, (real, imag) in enumerate(sweep_data.points):
|
||||||
|
complex_val = complex(real, imag)
|
||||||
|
magnitude_db = 20 * np.log10(abs(complex_val)) if abs(complex_val) > 0 else -120
|
||||||
|
|
||||||
|
# Calculate frequency based on point index
|
||||||
|
total_points = len(sweep_data.points)
|
||||||
|
frequency = start_freq + (stop_freq - start_freq) * i / (total_points - 1)
|
||||||
|
|
||||||
|
frequencies.append(frequency)
|
||||||
|
magnitudes_db.append(magnitude_db)
|
||||||
|
|
||||||
|
# Create Plotly trace
|
||||||
|
trace = {
|
||||||
|
'x': [f / 1e9 for f in frequencies], # Convert to GHz
|
||||||
|
'y': magnitudes_db,
|
||||||
|
'type': 'scatter',
|
||||||
|
'mode': 'lines',
|
||||||
|
'name': 'Magnitude',
|
||||||
|
'line': {'color': '#1f77b4', 'width': 2}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calculate reasonable Y-axis range
|
||||||
|
min_mag = min(magnitudes_db)
|
||||||
|
max_mag = max(magnitudes_db)
|
||||||
|
y_margin = (max_mag - min_mag) * 0.1
|
||||||
|
y_min = max(min_mag - y_margin, -120)
|
||||||
|
y_max = min(max_mag + y_margin, 20)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'data': [trace],
|
||||||
|
'layout': {
|
||||||
|
'title': 'Magnitude Response',
|
||||||
|
'xaxis': {
|
||||||
|
'title': 'Frequency (GHz)',
|
||||||
|
'showgrid': True,
|
||||||
|
'gridcolor': '#e5e5e5',
|
||||||
|
'gridwidth': 1
|
||||||
|
},
|
||||||
|
'yaxis': {
|
||||||
|
'title': 'Magnitude (dB)',
|
||||||
|
'range': [y_min, y_max],
|
||||||
|
'showgrid': True,
|
||||||
|
'gridcolor': '#e5e5e5',
|
||||||
|
'gridwidth': 1
|
||||||
|
},
|
||||||
|
'plot_bgcolor': '#fafafa',
|
||||||
|
'paper_bgcolor': '#ffffff',
|
||||||
|
'font': {
|
||||||
|
'family': 'Arial, sans-serif',
|
||||||
|
'size': 12,
|
||||||
|
'color': '#333333'
|
||||||
|
},
|
||||||
|
'hovermode': 'x unified',
|
||||||
|
'showlegend': True,
|
||||||
|
'margin': {'l': 60, 'r': 40, 't': 60, 'b': 60}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_sweep_data_from_json(json_file: Path) -> SweepData:
|
||||||
|
"""
|
||||||
|
Load SweepData from JSON file
|
||||||
|
|
||||||
|
Args:
|
||||||
|
json_file: Path to JSON file containing sweep data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SweepData instance
|
||||||
|
"""
|
||||||
|
with open(json_file, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
return SweepData(
|
||||||
|
sweep_number=data.get('sweep_number', 0),
|
||||||
|
timestamp=data.get('timestamp', 0.0),
|
||||||
|
points=data.get('points', []),
|
||||||
|
total_points=data.get('total_points', len(data.get('points', [])))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_standards_magnitude_plots(calibration_path: Path, preset: ConfigPreset = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Generate magnitude plots for all calibration standards in a calibration set
|
||||||
|
|
||||||
|
Args:
|
||||||
|
calibration_path: Path to calibration directory
|
||||||
|
preset: Optional ConfigPreset
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with plots for each standard, including raw data
|
||||||
|
"""
|
||||||
|
plots = {}
|
||||||
|
standard_colors = {
|
||||||
|
'open': '#2ca02c', # Green
|
||||||
|
'short': '#d62728', # Red
|
||||||
|
'load': '#ff7f0e', # Orange
|
||||||
|
'through': '#1f77b4' # Blue
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find all standard JSON files
|
||||||
|
for standard_file in calibration_path.glob('*.json'):
|
||||||
|
standard_name = standard_file.stem
|
||||||
|
|
||||||
|
# Skip metadata files
|
||||||
|
if 'metadata' in standard_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
sweep_data = load_sweep_data_from_json(standard_file)
|
||||||
|
plot_config = generate_magnitude_plot_from_sweep_data(sweep_data, preset)
|
||||||
|
|
||||||
|
if 'error' not in plot_config:
|
||||||
|
# Customize color and title for this standard
|
||||||
|
if plot_config.get('data'):
|
||||||
|
plot_config['data'][0]['line']['color'] = standard_colors.get(standard_name, '#1f77b4')
|
||||||
|
plot_config['data'][0]['name'] = f'{standard_name.upper()} Standard'
|
||||||
|
plot_config['layout']['title'] = f'{standard_name.upper()} Standard Magnitude'
|
||||||
|
|
||||||
|
# Include raw sweep data for download
|
||||||
|
plot_config['raw_sweep_data'] = {
|
||||||
|
'sweep_number': sweep_data.sweep_number,
|
||||||
|
'timestamp': sweep_data.timestamp,
|
||||||
|
'total_points': sweep_data.total_points,
|
||||||
|
'points': sweep_data.points, # Raw complex data points
|
||||||
|
'file_path': str(standard_file)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add frequency information if available
|
||||||
|
if preset:
|
||||||
|
plot_config['frequency_info'] = {
|
||||||
|
'start_freq': preset.start_freq,
|
||||||
|
'stop_freq': preset.stop_freq,
|
||||||
|
'points': preset.points,
|
||||||
|
'bandwidth': preset.bandwidth
|
||||||
|
}
|
||||||
|
|
||||||
|
plots[standard_name] = plot_config
|
||||||
|
except (json.JSONDecodeError, FileNotFoundError, KeyError) as e:
|
||||||
|
plots[standard_name] = {'error': f'Failed to load {standard_name}: {str(e)}'}
|
||||||
|
|
||||||
|
return plots
|
||||||
|
|
||||||
|
|
||||||
|
def generate_combined_standards_plot(calibration_path: Path, preset: ConfigPreset = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Generate a combined plot showing all calibration standards
|
||||||
|
|
||||||
|
Args:
|
||||||
|
calibration_path: Path to calibration directory
|
||||||
|
preset: Optional ConfigPreset
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Plotly configuration dict with all standards overlaid
|
||||||
|
"""
|
||||||
|
traces = []
|
||||||
|
standard_colors = {
|
||||||
|
'open': '#2ca02c', # Green
|
||||||
|
'short': '#d62728', # Red
|
||||||
|
'load': '#ff7f0e', # Orange
|
||||||
|
'through': '#1f77b4' # Blue
|
||||||
|
}
|
||||||
|
|
||||||
|
y_min, y_max = 0, -120
|
||||||
|
|
||||||
|
# Process each standard
|
||||||
|
for standard_file in calibration_path.glob('*.json'):
|
||||||
|
standard_name = standard_file.stem
|
||||||
|
|
||||||
|
# Skip metadata files
|
||||||
|
if 'metadata' in standard_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
sweep_data = load_sweep_data_from_json(standard_file)
|
||||||
|
plot_config = generate_magnitude_plot_from_sweep_data(sweep_data, preset)
|
||||||
|
|
||||||
|
if 'error' not in plot_config and plot_config.get('data'):
|
||||||
|
trace = plot_config['data'][0].copy()
|
||||||
|
trace['line']['color'] = standard_colors.get(standard_name, '#1f77b4')
|
||||||
|
trace['name'] = f'{standard_name.upper()} Standard'
|
||||||
|
traces.append(trace)
|
||||||
|
|
||||||
|
# Update Y range
|
||||||
|
if trace['y']:
|
||||||
|
trace_min = min(trace['y'])
|
||||||
|
trace_max = max(trace['y'])
|
||||||
|
y_min = min(y_min, trace_min)
|
||||||
|
y_max = max(y_max, trace_max)
|
||||||
|
|
||||||
|
except (json.JSONDecodeError, FileNotFoundError, KeyError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not traces:
|
||||||
|
return {'error': 'No valid calibration standards found'}
|
||||||
|
|
||||||
|
# Add margin to Y range
|
||||||
|
y_margin = (y_max - y_min) * 0.1
|
||||||
|
y_min = max(y_min - y_margin, -120)
|
||||||
|
y_max = min(y_max + y_margin, 20)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'data': traces,
|
||||||
|
'layout': {
|
||||||
|
'title': 'Calibration Standards Comparison',
|
||||||
|
'xaxis': {
|
||||||
|
'title': 'Frequency (GHz)',
|
||||||
|
'showgrid': True,
|
||||||
|
'gridcolor': '#e5e5e5',
|
||||||
|
'gridwidth': 1
|
||||||
|
},
|
||||||
|
'yaxis': {
|
||||||
|
'title': 'Magnitude (dB)',
|
||||||
|
'range': [y_min, y_max],
|
||||||
|
'showgrid': True,
|
||||||
|
'gridcolor': '#e5e5e5',
|
||||||
|
'gridwidth': 1
|
||||||
|
},
|
||||||
|
'plot_bgcolor': '#fafafa',
|
||||||
|
'paper_bgcolor': '#ffffff',
|
||||||
|
'font': {
|
||||||
|
'family': 'Arial, sans-serif',
|
||||||
|
'size': 12,
|
||||||
|
'color': '#333333'
|
||||||
|
},
|
||||||
|
'hovermode': 'x unified',
|
||||||
|
'showlegend': True,
|
||||||
|
'margin': {'l': 60, 'r': 40, 't': 60, 'b': 60}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -866,6 +866,37 @@ input:checked + .processor-param__toggle-slider:before {
|
|||||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chart-setting__button {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
background: var(--color-primary-600);
|
||||||
|
border: 1px solid var(--color-primary-600);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-setting__button:hover {
|
||||||
|
background: var(--color-primary-700);
|
||||||
|
border-color: var(--color-primary-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-setting__button:active {
|
||||||
|
background: var(--color-primary-800);
|
||||||
|
border-color: var(--color-primary-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-setting__button:disabled {
|
||||||
|
background: var(--color-bg-muted);
|
||||||
|
border-color: var(--color-border-muted);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
/* Mobile Responsive Styles for Chart Settings */
|
/* Mobile Responsive Styles for Chart Settings */
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.chart-card__content {
|
.chart-card__content {
|
||||||
@ -900,3 +931,147 @@ input:checked + .processor-param__toggle-slider:before {
|
|||||||
max-height: 150px;
|
max-height: 150px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal--active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__backdrop {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.75);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__content {
|
||||||
|
position: relative;
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: var(--shadow-2xl);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
width: 600px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__content--large {
|
||||||
|
width: 1200px;
|
||||||
|
max-width: 95vw;
|
||||||
|
height: 80vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-6);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
background-color: var(--color-surface-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__close {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__close:hover {
|
||||||
|
background-color: var(--color-surface-hover);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__body {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Plots Container */
|
||||||
|
.plots-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plots-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plots-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Calibration Actions */
|
||||||
|
.calibration-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Plot Error Styles */
|
||||||
|
.plot-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 200px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-4);
|
||||||
|
background-color: var(--color-surface-accent);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px dashed var(--color-border);
|
||||||
|
}
|
||||||
@ -161,11 +161,6 @@ class VNADashboard {
|
|||||||
this.savePreferences();
|
this.savePreferences();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Export
|
|
||||||
this.ui.onExportData(() => {
|
|
||||||
console.log('📊 Exporting chart data...');
|
|
||||||
this.exportData();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -217,37 +212,6 @@ class VNADashboard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Export data functionality
|
|
||||||
*/
|
|
||||||
exportData() {
|
|
||||||
try {
|
|
||||||
const data = this.charts.exportData();
|
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
||||||
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `vna-data-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.json`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
|
|
||||||
this.notifications.show({
|
|
||||||
type: 'success',
|
|
||||||
title: 'Export Complete',
|
|
||||||
message: 'Chart data exported successfully'
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Export failed:', error);
|
|
||||||
this.notifications.show({
|
|
||||||
type: 'error',
|
|
||||||
title: 'Export Failed',
|
|
||||||
message: 'Failed to export chart data'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean up old/invalid preferences
|
* Clean up old/invalid preferences
|
||||||
|
|||||||
@ -408,6 +408,23 @@ export class ChartManager {
|
|||||||
</div>
|
</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:
|
default:
|
||||||
return `
|
return `
|
||||||
<div class="chart-setting" data-param="${param.name}">
|
<div class="chart-setting" data-param="${param.name}">
|
||||||
@ -432,8 +449,14 @@ export class ChartManager {
|
|||||||
this.handleSettingChange(e, processorId);
|
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('input', onParamChange);
|
||||||
settingsContainer.addEventListener('change', onParamChange);
|
settingsContainer.addEventListener('change', onParamChange);
|
||||||
|
settingsContainer.addEventListener('click', onButtonClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -504,6 +527,47 @@ export class ChartManager {
|
|||||||
}, 300); // 300ms delay to prevent rapid updates
|
}, 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); }
|
toggleProcessor(id, enabled) { enabled ? this.showChart(id) : this.hideChart(id); }
|
||||||
showChart(id) {
|
showChart(id) {
|
||||||
const c = this.charts.get(id);
|
const c = this.charts.get(id);
|
||||||
@ -541,18 +605,195 @@ export class ChartManager {
|
|||||||
this.updateEmptyStateVisibility();
|
this.updateEmptyStateVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadChart(id) {
|
async downloadChart(id) {
|
||||||
const c = this.charts.get(id);
|
const c = this.charts.get(id);
|
||||||
if (!c?.plotContainer) return;
|
if (!c?.plotContainer) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Plotly.downloadImage(c.plotContainer, { format: 'png', width: 1200, height: 800, filename: `${id}-${Date.now()}` });
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
this.notifications?.show?.({ type: 'success', title: 'Download Started', message: 'Chart image download started' });
|
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) {
|
} catch (e) {
|
||||||
console.error('❌ Chart download failed:', e);
|
console.error('❌ Chart download failed:', e);
|
||||||
this.notifications?.show?.({ type: 'error', title: 'Download Failed', message: 'Failed to download chart image' });
|
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) {
|
toggleFullscreen(id) {
|
||||||
const c = this.charts.get(id);
|
const c = this.charts.get(id);
|
||||||
if (!c?.element) return;
|
if (!c?.element) return;
|
||||||
@ -592,41 +833,6 @@ export class ChartManager {
|
|||||||
pause() { this.isPaused = true; console.log('⏸️ Chart updates paused'); }
|
pause() { this.isPaused = true; console.log('⏸️ Chart updates paused'); }
|
||||||
resume() { this.isPaused = false; console.log('▶️ Chart updates resumed'); if (this.updateQueue.size) this.processUpdateQueue(); }
|
resume() { this.isPaused = false; console.log('▶️ Chart updates resumed'); if (this.updateQueue.size) this.processUpdateQueue(); }
|
||||||
|
|
||||||
exportData() {
|
|
||||||
const exportData = {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
charts: {},
|
|
||||||
stats: this.performanceStats,
|
|
||||||
description: "Current chart data snapshot - latest data from each visible processor"
|
|
||||||
};
|
|
||||||
|
|
||||||
// Export only the latest data from each visible chart (not all historical data)
|
|
||||||
for (const [processorId, chart] of this.charts) {
|
|
||||||
// Skip hidden/disabled processors
|
|
||||||
if (!chart.isVisible || this.disabledProcessors.has(processorId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const latestData = this.chartData.get(processorId);
|
|
||||||
if (!latestData) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
exportData.charts[processorId] = {
|
|
||||||
processor_name: this.formatProcessorName(processorId),
|
|
||||||
current_data: {
|
|
||||||
data: latestData.data,
|
|
||||||
metadata: latestData.metadata,
|
|
||||||
timestamp: latestData.timestamp.toISOString()
|
|
||||||
},
|
|
||||||
plotly_config: this.getCurrentPlotlyData(processorId),
|
|
||||||
settings: this.getProcessorSettings(processorId)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`📊 Exporting current data for ${Object.keys(exportData.charts).length} visible charts`);
|
|
||||||
return exportData;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current Plotly data/layout for a processor
|
* Get current Plotly data/layout for a processor
|
||||||
@ -654,6 +860,60 @@ export class ChartManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
* Get current processor settings (UI parameters)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -29,6 +29,11 @@ export class SettingsManager {
|
|||||||
this.handleSaveCalibration = this.handleSaveCalibration.bind(this);
|
this.handleSaveCalibration = this.handleSaveCalibration.bind(this);
|
||||||
this.handleSetCalibration = this.handleSetCalibration.bind(this);
|
this.handleSetCalibration = this.handleSetCalibration.bind(this);
|
||||||
this.handleCalibrationChange = this.handleCalibrationChange.bind(this);
|
this.handleCalibrationChange = this.handleCalibrationChange.bind(this);
|
||||||
|
this.handleViewPlots = this.handleViewPlots.bind(this);
|
||||||
|
this.handleViewCurrentPlots = this.handleViewCurrentPlots.bind(this);
|
||||||
|
|
||||||
|
// Store current plots data for download
|
||||||
|
this.currentPlotsData = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
@ -73,6 +78,13 @@ export class SettingsManager {
|
|||||||
saveCalibrationBtn: document.getElementById('saveCalibrationBtn'),
|
saveCalibrationBtn: document.getElementById('saveCalibrationBtn'),
|
||||||
calibrationDropdown: document.getElementById('calibrationDropdown'),
|
calibrationDropdown: document.getElementById('calibrationDropdown'),
|
||||||
setCalibrationBtn: document.getElementById('setCalibrationBtn'),
|
setCalibrationBtn: document.getElementById('setCalibrationBtn'),
|
||||||
|
viewPlotsBtn: document.getElementById('viewPlotsBtn'),
|
||||||
|
viewCurrentPlotsBtn: document.getElementById('viewCurrentPlotsBtn'),
|
||||||
|
|
||||||
|
// Modal elements
|
||||||
|
plotsModal: document.getElementById('plotsModal'),
|
||||||
|
plotsGrid: document.getElementById('plotsGrid'),
|
||||||
|
downloadAllBtn: document.getElementById('downloadAllBtn'),
|
||||||
|
|
||||||
// Status elements
|
// Status elements
|
||||||
presetCount: document.getElementById('presetCount'),
|
presetCount: document.getElementById('presetCount'),
|
||||||
@ -91,6 +103,8 @@ export class SettingsManager {
|
|||||||
this.elements.saveCalibrationBtn?.addEventListener('click', this.handleSaveCalibration);
|
this.elements.saveCalibrationBtn?.addEventListener('click', this.handleSaveCalibration);
|
||||||
this.elements.calibrationDropdown?.addEventListener('change', this.handleCalibrationChange);
|
this.elements.calibrationDropdown?.addEventListener('change', this.handleCalibrationChange);
|
||||||
this.elements.setCalibrationBtn?.addEventListener('click', this.handleSetCalibration);
|
this.elements.setCalibrationBtn?.addEventListener('click', this.handleSetCalibration);
|
||||||
|
this.elements.viewPlotsBtn?.addEventListener('click', this.handleViewPlots);
|
||||||
|
this.elements.viewCurrentPlotsBtn?.addEventListener('click', this.handleViewCurrentPlots);
|
||||||
|
|
||||||
// Calibration name input
|
// Calibration name input
|
||||||
this.elements.calibrationNameInput?.addEventListener('input', () => {
|
this.elements.calibrationNameInput?.addEventListener('input', () => {
|
||||||
@ -211,6 +225,7 @@ export class SettingsManager {
|
|||||||
|
|
||||||
dropdown.disabled = false;
|
dropdown.disabled = false;
|
||||||
this.elements.setCalibrationBtn.disabled = true;
|
this.elements.setCalibrationBtn.disabled = true;
|
||||||
|
this.elements.viewPlotsBtn.disabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
formatPresetDisplay(preset) {
|
formatPresetDisplay(preset) {
|
||||||
@ -280,11 +295,22 @@ export class SettingsManager {
|
|||||||
const hasName = this.elements.calibrationNameInput.value.trim().length > 0;
|
const hasName = this.elements.calibrationNameInput.value.trim().length > 0;
|
||||||
this.elements.saveCalibrationBtn.disabled = !hasName || !workingCalibration.is_complete;
|
this.elements.saveCalibrationBtn.disabled = !hasName || !workingCalibration.is_complete;
|
||||||
this.elements.calibrationNameInput.disabled = false;
|
this.elements.calibrationNameInput.disabled = false;
|
||||||
|
|
||||||
|
// Enable "View Current Plots" button if there are any completed standards
|
||||||
|
const hasCompletedStandards = workingCalibration.completed_standards && workingCalibration.completed_standards.length > 0;
|
||||||
|
if (this.elements.viewCurrentPlotsBtn) {
|
||||||
|
this.elements.viewCurrentPlotsBtn.disabled = !hasCompletedStandards;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hideCalibrationSteps() {
|
hideCalibrationSteps() {
|
||||||
this.elements.calibrationSteps.style.display = 'none';
|
this.elements.calibrationSteps.style.display = 'none';
|
||||||
this.elements.calibrationStandards.innerHTML = '';
|
this.elements.calibrationStandards.innerHTML = '';
|
||||||
|
|
||||||
|
// Disable "View Current Plots" button
|
||||||
|
if (this.elements.viewCurrentPlotsBtn) {
|
||||||
|
this.elements.viewCurrentPlotsBtn.disabled = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resetCalibrationState() {
|
resetCalibrationState() {
|
||||||
@ -592,6 +618,7 @@ export class SettingsManager {
|
|||||||
handleCalibrationChange() {
|
handleCalibrationChange() {
|
||||||
const selectedValue = this.elements.calibrationDropdown.value;
|
const selectedValue = this.elements.calibrationDropdown.value;
|
||||||
this.elements.setCalibrationBtn.disabled = !selectedValue;
|
this.elements.setCalibrationBtn.disabled = !selectedValue;
|
||||||
|
this.elements.viewPlotsBtn.disabled = !selectedValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleSetCalibration() {
|
async handleSetCalibration() {
|
||||||
@ -638,6 +665,629 @@ export class SettingsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async handleViewPlots() {
|
||||||
|
const calibrationName = this.elements.calibrationDropdown.value;
|
||||||
|
if (!calibrationName || !this.currentPreset) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.elements.viewPlotsBtn.disabled = true;
|
||||||
|
this.elements.viewPlotsBtn.innerHTML = '<i data-lucide="loader"></i> Loading...';
|
||||||
|
|
||||||
|
// Fetch plots data
|
||||||
|
const response = await fetch(`/api/v1/settings/calibration/${encodeURIComponent(calibrationName)}/standards-plots?preset_filename=${encodeURIComponent(this.currentPreset.filename)}`);
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
|
||||||
|
const plotsData = await response.json();
|
||||||
|
|
||||||
|
// Show modal with plots
|
||||||
|
this.showPlotsModal(plotsData);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load calibration plots:', error);
|
||||||
|
this.notifications.show({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Plots Error',
|
||||||
|
message: 'Failed to load calibration plots'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.elements.viewPlotsBtn.disabled = false;
|
||||||
|
this.elements.viewPlotsBtn.innerHTML = '<i data-lucide="bar-chart-3"></i> View Plots';
|
||||||
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleViewCurrentPlots() {
|
||||||
|
if (!this.workingCalibration || !this.workingCalibration.active) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.elements.viewCurrentPlotsBtn.disabled = true;
|
||||||
|
this.elements.viewCurrentPlotsBtn.innerHTML = '<i data-lucide="loader"></i> Loading...';
|
||||||
|
|
||||||
|
// Fetch current working calibration plots
|
||||||
|
const response = await fetch('/api/v1/settings/working-calibration/standards-plots');
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
this.notifications.show({
|
||||||
|
type: 'warning',
|
||||||
|
title: 'No Data',
|
||||||
|
message: 'No working calibration or standards available to plot'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plotsData = await response.json();
|
||||||
|
|
||||||
|
// Show modal with plots
|
||||||
|
this.showPlotsModal(plotsData);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load current calibration plots:', error);
|
||||||
|
this.notifications.show({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Plots Error',
|
||||||
|
message: 'Failed to load current calibration plots'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.elements.viewCurrentPlotsBtn.disabled = false;
|
||||||
|
this.elements.viewCurrentPlotsBtn.innerHTML = '<i data-lucide="bar-chart-3"></i> View Current Plots';
|
||||||
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showPlotsModal(plotsData) {
|
||||||
|
const modal = this.elements.plotsModal;
|
||||||
|
if (!modal) return;
|
||||||
|
|
||||||
|
// Setup modal close handlers
|
||||||
|
this.setupModalCloseHandlers(modal);
|
||||||
|
|
||||||
|
// Store plots data for download
|
||||||
|
this.currentPlotsData = plotsData;
|
||||||
|
|
||||||
|
// Render plots using chart manager style
|
||||||
|
this.renderCalibrationPlots(plotsData.individual_plots, plotsData.preset);
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
modal.classList.add('modal--active');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
// Update modal title
|
||||||
|
const title = modal.querySelector('.modal__title');
|
||||||
|
if (title) {
|
||||||
|
title.innerHTML = `
|
||||||
|
<i data-lucide="bar-chart-3"></i>
|
||||||
|
${plotsData.calibration_name} - ${plotsData.preset.mode.toUpperCase()} Standards
|
||||||
|
`;
|
||||||
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupModalCloseHandlers(modal) {
|
||||||
|
const closeElements = modal.querySelectorAll('[data-modal-close]');
|
||||||
|
closeElements.forEach(element => {
|
||||||
|
element.addEventListener('click', () => this.closePlotsModal());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup download all button
|
||||||
|
const downloadAllBtn = modal.querySelector('#downloadAllBtn');
|
||||||
|
if (downloadAllBtn) {
|
||||||
|
downloadAllBtn.addEventListener('click', () => this.downloadAllCalibrationData());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close on Escape key
|
||||||
|
const escapeHandler = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
this.closePlotsModal();
|
||||||
|
document.removeEventListener('keydown', escapeHandler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', escapeHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCalibrationPlots(individualPlots, preset) {
|
||||||
|
const container = this.elements.plotsGrid;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
if (!individualPlots || Object.keys(individualPlots).length === 0) {
|
||||||
|
container.innerHTML = '<div class="plot-error">No calibration plots available</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.entries(individualPlots).forEach(([standardName, plotConfig]) => {
|
||||||
|
if (plotConfig.error) {
|
||||||
|
const errorDiv = document.createElement('div');
|
||||||
|
errorDiv.className = 'chart-card';
|
||||||
|
errorDiv.innerHTML = `
|
||||||
|
<div class="chart-card__header">
|
||||||
|
<div class="chart-card__title">
|
||||||
|
<i data-lucide="alert-circle" class="chart-card__icon"></i>
|
||||||
|
${standardName.toUpperCase()} Standard
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-card__content">
|
||||||
|
<div class="plot-error">Error: ${plotConfig.error}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(errorDiv);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const card = this.createCalibrationChartCard(standardName, plotConfig, preset);
|
||||||
|
container.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof lucide !== 'undefined') {
|
||||||
|
lucide.createIcons({ attrs: { 'stroke-width': 1.5 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createCalibrationChartCard(standardName, plotConfig, preset) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'chart-card';
|
||||||
|
card.dataset.standard = standardName;
|
||||||
|
|
||||||
|
const standardTitle = `${standardName.toUpperCase()} Standard`;
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="chart-card__header">
|
||||||
|
<div class="chart-card__title">
|
||||||
|
<i data-lucide="bar-chart-3" class="chart-card__icon"></i>
|
||||||
|
${standardTitle}
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-card__content">
|
||||||
|
<div class="chart-card__plot" id="calibration-plot-${standardName}"></div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-card__meta">
|
||||||
|
<div class="chart-card__timestamp">Standard: ${standardName.toUpperCase()}</div>
|
||||||
|
<div class="chart-card__sweep">Preset: ${preset?.filename || 'Unknown'}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Setup event handlers
|
||||||
|
this.setupCalibrationChartEvents(card, standardName);
|
||||||
|
|
||||||
|
// Render the plot
|
||||||
|
const plotContainer = card.querySelector('.chart-card__plot');
|
||||||
|
this.renderCalibrationChart(plotContainer, plotConfig, standardTitle);
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
setupCalibrationChartEvents(card, standardName) {
|
||||||
|
card.addEventListener('click', (e) => {
|
||||||
|
const action = e.target.closest('[data-action]')?.dataset.action;
|
||||||
|
if (!action) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const plotContainer = card.querySelector('.chart-card__plot');
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'fullscreen':
|
||||||
|
this.toggleCalibrationFullscreen(card);
|
||||||
|
break;
|
||||||
|
case 'download':
|
||||||
|
this.downloadCalibrationStandard(standardName, plotContainer);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCalibrationChart(container, plotConfig, title) {
|
||||||
|
if (!container || !plotConfig || plotConfig.error) {
|
||||||
|
container.innerHTML = `<div class="plot-error">Failed to load plot: ${plotConfig?.error || 'Unknown error'}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layout = {
|
||||||
|
...plotConfig.layout,
|
||||||
|
title: { text: title, font: { size: 16, color: '#f1f5f9' } },
|
||||||
|
plot_bgcolor: 'transparent',
|
||||||
|
paper_bgcolor: 'transparent',
|
||||||
|
font: { family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif', size: 12, color: '#f1f5f9' },
|
||||||
|
autosize: true,
|
||||||
|
width: null,
|
||||||
|
height: null,
|
||||||
|
margin: { l: 60, r: 50, t: 50, b: 60 },
|
||||||
|
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: {
|
||||||
|
...plotConfig.layout.xaxis,
|
||||||
|
gridcolor: '#334155',
|
||||||
|
zerolinecolor: '#475569',
|
||||||
|
color: '#cbd5e1',
|
||||||
|
fixedrange: false
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
...plotConfig.layout.yaxis,
|
||||||
|
gridcolor: '#334155',
|
||||||
|
zerolinecolor: '#475569',
|
||||||
|
color: '#cbd5e1',
|
||||||
|
fixedrange: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
displayModeBar: true,
|
||||||
|
modeBarButtonsToRemove: ['select2d','lasso2d','hoverClosestCartesian','hoverCompareCartesian','toggleSpikelines'],
|
||||||
|
displaylogo: false,
|
||||||
|
responsive: false,
|
||||||
|
doubleClick: 'reset',
|
||||||
|
toImageButtonOptions: {
|
||||||
|
format: 'png',
|
||||||
|
filename: `calibration-plot-${Date.now()}`,
|
||||||
|
height: 600,
|
||||||
|
width: 800,
|
||||||
|
scale: 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Plotly.newPlot(container, plotConfig.data, layout, config);
|
||||||
|
|
||||||
|
// Setup resize observer
|
||||||
|
if (window.ResizeObserver) {
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
if (container && container.clientWidth > 0) {
|
||||||
|
Plotly.Plots.resize(container);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ro.observe(container);
|
||||||
|
container._resizeObserver = ro;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleCalibrationFullscreen(card) {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
card.requestFullscreen()?.then(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const plotContainer = card.querySelector('.chart-card__plot');
|
||||||
|
if (plotContainer && typeof Plotly !== 'undefined') {
|
||||||
|
const rect = plotContainer.getBoundingClientRect();
|
||||||
|
Plotly.relayout(plotContainer, { width: rect.width, height: rect.height });
|
||||||
|
Plotly.Plots.resize(plotContainer);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}).catch(console.error);
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen()?.then(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const plotContainer = card.querySelector('.chart-card__plot');
|
||||||
|
if (plotContainer && typeof Plotly !== 'undefined') {
|
||||||
|
Plotly.Plots.resize(plotContainer);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadCalibrationStandard(standardName, plotContainer) {
|
||||||
|
try {
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const calibrationName = this.currentPlotsData?.calibration_name || 'unknown';
|
||||||
|
const presetName = this.currentPlotsData?.preset?.filename || 'unknown';
|
||||||
|
const baseFilename = `${calibrationName}_${standardName}_${timestamp}`;
|
||||||
|
|
||||||
|
// Download image
|
||||||
|
if (plotContainer && typeof Plotly !== 'undefined') {
|
||||||
|
await Plotly.downloadImage(plotContainer, {
|
||||||
|
format: 'png',
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
filename: `${baseFilename}_plot`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare calibration data for download
|
||||||
|
const calibrationData = this.prepareCalibrationDownloadData(standardName);
|
||||||
|
|
||||||
|
// Download data as JSON
|
||||||
|
this.downloadJSON(calibrationData, `${baseFilename}_data.json`);
|
||||||
|
|
||||||
|
// Show success notification
|
||||||
|
this.notifications.show({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Download Complete',
|
||||||
|
message: `Downloaded ${standardName.toUpperCase()} standard plot and data`
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to download calibration standard:', error);
|
||||||
|
this.notifications.show({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Download Failed',
|
||||||
|
message: 'Failed to download calibration data'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareCalibrationDownloadData(standardName) {
|
||||||
|
if (!this.currentPlotsData) return null;
|
||||||
|
|
||||||
|
const standardPlot = this.currentPlotsData.individual_plots[standardName];
|
||||||
|
|
||||||
|
return {
|
||||||
|
calibration_info: {
|
||||||
|
calibration_name: this.currentPlotsData.calibration_name,
|
||||||
|
preset: this.currentPlotsData.preset,
|
||||||
|
standard_name: standardName,
|
||||||
|
download_timestamp: new Date().toISOString()
|
||||||
|
},
|
||||||
|
plot_data: standardPlot ? {
|
||||||
|
data: standardPlot.data,
|
||||||
|
layout: standardPlot.layout,
|
||||||
|
error: standardPlot.error
|
||||||
|
} : null,
|
||||||
|
raw_sweep_data: this.extractRawSweepData(standardName),
|
||||||
|
metadata: {
|
||||||
|
description: `VNA calibration standard data export - ${standardName.toUpperCase()}`,
|
||||||
|
format_version: "1.0",
|
||||||
|
exported_by: "VNA System Dashboard",
|
||||||
|
contains: [
|
||||||
|
"Calibration information",
|
||||||
|
"Plot configuration (Plotly format)",
|
||||||
|
"Raw sweep measurements",
|
||||||
|
"Frequency and magnitude data"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
extractRawSweepData(standardName) {
|
||||||
|
const standardPlot = this.currentPlotsData?.individual_plots?.[standardName];
|
||||||
|
if (!standardPlot || !standardPlot.raw_sweep_data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawData = standardPlot.raw_sweep_data;
|
||||||
|
const frequencyInfo = standardPlot.frequency_info;
|
||||||
|
|
||||||
|
// Process raw complex data points into detailed format
|
||||||
|
const processedPoints = [];
|
||||||
|
if (rawData.points && rawData.points.length > 0) {
|
||||||
|
for (let i = 0; i < rawData.points.length; i++) {
|
||||||
|
const [real, imag] = rawData.points[i];
|
||||||
|
const magnitude_linear = Math.sqrt(real * real + imag * imag);
|
||||||
|
const magnitude_db = magnitude_linear > 0 ? 20 * Math.log10(magnitude_linear) : -120;
|
||||||
|
const phase_radians = Math.atan2(imag, real);
|
||||||
|
const phase_degrees = phase_radians * (180 / Math.PI);
|
||||||
|
|
||||||
|
// Calculate frequency
|
||||||
|
let frequency_hz = 0;
|
||||||
|
if (frequencyInfo && frequencyInfo.start_freq && frequencyInfo.stop_freq) {
|
||||||
|
frequency_hz = frequencyInfo.start_freq +
|
||||||
|
(frequencyInfo.stop_freq - frequencyInfo.start_freq) * i / (rawData.points.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
processedPoints.push({
|
||||||
|
point_index: i,
|
||||||
|
frequency_hz: frequency_hz,
|
||||||
|
frequency_ghz: frequency_hz / 1e9,
|
||||||
|
complex_data: {
|
||||||
|
real: real,
|
||||||
|
imaginary: imag
|
||||||
|
},
|
||||||
|
magnitude: {
|
||||||
|
linear: magnitude_linear,
|
||||||
|
db: magnitude_db
|
||||||
|
},
|
||||||
|
phase: {
|
||||||
|
radians: phase_radians,
|
||||||
|
degrees: phase_degrees
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
standard_name: standardName,
|
||||||
|
sweep_info: {
|
||||||
|
sweep_number: rawData.sweep_number,
|
||||||
|
timestamp: rawData.timestamp,
|
||||||
|
total_points: rawData.total_points,
|
||||||
|
file_path: rawData.file_path
|
||||||
|
},
|
||||||
|
frequency_info: frequencyInfo,
|
||||||
|
measurement_points: processedPoints,
|
||||||
|
statistics: {
|
||||||
|
total_points: processedPoints.length,
|
||||||
|
frequency_range: {
|
||||||
|
start_hz: processedPoints[0]?.frequency_hz || 0,
|
||||||
|
stop_hz: processedPoints[processedPoints.length - 1]?.frequency_hz || 0
|
||||||
|
},
|
||||||
|
magnitude_range_db: {
|
||||||
|
min: Math.min(...processedPoints.map(p => p.magnitude.db)),
|
||||||
|
max: Math.max(...processedPoints.map(p => p.magnitude.db))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadAllCalibrationData() {
|
||||||
|
if (!this.currentPlotsData) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const calibrationName = this.currentPlotsData.calibration_name || 'unknown';
|
||||||
|
const baseFilename = `${calibrationName}_complete_${timestamp}`;
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
const downloadAllBtn = this.elements.downloadAllBtn;
|
||||||
|
if (downloadAllBtn) {
|
||||||
|
downloadAllBtn.disabled = true;
|
||||||
|
downloadAllBtn.innerHTML = '<i data-lucide="loader"></i> Downloading...';
|
||||||
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare complete calibration export
|
||||||
|
const completeData = this.prepareCompleteCalibrationData();
|
||||||
|
|
||||||
|
// Download complete data as JSON
|
||||||
|
this.downloadJSON(completeData, `${baseFilename}.json`);
|
||||||
|
|
||||||
|
// Also download individual plots as images
|
||||||
|
await this.downloadAllPlotImages(baseFilename);
|
||||||
|
|
||||||
|
// Show success notification
|
||||||
|
this.notifications.show({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Complete Download',
|
||||||
|
message: `Downloaded complete calibration data and all plots for ${calibrationName}`
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to download complete calibration data:', error);
|
||||||
|
this.notifications.show({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Download Failed',
|
||||||
|
message: 'Failed to download complete calibration data'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
// Restore button state
|
||||||
|
const downloadAllBtn = this.elements.downloadAllBtn;
|
||||||
|
if (downloadAllBtn) {
|
||||||
|
downloadAllBtn.disabled = false;
|
||||||
|
downloadAllBtn.innerHTML = '<i data-lucide="download-cloud"></i> Download All';
|
||||||
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareCompleteCalibrationData() {
|
||||||
|
if (!this.currentPlotsData) return null;
|
||||||
|
|
||||||
|
const allStandardsData = {};
|
||||||
|
Object.keys(this.currentPlotsData.individual_plots).forEach(standardName => {
|
||||||
|
allStandardsData[standardName] = this.prepareCalibrationDownloadData(standardName);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
export_info: {
|
||||||
|
export_timestamp: new Date().toISOString(),
|
||||||
|
export_type: "complete_calibration",
|
||||||
|
calibration_name: this.currentPlotsData.calibration_name,
|
||||||
|
preset: this.currentPlotsData.preset,
|
||||||
|
standards_included: Object.keys(allStandardsData),
|
||||||
|
format_version: "1.0"
|
||||||
|
},
|
||||||
|
calibration_summary: {
|
||||||
|
name: this.currentPlotsData.calibration_name,
|
||||||
|
preset: this.currentPlotsData.preset,
|
||||||
|
total_standards: Object.keys(allStandardsData).length,
|
||||||
|
standards: Object.keys(allStandardsData).map(name => ({
|
||||||
|
name: name,
|
||||||
|
has_data: allStandardsData[name] !== null,
|
||||||
|
has_error: allStandardsData[name]?.plot_data?.error ? true : false
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
standards_data: allStandardsData,
|
||||||
|
metadata: {
|
||||||
|
description: "Complete VNA calibration data export including all standards",
|
||||||
|
exported_by: "VNA System Dashboard",
|
||||||
|
contains: [
|
||||||
|
"Complete calibration information",
|
||||||
|
"All calibration standards data",
|
||||||
|
"Raw sweep measurements for each standard",
|
||||||
|
"Plot configurations (Plotly format)",
|
||||||
|
"Frequency and magnitude data",
|
||||||
|
"Complex impedance measurements"
|
||||||
|
],
|
||||||
|
usage_notes: [
|
||||||
|
"This file contains all data for the calibration set",
|
||||||
|
"Individual standard data is available in the 'standards_data' section",
|
||||||
|
"Raw complex measurements are in [real, imaginary] format",
|
||||||
|
"Frequencies are provided in both Hz and GHz",
|
||||||
|
"Magnitudes are provided in both linear and dB scales"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadAllPlotImages(baseFilename) {
|
||||||
|
const plotContainers = this.elements.plotsModal.querySelectorAll('[id^="calibration-plot-"]');
|
||||||
|
const downloadPromises = [];
|
||||||
|
|
||||||
|
plotContainers.forEach(container => {
|
||||||
|
if (container && typeof Plotly !== 'undefined' && container._fullData) {
|
||||||
|
const standardName = container.id.replace('calibration-plot-', '');
|
||||||
|
const promise = Plotly.downloadImage(container, {
|
||||||
|
format: 'png',
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
filename: `${baseFilename}_${standardName}_plot`
|
||||||
|
});
|
||||||
|
downloadPromises.push(promise);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for all image downloads to complete
|
||||||
|
await Promise.all(downloadPromises);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
closePlotsModal() {
|
||||||
|
const modal = this.elements.plotsModal;
|
||||||
|
if (!modal) return;
|
||||||
|
|
||||||
|
modal.classList.remove('modal--active');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
|
||||||
|
// Clean up plots and resize observers
|
||||||
|
if (typeof Plotly !== 'undefined') {
|
||||||
|
const plotContainers = modal.querySelectorAll('[id^="calibration-plot-"]');
|
||||||
|
plotContainers.forEach(container => {
|
||||||
|
if (container._resizeObserver) {
|
||||||
|
container._resizeObserver.disconnect();
|
||||||
|
container._resizeObserver = null;
|
||||||
|
}
|
||||||
|
if (container._fullData) {
|
||||||
|
Plotly.purge(container);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear plots data
|
||||||
|
this.currentPlotsData = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Public methods for external use
|
// Public methods for external use
|
||||||
async refresh() {
|
async refresh() {
|
||||||
if (!this.isInitialized) return;
|
if (!this.isInitialized) return;
|
||||||
|
|||||||
@ -15,7 +15,6 @@ export class UIManager {
|
|||||||
processorToggles: null,
|
processorToggles: null,
|
||||||
sweepCount: null,
|
sweepCount: null,
|
||||||
dataRate: null,
|
dataRate: null,
|
||||||
exportBtn: null,
|
|
||||||
navButtons: null,
|
navButtons: null,
|
||||||
views: null
|
views: null
|
||||||
};
|
};
|
||||||
@ -30,7 +29,6 @@ export class UIManager {
|
|||||||
this.eventHandlers = {
|
this.eventHandlers = {
|
||||||
viewChange: [],
|
viewChange: [],
|
||||||
processorToggle: [],
|
processorToggle: [],
|
||||||
exportData: []
|
|
||||||
};
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -70,12 +68,11 @@ export class UIManager {
|
|||||||
findElements() {
|
findElements() {
|
||||||
this.elements.connectionStatus = document.getElementById('connectionStatus');
|
this.elements.connectionStatus = document.getElementById('connectionStatus');
|
||||||
this.elements.processorToggles = document.getElementById('processorToggles');
|
this.elements.processorToggles = document.getElementById('processorToggles');
|
||||||
this.elements.exportBtn = document.getElementById('exportBtn');
|
|
||||||
this.elements.navButtons = document.querySelectorAll('.nav-btn[data-view]');
|
this.elements.navButtons = document.querySelectorAll('.nav-btn[data-view]');
|
||||||
this.elements.views = document.querySelectorAll('.view');
|
this.elements.views = document.querySelectorAll('.view');
|
||||||
|
|
||||||
// Validate required elements
|
// Validate required elements
|
||||||
const required = ['connectionStatus', 'processorToggles', 'exportBtn'];
|
const required = ['connectionStatus', 'processorToggles'];
|
||||||
for (const key of required) {
|
for (const key of required) {
|
||||||
if (!this.elements[key]) {
|
if (!this.elements[key]) {
|
||||||
throw new Error(`Required UI element not found: ${key}`);
|
throw new Error(`Required UI element not found: ${key}`);
|
||||||
@ -95,11 +92,6 @@ export class UIManager {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Export button
|
|
||||||
this.elements.exportBtn.addEventListener('click', () => {
|
|
||||||
this.triggerExportData();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Processor toggles container (event delegation)
|
// Processor toggles container (event delegation)
|
||||||
this.elements.processorToggles.addEventListener('click', (e) => {
|
this.elements.processorToggles.addEventListener('click', (e) => {
|
||||||
const toggle = e.target.closest('.processor-toggle');
|
const toggle = e.target.closest('.processor-toggle');
|
||||||
@ -402,10 +394,6 @@ export class UIManager {
|
|||||||
.join(' ');
|
.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
triggerExportData() {
|
|
||||||
this.emitEvent('exportData');
|
|
||||||
}
|
|
||||||
|
|
||||||
handleResize() {
|
handleResize() {
|
||||||
console.log('📱 Window resized');
|
console.log('📱 Window resized');
|
||||||
// Charts handle their own resize
|
// Charts handle their own resize
|
||||||
@ -419,7 +407,6 @@ export class UIManager {
|
|||||||
// Events
|
// Events
|
||||||
onViewChange(callback) { this.eventHandlers.viewChange.push(callback); }
|
onViewChange(callback) { this.eventHandlers.viewChange.push(callback); }
|
||||||
onProcessorToggle(callback) { this.eventHandlers.processorToggle.push(callback); }
|
onProcessorToggle(callback) { this.eventHandlers.processorToggle.push(callback); }
|
||||||
onExportData(callback) { this.eventHandlers.exportData.push(callback); }
|
|
||||||
|
|
||||||
emitEvent(eventType, ...args) {
|
emitEvent(eventType, ...args) {
|
||||||
if (this.eventHandlers[eventType]) {
|
if (this.eventHandlers[eventType]) {
|
||||||
|
|||||||
@ -107,15 +107,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="controls-group">
|
|
||||||
<label class="controls-label">Actions</label>
|
|
||||||
<div class="display-controls">
|
|
||||||
<button class="btn btn--primary btn--bordered" id="exportBtn">
|
|
||||||
<i data-lucide="download"></i>
|
|
||||||
Export Data
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -219,6 +210,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="calibration-actions">
|
<div class="calibration-actions">
|
||||||
|
<button class="btn btn--secondary" id="viewCurrentPlotsBtn" disabled>
|
||||||
|
<i data-lucide="bar-chart-3"></i>
|
||||||
|
View Current Plots
|
||||||
|
</button>
|
||||||
<input type="text" class="calibration-name-input" id="calibrationNameInput" placeholder="Enter calibration name" disabled>
|
<input type="text" class="calibration-name-input" id="calibrationNameInput" placeholder="Enter calibration name" disabled>
|
||||||
<button class="btn btn--success" id="saveCalibrationBtn" disabled>
|
<button class="btn btn--success" id="saveCalibrationBtn" disabled>
|
||||||
<i data-lucide="save"></i>
|
<i data-lucide="save"></i>
|
||||||
@ -234,10 +229,16 @@
|
|||||||
<select class="calibration-dropdown" id="calibrationDropdown" disabled>
|
<select class="calibration-dropdown" id="calibrationDropdown" disabled>
|
||||||
<option value="">No calibrations available</option>
|
<option value="">No calibrations available</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="btn btn--primary" id="setCalibrationBtn" disabled>
|
<div class="calibration-actions">
|
||||||
<i data-lucide="check"></i>
|
<button class="btn btn--primary" id="setCalibrationBtn" disabled>
|
||||||
Set Active
|
<i data-lucide="check"></i>
|
||||||
</button>
|
Set Active
|
||||||
|
</button>
|
||||||
|
<button class="btn btn--secondary" id="viewPlotsBtn" disabled>
|
||||||
|
<i data-lucide="bar-chart-3"></i>
|
||||||
|
View Plots
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -266,6 +267,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- Calibration Plots Modal -->
|
||||||
|
<div class="modal" id="plotsModal">
|
||||||
|
<div class="modal__backdrop" data-modal-close></div>
|
||||||
|
<div class="modal__content modal__content--large">
|
||||||
|
<div class="modal__header">
|
||||||
|
<h2 class="modal__title">
|
||||||
|
<i data-lucide="bar-chart-3"></i>
|
||||||
|
Calibration Standards Plots
|
||||||
|
</h2>
|
||||||
|
<div class="modal__actions">
|
||||||
|
<button class="btn btn--secondary btn--sm" id="downloadAllBtn" title="Download all calibration data">
|
||||||
|
<i data-lucide="download-cloud"></i>
|
||||||
|
Download All
|
||||||
|
</button>
|
||||||
|
<button class="modal__close" data-modal-close title="Close">
|
||||||
|
<i data-lucide="x"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal__body" id="plotsModalBody">
|
||||||
|
<div class="plots-container">
|
||||||
|
<div class="plots-content">
|
||||||
|
<div class="plots-grid" id="plotsGrid">
|
||||||
|
<!-- Individual plots will be populated here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Notification System -->
|
<!-- Notification System -->
|
||||||
<div class="notifications" id="notifications"></div>
|
<div class="notifications" id="notifications"></div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user