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'); } }