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); } /**