From 95fdfa5fdb98e4c8d5a9be1f3cdfc2d73368b26b Mon Sep 17 00:00:00 2001 From: ayzen Date: Mon, 20 Oct 2025 16:33:50 +0300 Subject: [PATCH 1/3] added append and delete column feature --- vna_system/core/processors/base_processor.py | 193 +++++++++++++++++- .../core/processors/configs/bscan_config.json | 8 +- .../implementations/bscan_processor.py | 45 ++++ vna_system/core/processors/manager.py | 41 ++++ .../core/processors/websocket_handler.py | 83 ++++++++ vna_system/web_ui/static/js/main.js | 8 +- vna_system/web_ui/static/js/modules/charts.js | 140 +++++++++++-- .../js/modules/charts/bscan-click-handler.js | 117 +++++++++++ vna_system/web_ui/static/js/modules/icons.js | 7 + .../web_ui/static/js/modules/plotly-utils.js | 10 +- 10 files changed, 624 insertions(+), 28 deletions(-) create mode 100644 vna_system/web_ui/static/js/modules/charts/bscan-click-handler.js diff --git a/vna_system/core/processors/base_processor.py b/vna_system/core/processors/base_processor.py index b18ef6a..06d064e 100644 --- a/vna_system/core/processors/base_processor.py +++ b/vna_system/core/processors/base_processor.py @@ -171,6 +171,133 @@ class BaseProcessor: self._sweep_history.clear() logger.debug("History cleared") + def append_history(self, history_data: list[dict[str, Any]]) -> ProcessedResult | None: + """ + Append sweep history from external source (e.g., loaded file) to existing history. + + Parameters + ---------- + history_data: + List of history entries in the format exported by export_history_data. + + Returns + ------- + ProcessedResult | None + Result after appending history, or None if history is empty. + + Notes + ----- + - Only history is appended, processor config is NOT updated. + - If total history exceeds max_history limit, oldest entries are dropped. + """ + if not history_data: + logger.warning("Append history called with empty data") + return None + + from vna_system.core.acquisition.sweep_buffer import SweepData + from vna_system.core.settings.preset_manager import VNAMode + + with self._lock: + initial_count = len(self._sweep_history) + + # Convert imported data to internal format (same as import_history_data) + for idx, entry in enumerate(history_data): + sweep_points = entry.get("sweep_points", []) + calibrated_points = entry.get("calibrated_points", []) + reference_points = entry.get("reference_points", []) + raw_reference_points = entry.get("raw_reference_points", []) + + # Reconstruct SweepData objects + sweep_data = SweepData( + sweep_number=initial_count + idx, + timestamp=entry.get("timestamp", 0.0), + points=sweep_points, + total_points=len(sweep_points) + ) if sweep_points else None + + calibrated_data = SweepData( + sweep_number=initial_count + idx, + timestamp=entry.get("timestamp", 0.0), + points=calibrated_points, + total_points=len(calibrated_points) + ) if calibrated_points else None + + reference_data = SweepData( + sweep_number=initial_count + idx, + timestamp=entry.get("timestamp", 0.0), + points=reference_points, + total_points=len(reference_points) + ) if reference_points else None + + raw_reference_data = SweepData( + sweep_number=initial_count + idx, + timestamp=entry.get("timestamp", 0.0), + points=raw_reference_points, + total_points=len(raw_reference_points) + ) if raw_reference_points else None + + # Reconstruct calibration standards + calibration_standards = None + cal_standards_data = entry.get("calibration_standards") + if cal_standards_data: + calibration_standards = {} + for std_name, std_data in cal_standards_data.items(): + std_points = std_data.get("points", []) + if std_points: + calibration_standards[std_name] = SweepData( + sweep_number=std_data.get("sweep_number", initial_count + idx), + timestamp=std_data.get("timestamp", entry.get("timestamp", 0.0)), + points=std_points, + total_points=len(std_points) + ) + + # Reconstruct reference info + from vna_system.core.settings.reference_manager import ReferenceInfo + reference_info = None + ref_info_data = entry.get("reference_info") + if ref_info_data and ref_info_data.get("name"): + # Only create ReferenceInfo if we have required fields + try: + reference_info = ReferenceInfo( + name=ref_info_data.get("name", ""), + timestamp=datetime.fromtimestamp(ref_info_data.get("timestamp", 0.0)) if ref_info_data.get("timestamp") else datetime.now(), + preset_filename=ref_info_data.get("preset_filename", ""), + description=ref_info_data.get("description", ""), + metadata=ref_info_data.get("metadata") + ) + except Exception: + # If ReferenceInfo creation fails, skip it + reference_info = None + + # Reconstruct VNA config + vna_config = entry.get("vna_config", {}) + + # Append to history + self._sweep_history.append({ + "sweep_data": sweep_data, + "calibrated_data": calibrated_data, + "vna_config": vna_config, + "reference_data": reference_data, + "reference_info": reference_info, + "raw_reference_data": raw_reference_data, + "calibration_standards": calibration_standards, + "timestamp": entry.get("timestamp", datetime.now().timestamp()), + }) + + self._trim_history() + final_count = len(self._sweep_history) + + logger.info( + "History appended", + processor_id=self.processor_id, + added=len(history_data), + initial_count=initial_count, + final_count=final_count, + dropped=initial_count + len(history_data) - final_count + ) + + return self.recalculate() + def _trim_history(self) -> None: """Internal: keep only the newest `_max_history` items.""" if len(self._sweep_history) > self._max_history: @@ -636,17 +763,29 @@ class BaseProcessor: "timestamp": getattr(std_data, "timestamp", None), } + # Export reference info with all fields + ref_info_export = None + if reference_info: + ref_info_export = { + "name": getattr(reference_info, "name", None), + "timestamp": getattr(reference_info, "timestamp", datetime.now()).timestamp() if hasattr(reference_info, "timestamp") else None, + "preset_filename": getattr(reference_info, "preset_filename", None), + "description": getattr(reference_info, "description", None), + "metadata": getattr(reference_info, "metadata", None), + } + + # Get timestamp safely + entry_timestamp = entry.get("timestamp") + timestamp_export = float(entry_timestamp) if entry_timestamp is not None else None + exported.append({ - "timestamp": float(entry.get("timestamp")) if entry.get("timestamp") is not None else None, + "timestamp": timestamp_export, "sweep_points": self._points_to_list(getattr(sweep_data, "points", [])), "calibrated_points": self._points_to_list(getattr(calibrated_data, "points", [])), "reference_points": self._points_to_list(getattr(reference_data, "points", [])), "raw_reference_points": self._points_to_list(getattr(raw_reference_data, "points", [])), "calibration_standards": cal_standards_export, - "reference_info": { - "name": getattr(reference_info, "name", None), - "description": getattr(reference_info, "description", None), - } if reference_info else None, + "reference_info": ref_info_export, "vna_config": self._snapshot_vna_config(entry.get("vna_config")), }) @@ -671,6 +810,7 @@ class BaseProcessor: sweep_points = entry.get("sweep_points", []) calibrated_points = entry.get("calibrated_points", []) reference_points = entry.get("reference_points", []) + raw_reference_points = entry.get("raw_reference_points", []) # Reconstruct SweepData objects # Use sequential index as sweep_number since it's not stored @@ -695,6 +835,46 @@ class BaseProcessor: total_points=len(reference_points) ) if reference_points else None + raw_reference_data = SweepData( + sweep_number=idx, + timestamp=entry.get("timestamp", 0.0), + points=raw_reference_points, + total_points=len(raw_reference_points) + ) if raw_reference_points else None + + # Reconstruct calibration standards + calibration_standards = None + cal_standards_data = entry.get("calibration_standards") + if cal_standards_data: + calibration_standards = {} + for std_name, std_data in cal_standards_data.items(): + std_points = std_data.get("points", []) + if std_points: + calibration_standards[std_name] = SweepData( + sweep_number=std_data.get("sweep_number", idx), + timestamp=std_data.get("timestamp", entry.get("timestamp", 0.0)), + points=std_points, + total_points=len(std_points) + ) + + # Reconstruct reference info + from vna_system.core.settings.reference_manager import ReferenceInfo + reference_info = None + ref_info_data = entry.get("reference_info") + if ref_info_data and ref_info_data.get("name"): + # Only create ReferenceInfo if we have required fields + try: + reference_info = ReferenceInfo( + name=ref_info_data.get("name", ""), + timestamp=datetime.fromtimestamp(ref_info_data.get("timestamp", 0.0)) if ref_info_data.get("timestamp") else datetime.now(), + preset_filename=ref_info_data.get("preset_filename", ""), + description=ref_info_data.get("description", ""), + metadata=ref_info_data.get("metadata") + ) + except Exception: + # If ReferenceInfo creation fails, skip it + reference_info = None + # Restore VNAMode enum from string if needed vna_config = entry.get("vna_config", {}) if isinstance(vna_config.get("mode"), str): @@ -707,6 +887,9 @@ class BaseProcessor: "sweep_data": sweep_data, "calibrated_data": calibrated_data, "reference_data": reference_data, + "raw_reference_data": raw_reference_data, + "reference_info": reference_info, + "calibration_standards": calibration_standards, "vna_config": vna_config, "timestamp": entry.get("timestamp"), }) diff --git a/vna_system/core/processors/configs/bscan_config.json b/vna_system/core/processors/configs/bscan_config.json index 2c7b504..1f46b36 100644 --- a/vna_system/core/processors/configs/bscan_config.json +++ b/vna_system/core/processors/configs/bscan_config.json @@ -1,10 +1,10 @@ { "open_air": false, - "axis": "phase", + "axis": "abs", "cut": 0.279, - "max": 4.0, - "gain": 0.5, - "start_freq": 100.0, + "max": 1.5, + "gain": 0.7, + "start_freq": 2130.0, "stop_freq": 8230.0, "clear_history": false, "data_limit": 500 diff --git a/vna_system/core/processors/implementations/bscan_processor.py b/vna_system/core/processors/implementations/bscan_processor.py index 061834e..387e0fa 100644 --- a/vna_system/core/processors/implementations/bscan_processor.py +++ b/vna_system/core/processors/implementations/bscan_processor.py @@ -144,6 +144,51 @@ class BScanProcessor(BaseProcessor): self._plot_history.clear() logger.info("Plot and sweep history cleared completely", processor_id=self.processor_id) + def delete_column(self, column_index: int) -> bool: + """ + Delete a specific column (sweep) from the plot history. + + Parameters + ---------- + column_index : int + The 1-based column index to delete (matching the sweep number displayed to the user). + + Returns + ------- + bool + True if deletion was successful, False otherwise. + """ + try: + with self._lock: + # Convert 1-based index to 0-based + array_index = column_index - 1 + + if array_index < 0 or array_index >= len(self._plot_history): + logger.warning( + "Invalid column index for deletion", + column_index=column_index, + history_length=len(self._plot_history) + ) + return False + + # Delete from plot history + del self._plot_history[array_index] + + # Also delete from sweep history if indices match + if array_index < len(self._sweep_history): + del self._sweep_history[array_index] + + logger.info( + "Column deleted successfully", + column_index=column_index, + remaining_columns=len(self._plot_history) + ) + return True + + except Exception as exc: + logger.error("Column deletion failed", error=repr(exc), column_index=column_index) + return False + # ------------------------------------------------------------------------- # Processing # ------------------------------------------------------------------------- diff --git a/vna_system/core/processors/manager.py b/vna_system/core/processors/manager.py index cf42e9c..5b64559 100644 --- a/vna_system/core/processors/manager.py +++ b/vna_system/core/processors/manager.py @@ -206,6 +206,47 @@ class ProcessorManager: logger.error("History load error", processor_id=processor_id, error=repr(exc)) raise + def append_processor_history(self, processor_id: str, history_data: list[dict[str, Any]]) -> ProcessedResult | None: + """ + Append sweep history to existing processor history from JSON data and recalculate. + + Does NOT update processor configuration - only appends history. + + Parameters + ---------- + processor_id : str + The processor to append history to. + history_data : list[dict] + History records in the format exported by export_history_data. + + Returns + ------- + ProcessedResult | None + The result of recalculation after appending history. + """ + processor = self.get_processor(processor_id) + if not processor: + raise ValueError(f"Processor {processor_id} not found") + + try: + result = processor.append_history(history_data) + + if result: + with self._lock: + callbacks = list(self._result_callbacks) + for cb in callbacks: + try: + cb(processor_id, result) + except Exception as exc: # noqa: BLE001 + logger.error("Result callback failed", processor_id=processor_id, error=repr(exc)) + + logger.info("History appended and recalculated", processor_id=processor_id, records=len(history_data)) + return result + + except Exception as exc: # noqa: BLE001 + logger.error("History append error", processor_id=processor_id, error=repr(exc)) + raise + def build_processor_state(self, processor_id: str) -> dict[str, Any]: """Return a JSON-ready snapshot of processor state and current result.""" processor = self.get_processor(processor_id) diff --git a/vna_system/core/processors/websocket_handler.py b/vna_system/core/processors/websocket_handler.py index a338a52..a176cbc 100644 --- a/vna_system/core/processors/websocket_handler.py +++ b/vna_system/core/processors/websocket_handler.py @@ -104,6 +104,10 @@ class ProcessorWebSocketHandler: await self._handle_load_history(websocket, message) elif mtype == "get_processor_state": await self._handle_get_processor_state(websocket, message) + elif mtype == "delete_column": + await self._handle_delete_column(websocket, message) + elif mtype == "append_history": + await self._handle_append_history(websocket, message) else: await self._send_error(websocket, f"Неизвестный тип сообщения: {mtype!r}") except json.JSONDecodeError as json_error: @@ -202,6 +206,33 @@ class ProcessorWebSocketHandler: logger.error("History load failed", processor_id=processor_id, error=repr(exc)) await self._send_error(websocket, f"Загрузка истории не удалась: {exc}") + async def _handle_append_history(self, websocket: WebSocket, message: dict[str, Any]) -> None: + """ + Append sweep history from JSON data to existing processor history and recalculate. + + Does NOT update processor configuration - only appends history. + """ + processor_id = message.get("processor_id") + history_data = message.get("history_data") + + if not processor_id: + await self._send_error(websocket, "Требуется processor_id") + return + + if not history_data or not isinstance(history_data, list): + await self._send_error(websocket, "Требуется history_data (список)") + return + + try: + result = self.processor_manager.append_processor_history(processor_id, history_data) + if result: + await websocket.send_text(json.dumps(self._result_to_message(processor_id, result))) + else: + await self._send_error(websocket, f"Нет результата от процессора {processor_id} после дополнения истории") + except Exception as exc: # noqa: BLE001 + logger.error("History append failed", processor_id=processor_id, error=repr(exc)) + await self._send_error(websocket, f"Дополнение истории не удалось: {exc}") + async def _handle_get_processor_state(self, websocket: WebSocket, message: dict[str, Any]) -> None: """ Fetch complete processor state including sweep history and current data. @@ -222,6 +253,58 @@ class ProcessorWebSocketHandler: logger.error("Error getting processor state", processor_id=processor_id, error=repr(exc)) await self._send_error(websocket, f"Ошибка получения состояния процессора: {exc}") + async def _handle_delete_column(self, websocket: WebSocket, message: dict[str, Any]) -> None: + """ + Delete a specific column (sweep) from the bscan processor plot history. + """ + processor_id = message.get("processor_id") + column_index = message.get("column_index") + + if not processor_id: + await self._send_error(websocket, "Требуется processor_id") + return + + if column_index is None or not isinstance(column_index, int): + await self._send_error(websocket, "Требуется column_index (целое число)") + return + + try: + processor = self.processor_manager.get_processor(processor_id) + if processor is None: + await self._send_error(websocket, f"Процессор {processor_id} не найден") + return + + # Check if processor has delete_column method (only BScanProcessor) + if not hasattr(processor, "delete_column"): + await self._send_error( + websocket, + f"Процессор {processor_id} не поддерживает удаление столбцов" + ) + return + + # Execute deletion + success = processor.delete_column(column_index) + + if not success: + await self._send_error( + websocket, + f"Не удалось удалить столбец {column_index}" + ) + return + + # Recalculate and send updated result to all clients + result = processor.recalculate() + if result: + # Broadcast to all connected clients + message_str = json.dumps(self._result_to_message(processor_id, result)) + await self._send_to_connections(message_str) + else: + await self._send_error(websocket, "Пересчёт после удаления не удался") + + except Exception as exc: # noqa: BLE001 + logger.error("Column deletion failed", processor_id=processor_id, error=repr(exc)) + await self._send_error(websocket, f"Удаление столбца не удалось: {exc}") + def _result_to_message(self, processor_id: str, result: ProcessedResult) -> dict[str, Any]: """ Convert a `ProcessedResult` into a lightweight JSON-serializable message for broadcasting. diff --git a/vna_system/web_ui/static/js/main.js b/vna_system/web_ui/static/js/main.js index f96c21b..3c49136 100644 --- a/vna_system/web_ui/static/js/main.js +++ b/vna_system/web_ui/static/js/main.js @@ -41,12 +41,12 @@ class VNADashboard { this.storage = new StorageManager(); this.notifications = new NotificationManager(); - // Charts first (used by UI, independent of UI initialization) - this.charts = new ChartManager(this.config.charts, this.notifications); - - // WebSocket before UI (UI subscribes to WebSocket events) + // WebSocket first (needed by charts for bscan click handler) this.websocket = new WebSocketManager(this.config.websocket, this.notifications); + // Charts after websocket (uses websocket for bscan click handler) + this.charts = new ChartManager(this.config.charts, this.notifications, this.websocket); + // UI receives dependencies from outside this.ui = new UIManager(this.notifications, this.websocket, this.charts); diff --git a/vna_system/web_ui/static/js/modules/charts.js b/vna_system/web_ui/static/js/modules/charts.js index 6c09944..52864d5 100644 --- a/vna_system/web_ui/static/js/modules/charts.js +++ b/vna_system/web_ui/static/js/modules/charts.js @@ -6,6 +6,7 @@ import { formatProcessorName, safeClone, downloadJSON } from './utils.js'; import { renderIcons } from './icons.js'; import { ChartSettingsManager } from './charts/chart-settings.js'; +import { BScanClickHandler } from './charts/bscan-click-handler.js'; import { defaultPlotlyLayout, defaultPlotlyConfig, @@ -17,9 +18,10 @@ import { } from './plotly-utils.js'; export class ChartManager { - constructor(config, notifications) { + constructor(config, notifications, websocket = null) { this.config = config; this.notifications = notifications; + this.websocket = websocket; this.charts = new Map(); this.chartData = new Map(); @@ -40,6 +42,7 @@ export class ChartManager { }; this.settingsManager = new ChartSettingsManager(); + this.bscanClickHandler = new BScanClickHandler(websocket, notifications); } async init() { @@ -97,11 +100,26 @@ export class ChartManager { height: plotContainer.clientHeight || 420 }; - createPlotlyPlot(plotContainer, [], layoutOverrides); + // Disable interactivity for bscan processor + const configOverrides = processorId === 'bscan' ? { + staticPlot: false, + displayModeBar: false, + scrollZoom: false, + doubleClick: false, + showTips: false, + editable: false + } : {}; + + createPlotlyPlot(plotContainer, [], layoutOverrides, configOverrides); this.charts.set(processorId, { element: card, plotContainer, isVisible: true, settingsInitialized: false }); this.performanceStats.chartsCreated++; + // Attach click handler for bscan processor + if (processorId === 'bscan') { + this.bscanClickHandler.attachClickHandler(processorId, plotContainer); + } + if (this.config.animation) { setTimeout(() => card.classList.add('chart-card--animated'), 50); } @@ -124,7 +142,17 @@ export class ChartManager { title: { text: formatProcessorName(processorId), font: { size: 16, color: '#f1f5f9' } } }; - await updatePlotlyPlot(chart.plotContainer, plotlyConfig.data || [], layoutOverrides); + // Disable interactivity for bscan processor + const configOverrides = processorId === 'bscan' ? { + staticPlot: false, + displayModeBar: false, + scrollZoom: false, + doubleClick: false, + showTips: false, + editable: false + } : {}; + + await updatePlotlyPlot(chart.plotContainer, plotlyConfig.data || [], layoutOverrides, configOverrides); this.updateChartMetadata(processorId); @@ -178,6 +206,9 @@ export class ChartManager { + @@ -189,6 +220,7 @@ export class ChartManager { +
@@ -222,6 +254,7 @@ export class ChartManager { switch (action) { case 'fullscreen': this.toggleFullscreen(processorId); break; case 'upload': this.uploadHistory(processorId); break; + case 'append': this.appendHistory(processorId); break; case 'download': this.downloadChart(processorId); break; case 'export-sweeps': this.exportSweeps(processorId); break; case 'hide': @@ -231,13 +264,21 @@ export class ChartManager { } }); - // Setup file input handler + // Setup file input handler for load history const fileInput = card.querySelector(`#historyFileInput_${processorId}`); if (fileInput) { fileInput.addEventListener('change', async (e) => { await this.handleHistoryUpload(processorId, e); }); } + + // Setup file input handler for append history + const appendFileInput = card.querySelector(`#appendFileInput_${processorId}`); + if (appendFileInput) { + appendFileInput.addEventListener('change', async (e) => { + await this.handleHistoryAppend(processorId, e); + }); + } } updateChartMetadata(processorId) { @@ -284,6 +325,11 @@ export class ChartManager { removeChart(id) { const c = this.charts.get(id); if (c) { + // Cleanup bscan click handler if applicable + if (id === 'bscan') { + this.bscanClickHandler.detachClickHandler(id, c.plotContainer); + } + cleanupPlotly(c.plotContainer); c.element.remove(); this.charts.delete(id); @@ -514,15 +560,16 @@ export class ChartManager { } // Export calibration standards if present - if (latestSweep.calibration_standards) { - console.log('Exporting calibration standards:', Object.keys(latestSweep.calibration_standards)); - for (const [standardName, standardData] of Object.entries(latestSweep.calibration_standards)) { - if (standardData && standardData.points && standardData.points.length > 0) { - this.exportPointsToTSV(standardData.points, latestSweep.vna_config, `${baseFilename}_cal_${standardName}`); - exportedCount++; - } - } - } + // COMMENTED OUT: Don't export calibration files (may be needed later) + // if (latestSweep.calibration_standards) { + // console.log('Exporting calibration standards:', Object.keys(latestSweep.calibration_standards)); + // for (const [standardName, standardData] of Object.entries(latestSweep.calibration_standards)) { + // if (standardData && standardData.points && standardData.points.length > 0) { + // this.exportPointsToTSV(standardData.points, latestSweep.vna_config, `${baseFilename}_cal_${standardName}`); + // exportedCount++; + // } + // } + // } // Export raw reference if present if (latestSweep.raw_reference_points && latestSweep.raw_reference_points.length > 0) { @@ -641,6 +688,16 @@ export class ChartManager { } } + appendHistory(processorId) { + const chart = this.charts.get(processorId); + if (!chart) return; + + const fileInput = chart.element.querySelector(`#appendFileInput_${processorId}`); + if (fileInput) { + fileInput.click(); + } + } + async handleHistoryUpload(processorId, event) { const file = event.target.files?.[0]; if (!file) return; @@ -698,10 +755,67 @@ export class ChartManager { event.target.value = ''; } + async handleHistoryAppend(processorId, event) { + const file = event.target.files?.[0]; + if (!file) return; + + try { + const text = await file.text(); + const jsonData = JSON.parse(text); + + // Extract sweep_history from the saved JSON file + // Note: We do NOT use the config - only append history + const sweepHistory = jsonData.sweep_history || []; + + if (!sweepHistory || sweepHistory.length === 0) { + this.notifications?.show?.({ + type: 'error', + title: 'Ошибка дополнения', + message: 'Файл не содержит истории свипов' + }); + return; + } + + // Send append_history message via WebSocket + const websocket = window.vnaDashboard?.websocket; + if (websocket && websocket.ws && websocket.ws.readyState === WebSocket.OPEN) { + websocket.ws.send(JSON.stringify({ + type: 'append_history', + processor_id: processorId, + history_data: sweepHistory + })); + + this.notifications?.show?.({ + type: 'success', + title: 'История дополнена', + message: `Добавлено ${sweepHistory.length} записей к истории ${formatProcessorName(processorId)}` + }); + } else { + this.notifications?.show?.({ + type: 'error', + title: 'Ошибка подключения', + message: 'WebSocket не подключен' + }); + } + + } catch (err) { + console.error('Error appending history:', err); + this.notifications?.show?.({ + type: 'error', + title: 'Ошибка дополнения', + message: `Не удалось прочитать файл: ${err.message}` + }); + } + + // Reset file input + event.target.value = ''; + } + destroy() { console.log('Cleaning up Chart Manager...'); this.clearAll(); this.settingsManager.destroy(); + this.bscanClickHandler.destroy(); this.updateQueue.clear(); this.isUpdating = false; this.isPaused = true; diff --git a/vna_system/web_ui/static/js/modules/charts/bscan-click-handler.js b/vna_system/web_ui/static/js/modules/charts/bscan-click-handler.js new file mode 100644 index 0000000..bb8a55c --- /dev/null +++ b/vna_system/web_ui/static/js/modules/charts/bscan-click-handler.js @@ -0,0 +1,117 @@ +/** + * B-Scan Click Handler + * Handles column deletion clicks on the B-Scan heatmap + */ + +export class BScanClickHandler { + constructor(websocket, notifications) { + this.websocket = websocket; + this.notifications = notifications; + this.activeListeners = new Map(); + } + + /** + * Attach click handler to a B-Scan plot container + * @param {string} processorId - Processor ID (e.g., "bscan") + * @param {HTMLElement} plotContainer - Plot container element + */ + attachClickHandler(processorId, plotContainer) { + if (!plotContainer || this.activeListeners.has(processorId)) { + return; + } + + const clickHandler = (data) => { + // Check if user clicked on a heatmap point + if (!data.points || data.points.length === 0) { + return; + } + + const point = data.points[0]; + const columnIndex = point.x; // X-axis represents sweep/column number (1-based) + + if (!columnIndex || typeof columnIndex !== 'number') { + return; + } + + // Show confirmation dialog + this.showDeleteConfirmation(processorId, columnIndex); + }; + + // Attach Plotly click event + plotContainer.on('plotly_click', clickHandler); + this.activeListeners.set(processorId, clickHandler); + } + + /** + * Remove click handler from a plot container + * @param {string} processorId - Processor ID + * @param {HTMLElement} plotContainer - Plot container element + */ + detachClickHandler(processorId, plotContainer) { + const handler = this.activeListeners.get(processorId); + if (handler && plotContainer) { + plotContainer.removeListener('plotly_click', handler); + this.activeListeners.delete(processorId); + } + } + + /** + * Show confirmation dialog for column deletion + * @param {string} processorId - Processor ID + * @param {number} columnIndex - Column index to delete (1-based) + */ + showDeleteConfirmation(processorId, columnIndex) { + const message = `Вы хотите удалить столбец ${columnIndex}?`; + + if (confirm(message)) { + this.deleteColumn(processorId, columnIndex); + } + } + + /** + * Send delete column command to backend + * @param {string} processorId - Processor ID + * @param {number} columnIndex - Column index to delete (1-based) + */ + deleteColumn(processorId, columnIndex) { + if (!this.websocket || !this.websocket.ws || this.websocket.ws.readyState !== WebSocket.OPEN) { + this.notifications?.show?.({ + type: 'error', + title: 'Ошибка подключения', + message: 'WebSocket не подключен' + }); + return; + } + + try { + const message = { + type: 'delete_column', + processor_id: processorId, + column_index: columnIndex + }; + + this.websocket.ws.send(JSON.stringify(message)); + + this.notifications?.show?.({ + type: 'info', + title: 'Удаление столбца', + message: `Удаление столбца ${columnIndex}...` + }); + + } catch (error) { + console.error('Failed to send delete column command:', error); + this.notifications?.show?.({ + type: 'error', + title: 'Ошибка удаления', + message: `Не удалось удалить столбец ${columnIndex}` + }); + } + } + + /** + * Clean up all listeners + */ + destroy() { + this.activeListeners.clear(); + } +} diff --git a/vna_system/web_ui/static/js/modules/icons.js b/vna_system/web_ui/static/js/modules/icons.js index 2e11f65..9394dce 100644 --- a/vna_system/web_ui/static/js/modules/icons.js +++ b/vna_system/web_ui/static/js/modules/icons.js @@ -186,6 +186,13 @@ const ICONS = { { type: 'path', attrs: { d: 'M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5' } }, { type: 'path', attrs: { d: 'M3 12c0 1.66 4 3 9 3s9-1.34 9-3' } } ] + }, + plus: { + viewBox: '0 0 24 24', + elements: [ + { type: 'line', attrs: { x1: 12, y1: 5, x2: 12, y2: 19 } }, + { type: 'line', attrs: { x1: 5, y1: 12, x2: 19, y2: 12 } } + ] } }; diff --git a/vna_system/web_ui/static/js/modules/plotly-utils.js b/vna_system/web_ui/static/js/modules/plotly-utils.js index b7b1834..7ecb471 100644 --- a/vna_system/web_ui/static/js/modules/plotly-utils.js +++ b/vna_system/web_ui/static/js/modules/plotly-utils.js @@ -105,8 +105,9 @@ export function createPlotlyPlot(container, data = [], layoutOverrides = {}, con * @param {HTMLElement} container - Container element * @param {Array} data - Plotly data traces * @param {Object} layoutOverrides - Layout overrides + * @param {Object} configOverrides - Config overrides */ -export async function updatePlotlyPlot(container, data = [], layoutOverrides = {}) { +export async function updatePlotlyPlot(container, data = [], layoutOverrides = {}, configOverrides = {}) { if (!container || typeof Plotly === 'undefined') return; const layout = { @@ -118,7 +119,12 @@ export async function updatePlotlyPlot(container, data = [], layoutOverrides = { delete layout.width; delete layout.height; - await Plotly.react(container, data, layout, defaultPlotlyConfig); + const config = { + ...defaultPlotlyConfig, + ...configOverrides + }; + + await Plotly.react(container, data, layout, config); } /** From f5c63da1c64364712e8f935cd11af57a3b8e5491 Mon Sep 17 00:00:00 2001 From: ayzen Date: Mon, 20 Oct 2025 18:56:21 +0300 Subject: [PATCH 2/3] added new preview feature --- .../core/processors/configs/bscan_config.json | 10 +- .../processors/configs/magnitude_config.json | 2 +- vna_system/web_ui/static/css/charts.css | 24 +- vna_system/web_ui/static/js/modules/charts.js | 32 +- .../js/modules/charts/bscan-click-handler.js | 474 +++++++++++++++++- 5 files changed, 513 insertions(+), 29 deletions(-) diff --git a/vna_system/core/processors/configs/bscan_config.json b/vna_system/core/processors/configs/bscan_config.json index 1f46b36..f19372e 100644 --- a/vna_system/core/processors/configs/bscan_config.json +++ b/vna_system/core/processors/configs/bscan_config.json @@ -1,11 +1,11 @@ { "open_air": false, "axis": "abs", - "cut": 0.279, - "max": 1.5, - "gain": 0.7, - "start_freq": 2130.0, - "stop_freq": 8230.0, + "cut": 0.0, + "max": 5.0, + "gain": 0.0, + "start_freq": 100.0, + "stop_freq": 8800.0, "clear_history": false, "data_limit": 500 } \ No newline at end of file diff --git a/vna_system/core/processors/configs/magnitude_config.json b/vna_system/core/processors/configs/magnitude_config.json index bcee4c9..5d834fc 100644 --- a/vna_system/core/processors/configs/magnitude_config.json +++ b/vna_system/core/processors/configs/magnitude_config.json @@ -4,5 +4,5 @@ "autoscale": true, "show_magnitude": true, "show_phase": false, - "open_air": true + "open_air": false } \ No newline at end of file diff --git a/vna_system/web_ui/static/css/charts.css b/vna_system/web_ui/static/css/charts.css index 96b6914..3d95c01 100644 --- a/vna_system/web_ui/static/css/charts.css +++ b/vna_system/web_ui/static/css/charts.css @@ -268,4 +268,26 @@ .chart-card__content { padding: 0; } -} \ No newline at end of file +} +/* Keyboard shortcuts display */ +.chart-card__shortcuts { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +} + +.chart-card__shortcuts kbd { + display: inline-block; + padding: 2px 6px; + font-size: 10px; + font-family: 'Monaco', 'Courier New', monospace; + font-weight: 600; + line-height: 1; + color: #1e293b; + background: linear-gradient(180deg, #e2e8f0 0%, #cbd5e1 100%); + border: 1px solid #94a3b8; + border-radius: 3px; + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.2), 0 0 0 2px #1e293b inset; + white-space: nowrap; +} diff --git a/vna_system/web_ui/static/js/modules/charts.js b/vna_system/web_ui/static/js/modules/charts.js index 52864d5..7a13fe9 100644 --- a/vna_system/web_ui/static/js/modules/charts.js +++ b/vna_system/web_ui/static/js/modules/charts.js @@ -100,14 +100,11 @@ export class ChartManager { height: plotContainer.clientHeight || 420 }; - // Disable interactivity for bscan processor + // Keep interactivity for bscan processor but disable some features const configOverrides = processorId === 'bscan' ? { - staticPlot: false, - displayModeBar: false, - scrollZoom: false, - doubleClick: false, - showTips: false, - editable: false + displayModeBar: true, + modeBarButtonsToRemove: ['select2d', 'lasso2d'], + scrollZoom: false } : {}; createPlotlyPlot(plotContainer, [], layoutOverrides, configOverrides); @@ -142,14 +139,11 @@ export class ChartManager { title: { text: formatProcessorName(processorId), font: { size: 16, color: '#f1f5f9' } } }; - // Disable interactivity for bscan processor + // Keep interactivity for bscan processor but disable some features const configOverrides = processorId === 'bscan' ? { - staticPlot: false, - displayModeBar: false, - scrollZoom: false, - doubleClick: false, - showTips: false, - editable: false + displayModeBar: true, + modeBarButtonsToRemove: ['select2d', 'lasso2d'], + scrollZoom: false } : {}; await updatePlotlyPlot(chart.plotContainer, plotlyConfig.data || [], layoutOverrides, configOverrides); @@ -163,6 +157,11 @@ export class ChartManager { this.updateChartSettings(processorId); } + // Clear selection for bscan when data updates + if (processorId === 'bscan') { + this.bscanClickHandler.onDataUpdate(processorId); + } + const dt = performance.now() - start; this.updatePerformanceStats(dt); }); @@ -236,6 +235,11 @@ export class ChartManager {
Last update: --
+ ${processorId === 'bscan' ? ` +
+ Клавиши: Клик - выбрать | D - удалить | P - предпросмотр | Esc - отмена +
+ ` : ''}
`; diff --git a/vna_system/web_ui/static/js/modules/charts/bscan-click-handler.js b/vna_system/web_ui/static/js/modules/charts/bscan-click-handler.js index bb8a55c..c68e075 100644 --- a/vna_system/web_ui/static/js/modules/charts/bscan-click-handler.js +++ b/vna_system/web_ui/static/js/modules/charts/bscan-click-handler.js @@ -3,11 +3,16 @@ * Handles column deletion clicks on the B-Scan heatmap */ +import { createPlotlyPlot, cleanupPlotly } from '../plotly-utils.js'; + export class BScanClickHandler { constructor(websocket, notifications) { this.websocket = websocket; this.notifications = notifications; this.activeListeners = new Map(); + this.selectedColumn = null; + this.selectedProcessorId = null; + this.keyboardListener = null; } /** @@ -33,13 +38,18 @@ export class BScanClickHandler { return; } - // Show confirmation dialog - this.showDeleteConfirmation(processorId, columnIndex); + // Select/highlight the column + this.selectColumn(processorId, columnIndex, plotContainer); }; // Attach Plotly click event plotContainer.on('plotly_click', clickHandler); - this.activeListeners.set(processorId, clickHandler); + this.activeListeners.set(processorId, { clickHandler, plotContainer }); + + // Setup keyboard listener if not already done + if (!this.keyboardListener) { + this.setupKeyboardListener(); + } } /** @@ -48,11 +58,132 @@ export class BScanClickHandler { * @param {HTMLElement} plotContainer - Plot container element */ detachClickHandler(processorId, plotContainer) { - const handler = this.activeListeners.get(processorId); - if (handler && plotContainer) { - plotContainer.removeListener('plotly_click', handler); + const listenerData = this.activeListeners.get(processorId); + if (listenerData && plotContainer) { + plotContainer.removeListener('plotly_click', listenerData.clickHandler); this.activeListeners.delete(processorId); } + + // Clear selection if this processor was selected + if (this.selectedProcessorId === processorId) { + this.clearSelection(); + } + } + + /** + * Select and highlight a column + * @param {string} processorId - Processor ID + * @param {number} columnIndex - Column index (1-based) + * @param {HTMLElement} plotContainer - Plot container element + */ + selectColumn(processorId, columnIndex, plotContainer) { + this.selectedColumn = columnIndex; + this.selectedProcessorId = processorId; + + // Add visual highlight by adding a vertical line shape + this.highlightColumn(plotContainer, columnIndex); + + console.log(`Column ${columnIndex} selected. Press 'D' to delete.`); + } + + /** + * Add visual highlight to the selected column + * @param {HTMLElement} plotContainer - Plot container element + * @param {number} columnIndex - Column index (1-based) + */ + highlightColumn(plotContainer, columnIndex) { + if (!plotContainer || typeof Plotly === 'undefined') { + return; + } + + // Add a vertical line shape to highlight the column + const update = { + shapes: [{ + type: 'line', + x0: columnIndex - 0.5, + x1: columnIndex - 0.5, + y0: 0, + y1: 1, + yref: 'paper', + line: { + color: 'rgba(255, 0, 0, 0.8)', + width: 3, + dash: 'solid' + } + }, { + type: 'line', + x0: columnIndex + 0.5, + x1: columnIndex + 0.5, + y0: 0, + y1: 1, + yref: 'paper', + line: { + color: 'rgba(255, 0, 0, 0.8)', + width: 3, + dash: 'solid' + } + }] + }; + + Plotly.relayout(plotContainer, update); + } + + /** + * Clear column selection and highlighting + */ + clearSelection() { + if (!this.selectedProcessorId || !this.selectedColumn) { + return; + } + + const listenerData = this.activeListeners.get(this.selectedProcessorId); + if (listenerData && listenerData.plotContainer && typeof Plotly !== 'undefined') { + // Remove shapes (highlighting) + Plotly.relayout(listenerData.plotContainer, { shapes: [] }); + } + + this.selectedColumn = null; + this.selectedProcessorId = null; + + console.log('Selection cleared'); + } + + /** + * Setup keyboard listener for 'D' key + */ + setupKeyboardListener() { + this.keyboardListener = (event) => { + // Ignore if user is typing in an input field + const activeElement = document.activeElement; + if (activeElement && ( + activeElement.tagName === 'INPUT' || + activeElement.tagName === 'TEXTAREA' || + activeElement.isContentEditable + )) { + return; + } + + // Check if 'D' key is pressed (case insensitive) + if (event.key === 'd' || event.key === 'D') { + // Delete selected column if any + if (this.selectedColumn !== null && this.selectedProcessorId !== null) { + event.preventDefault(); + this.showDeleteConfirmation(this.selectedProcessorId, this.selectedColumn); + } + } else if (event.key === 'p' || event.key === 'P') { + // Show sweep preview modal + if (this.selectedColumn !== null && this.selectedProcessorId !== null) { + event.preventDefault(); + this.showSweepPreview(this.selectedProcessorId, this.selectedColumn); + } + } else if (event.key === 'Escape') { + // Clear selection on Escape + this.clearSelection(); + } + }; + + document.addEventListener('keydown', this.keyboardListener); + console.log('Keyboard shortcuts: D - delete column, P - preview sweep, Escape - deselect'); } /** @@ -74,7 +205,19 @@ export class BScanClickHandler { * @param {number} columnIndex - Column index to delete (1-based) */ deleteColumn(processorId, columnIndex) { - if (!this.websocket || !this.websocket.ws || this.websocket.ws.readyState !== WebSocket.OPEN) { + // Get websocket from global window object if not available in instance + const websocket = this.websocket || window.vnaDashboard?.websocket; + + console.log('deleteColumn called:', { processorId, columnIndex, websocket, ws: websocket?.ws, readyState: websocket?.ws?.readyState }); + + if (!websocket || !websocket.ws || websocket.ws.readyState !== WebSocket.OPEN) { + console.error('WebSocket not available or not open:', { + hasWebsocket: !!websocket, + hasWs: !!websocket?.ws, + readyState: websocket?.ws?.readyState, + OPEN: WebSocket.OPEN + }); + this.notifications?.show?.({ type: 'error', title: 'Ошибка подключения', @@ -90,7 +233,8 @@ export class BScanClickHandler { column_index: columnIndex }; - this.websocket.ws.send(JSON.stringify(message)); + console.log('Sending delete column message:', message); + websocket.ws.send(JSON.stringify(message)); this.notifications?.show?.({ type: 'info', @@ -108,10 +252,324 @@ export class BScanClickHandler { } } + /** + * Show sweep preview modal for selected column + * @param {string} processorId - Processor ID + * @param {number} columnIndex - Column index (1-based) + */ + showSweepPreview(processorId, columnIndex) { + // Get websocket from global window object if not available in instance + const websocket = this.websocket || window.vnaDashboard?.websocket; + + if (!websocket || !websocket.ws || websocket.ws.readyState !== WebSocket.OPEN) { + this.notifications?.show?.({ + type: 'error', + title: 'Ошибка подключения', + message: 'WebSocket не подключен' + }); + return; + } + + try { + // Request processor state to get sweep data + const message = { + type: 'get_processor_state', + processor_id: processorId + }; + + console.log('Requesting processor state for sweep preview:', message); + + // Set up one-time listener for the response + const handleResponse = (event) => { + try { + const response = JSON.parse(event.data); + + if (response.type === 'processor_state' && response.processor_id === processorId) { + // Remove this listener + websocket.ws.removeEventListener('message', handleResponse); + + // Extract sweep data for the selected column + this.displaySweepModal(response, columnIndex); + } + } catch (error) { + console.error('Error parsing processor state response:', error); + } + }; + + websocket.ws.addEventListener('message', handleResponse); + websocket.ws.send(JSON.stringify(message)); + + } catch (error) { + console.error('Failed to request sweep preview:', error); + this.notifications?.show?.({ + type: 'error', + title: 'Ошибка предпросмотра', + message: 'Не удалось загрузить данные свипа' + }); + } + } + + /** + * Display sweep data in modal window + * @param {Object} processorState - Processor state from backend + * @param {number} columnIndex - Column index (1-based, same as sweep number) + */ + displaySweepModal(processorState, columnIndex) { + const sweepHistory = processorState.state?.sweep_history || []; + const config = processorState.state?.config || {}; + + // Convert 1-based column index to 0-based array index + const sweepIndex = columnIndex - 1; + + if (sweepIndex < 0 || sweepIndex >= sweepHistory.length) { + this.notifications?.show?.({ + type: 'error', + title: 'Ошибка', + message: `Свип ${columnIndex} не найден в истории` + }); + return; + } + + const sweep = sweepHistory[sweepIndex]; + + // Use same logic as BScanProcessor: calibrated_data or sweep_data + const hasCalibrated = sweep.calibrated_points && sweep.calibrated_points.length > 0; + const dataToProcess = hasCalibrated ? sweep.calibrated_points : (sweep.sweep_points || []); + const referencePoints = sweep.reference_points || []; + const openAirEnabled = config.open_air || false; + + if (dataToProcess.length === 0) { + this.notifications?.show?.({ + type: 'warning', + title: 'Нет данных', + message: `Свип ${columnIndex} не содержит данных для отображения` + }); + return; + } + + // Show modal with plot + this.showSweepPlotModal( + columnIndex, + dataToProcess, + referencePoints, + openAirEnabled, + hasCalibrated, + sweep.vna_config + ); + } + + /** + * Show modal with sweep plot + * @param {number} sweepNumber - Sweep number (1-based) + * @param {Array} points - Sweep points (calibrated or raw) + * @param {Array} referencePoints - Reference points for open air subtraction + * @param {boolean} openAirEnabled - Whether to subtract reference + * @param {boolean} hasCalibrated - Whether the data is calibrated (true) or raw (false) + * @param {Object} vnaConfig - VNA configuration + */ + showSweepPlotModal(sweepNumber, points, referencePoints, openAirEnabled, hasCalibrated, vnaConfig) { + // Get modal element + const modal = document.getElementById('plotsModal'); + if (!modal) { + console.error('Plots modal not found'); + return; + } + // Prepare data for Plotly + const frequencies = []; + const magnitudes = []; + const phases = []; + + points.forEach((point, index) => { + const freq = vnaConfig?.start_freq + (index / (points.length - 1)) * (vnaConfig?.stop_freq - vnaConfig?.start_freq); + frequencies.push(freq / 1e6); // Convert to MHz + + let real = point[0] || point.real || point.r || 0; + let imag = point[1] || point.imag || point.i || 0; + + // Apply reference subtraction if enabled (same as BScanProcessor) + if (openAirEnabled && referencePoints && referencePoints.length > index) { + const refPoint = referencePoints[index]; + const refReal = refPoint[0] || refPoint.real || refPoint.r || 0; + const refImag = refPoint[1] || refPoint.imag || refPoint.i || 0; + + // Subtract reference: complex_data - reference_complex + real = real - refReal; + imag = imag - refImag; + } + + const magnitude = Math.sqrt(real * real + imag * imag); + const phase = Math.atan2(imag, real) * (180 / Math.PI); + + magnitudes.push(magnitude); + phases.push(phase); + }); + + // Create Plotly traces + const traces = [ + { + x: frequencies, + y: magnitudes, + type: 'scatter', + mode: 'lines', + name: 'Амплитуда', + line: { color: '#3b82f6', width: 2 } + }, + { + x: frequencies, + y: phases, + type: 'scatter', + mode: 'lines', + name: 'Фаза', + yaxis: 'y2', + line: { color: '#f59e0b', width: 2 } + } + ]; + + // Build descriptive title + const dataType = hasCalibrated ? 'откалиброванные' : 'сырые'; + const refStatus = openAirEnabled && referencePoints && referencePoints.length > 0 ? 'с вычетом референса' : 'без вычета референса'; + const titleText = `Свип ${sweepNumber} (${dataType}, ${refStatus})`; + + const layoutOverrides = { + title: { text: titleText, font: { size: 16, color: '#f1f5f9' } }, + xaxis: { + title: 'Частота (МГц)', + gridcolor: '#334155', + zerolinecolor: '#475569', + color: '#cbd5e1', + fixedrange: false + }, + yaxis: { + title: 'Амплитуда', + side: 'left', + gridcolor: '#334155', + zerolinecolor: '#475569', + color: '#cbd5e1', + fixedrange: false + }, + yaxis2: { + title: 'Фаза (°)', + overlaying: 'y', + side: 'right', + gridcolor: '#334155', + zerolinecolor: '#475569', + color: '#cbd5e1', + fixedrange: false + }, + showlegend: true, + legend: { x: 0.01, y: 0.99 }, + height: 500 + }; + + const configOverrides = { + toImageButtonOptions: { + format: 'png', + filename: `sweep-${sweepNumber}-preview`, + height: 600, + width: 800, + scale: 1 + } + }; + + // Update modal title + const title = modal.querySelector('.modal__title'); + if (title) { + title.textContent = titleText; + } + + // Get plots grid container and clear it + const container = document.getElementById('plotsGrid'); + if (!container) { + console.error('Plots grid container not found'); + return; + } + container.innerHTML = ''; + + // Create card wrapper with proper styling + const card = document.createElement('div'); + card.className = 'chart-card'; + card.innerHTML = ` +
+
${titleText}
+
+
+
+
+ `; + container.appendChild(card); + + // Setup close handlers + this.setupModalCloseHandlers(modal); + + // Show modal + modal.classList.add('modal--active'); + document.body.style.overflow = 'hidden'; + + // Create plot after modal is shown + setTimeout(() => { + const plotElement = document.getElementById('sweep-preview-plot'); + if (plotElement) { + createPlotlyPlot(plotElement, traces, layoutOverrides, configOverrides); + } else { + console.error('Plot element not found'); + } + }, 50); + } + + /** + * Setup modal close handlers + * @param {HTMLElement} modal - Modal element + */ + setupModalCloseHandlers(modal) { + const closeElements = modal.querySelectorAll('[data-modal-close]'); + closeElements.forEach(el => { + el.onclick = () => this.closeModal(modal); + }); + } + + /** + * Close modal + * @param {HTMLElement} modal - Modal element + */ + closeModal(modal) { + modal.classList.remove('modal--active'); + document.body.style.overflow = ''; + + // Cleanup Plotly plot + const plotElement = document.getElementById('sweep-preview-plot'); + if (plotElement) { + cleanupPlotly(plotElement); + } + } + + /** + * Called when data is updated (new columns added, imported, etc.) + * Clears the current selection + * @param {string} processorId - Processor ID + */ + onDataUpdate(processorId) { + // Clear selection if this processor's data was updated + if (this.selectedProcessorId === processorId) { + this.clearSelection(); + } + } + /** * Clean up all listeners */ destroy() { + // Remove keyboard listener + if (this.keyboardListener) { + document.removeEventListener('keydown', this.keyboardListener); + this.keyboardListener = null; + } + + // Clear selection + this.clearSelection(); + + // Clear active listeners this.activeListeners.clear(); + + console.log('BScanClickHandler destroyed'); } } From 72b1b25dc19e3f212714be13ef878097ac2524bf Mon Sep 17 00:00:00 2001 From: ayzen Date: Mon, 20 Oct 2025 21:24:01 +0300 Subject: [PATCH 3/3] fixed error --- .../js/modules/charts/bscan-click-handler.js | 86 ++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/vna_system/web_ui/static/js/modules/charts/bscan-click-handler.js b/vna_system/web_ui/static/js/modules/charts/bscan-click-handler.js index c68e075..65fd1e5 100644 --- a/vna_system/web_ui/static/js/modules/charts/bscan-click-handler.js +++ b/vna_system/web_ui/static/js/modules/charts/bscan-click-handler.js @@ -333,7 +333,7 @@ export class BScanClickHandler { const sweep = sweepHistory[sweepIndex]; // Use same logic as BScanProcessor: calibrated_data or sweep_data - const hasCalibrated = sweep.calibrated_points && sweep.calibrated_points.length > 0; + const hasCalibrated = this.hasCalibratedSweepData(sweep); const dataToProcess = hasCalibrated ? sweep.calibrated_points : (sweep.sweep_points || []); const referencePoints = sweep.reference_points || []; const openAirEnabled = config.open_air || false; @@ -358,6 +358,90 @@ export class BScanClickHandler { ); } + /** + * Determine if sweep contains calibrated data that differs from raw points. + * @param {Object} sweep - Sweep data entry from processor state + * @returns {boolean} True when calibrated points appear to be applied + */ + hasCalibratedSweepData(sweep) { + const calibratedPoints = Array.isArray(sweep?.calibrated_points) ? sweep.calibrated_points : []; + if (calibratedPoints.length === 0) { + return false; + } + + const rawPoints = Array.isArray(sweep?.sweep_points) ? sweep.sweep_points : []; + if (rawPoints.length === 0 || calibratedPoints.length !== rawPoints.length) { + return true; + } + + const tolerance = 1e-9; + let comparablePoints = 0; + + for (let i = 0; i < calibratedPoints.length; i++) { + const calibrated = this._extractComplexPoint(calibratedPoints[i]); + const raw = this._extractComplexPoint(rawPoints[i]); + + if (!this._canCompareComplexPoints(calibrated, raw)) { + continue; + } + + comparablePoints++; + + if (!this._areComplexPointsClose(calibrated, raw, tolerance)) { + return true; + } + } + + return false; + } + + _extractComplexPoint(point) { + if (!point) { + return { real: NaN, imag: NaN }; + } + + if (Array.isArray(point)) { + return { + real: this._toFiniteNumber(point[0]), + imag: this._toFiniteNumber(point[1]) + }; + } + + if (typeof point === 'object') { + const realCandidate = point.real ?? point.r ?? point.Re ?? point.re; + const imagCandidate = point.imag ?? point.i ?? point.Im ?? point.im; + return { + real: this._toFiniteNumber(realCandidate), + imag: this._toFiniteNumber(imagCandidate) + }; + } + + return { real: NaN, imag: NaN }; + } + + _toFiniteNumber(value) { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : NaN; + } + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : NaN; + } + return NaN; + } + + _canCompareComplexPoints(a, b) { + return Number.isFinite(a.real) && Number.isFinite(a.imag) && Number.isFinite(b.real) && Number.isFinite(b.imag); + } + + _areComplexPointsClose(a, b, tolerance) { + const realScale = Math.max(1, Math.abs(a.real), Math.abs(b.real)); + const imagScale = Math.max(1, Math.abs(a.imag), Math.abs(b.imag)); + + return Math.abs(a.real - b.real) <= tolerance * realScale && + Math.abs(a.imag - b.imag) <= tolerance * imagScale; + } + /** * Show modal with sweep plot * @param {number} sweepNumber - Sweep number (1-based)