added new preview feature

This commit is contained in:
ayzen
2025-10-20 18:56:21 +03:00
parent 95fdfa5fdb
commit f5c63da1c6
5 changed files with 513 additions and 29 deletions

View File

@ -1,11 +1,11 @@
{ {
"open_air": false, "open_air": false,
"axis": "abs", "axis": "abs",
"cut": 0.279, "cut": 0.0,
"max": 1.5, "max": 5.0,
"gain": 0.7, "gain": 0.0,
"start_freq": 2130.0, "start_freq": 100.0,
"stop_freq": 8230.0, "stop_freq": 8800.0,
"clear_history": false, "clear_history": false,
"data_limit": 500 "data_limit": 500
} }

View File

@ -4,5 +4,5 @@
"autoscale": true, "autoscale": true,
"show_magnitude": true, "show_magnitude": true,
"show_phase": false, "show_phase": false,
"open_air": true "open_air": false
} }

View File

@ -268,4 +268,26 @@
.chart-card__content { .chart-card__content {
padding: 0; padding: 0;
} }
} }
/* 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;
}

View File

@ -100,14 +100,11 @@ export class ChartManager {
height: plotContainer.clientHeight || 420 height: plotContainer.clientHeight || 420
}; };
// Disable interactivity for bscan processor // Keep interactivity for bscan processor but disable some features
const configOverrides = processorId === 'bscan' ? { const configOverrides = processorId === 'bscan' ? {
staticPlot: false, displayModeBar: true,
displayModeBar: false, modeBarButtonsToRemove: ['select2d', 'lasso2d'],
scrollZoom: false, scrollZoom: false
doubleClick: false,
showTips: false,
editable: false
} : {}; } : {};
createPlotlyPlot(plotContainer, [], layoutOverrides, configOverrides); createPlotlyPlot(plotContainer, [], layoutOverrides, configOverrides);
@ -142,14 +139,11 @@ export class ChartManager {
title: { text: formatProcessorName(processorId), font: { size: 16, color: '#f1f5f9' } } 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' ? { const configOverrides = processorId === 'bscan' ? {
staticPlot: false, displayModeBar: true,
displayModeBar: false, modeBarButtonsToRemove: ['select2d', 'lasso2d'],
scrollZoom: false, scrollZoom: false
doubleClick: false,
showTips: false,
editable: false
} : {}; } : {};
await updatePlotlyPlot(chart.plotContainer, plotlyConfig.data || [], layoutOverrides, configOverrides); await updatePlotlyPlot(chart.plotContainer, plotlyConfig.data || [], layoutOverrides, configOverrides);
@ -163,6 +157,11 @@ export class ChartManager {
this.updateChartSettings(processorId); this.updateChartSettings(processorId);
} }
// Clear selection for bscan when data updates
if (processorId === 'bscan') {
this.bscanClickHandler.onDataUpdate(processorId);
}
const dt = performance.now() - start; const dt = performance.now() - start;
this.updatePerformanceStats(dt); this.updatePerformanceStats(dt);
}); });
@ -236,6 +235,11 @@ export class ChartManager {
<div class="chart-card__meta"> <div class="chart-card__meta">
<div class="chart-card__timestamp" data-timestamp="">Last update: --</div> <div class="chart-card__timestamp" data-timestamp="">Last update: --</div>
<div class="chart-card__sweep" data-sweep=""></div> <div class="chart-card__sweep" data-sweep=""></div>
${processorId === 'bscan' ? `
<div class="chart-card__shortcuts" style="font-size: 11px; color: #94a3b8; margin-top: 4px;">
Клавиши: <kbd>Клик</kbd> - выбрать | <kbd>D</kbd> - удалить | <kbd>P</kbd> - предпросмотр | <kbd>Esc</kbd> - отмена
</div>
` : ''}
</div> </div>
`; `;

View File

@ -3,11 +3,16 @@
* Handles column deletion clicks on the B-Scan heatmap * Handles column deletion clicks on the B-Scan heatmap
*/ */
import { createPlotlyPlot, cleanupPlotly } from '../plotly-utils.js';
export class BScanClickHandler { export class BScanClickHandler {
constructor(websocket, notifications) { constructor(websocket, notifications) {
this.websocket = websocket; this.websocket = websocket;
this.notifications = notifications; this.notifications = notifications;
this.activeListeners = new Map(); this.activeListeners = new Map();
this.selectedColumn = null;
this.selectedProcessorId = null;
this.keyboardListener = null;
} }
/** /**
@ -33,13 +38,18 @@ export class BScanClickHandler {
return; return;
} }
// Show confirmation dialog // Select/highlight the column
this.showDeleteConfirmation(processorId, columnIndex); this.selectColumn(processorId, columnIndex, plotContainer);
}; };
// Attach Plotly click event // Attach Plotly click event
plotContainer.on('plotly_click', clickHandler); 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 * @param {HTMLElement} plotContainer - Plot container element
*/ */
detachClickHandler(processorId, plotContainer) { detachClickHandler(processorId, plotContainer) {
const handler = this.activeListeners.get(processorId); const listenerData = this.activeListeners.get(processorId);
if (handler && plotContainer) { if (listenerData && plotContainer) {
plotContainer.removeListener('plotly_click', handler); plotContainer.removeListener('plotly_click', listenerData.clickHandler);
this.activeListeners.delete(processorId); 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) * @param {number} columnIndex - Column index to delete (1-based)
*/ */
deleteColumn(processorId, columnIndex) { 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?.({ this.notifications?.show?.({
type: 'error', type: 'error',
title: 'Ошибка подключения', title: 'Ошибка подключения',
@ -90,7 +233,8 @@ export class BScanClickHandler {
column_index: columnIndex 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?.({ this.notifications?.show?.({
type: 'info', 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 = `
<div class="chart-card__header">
<div class="chart-card__title">${titleText}</div>
</div>
<div class="chart-card__content">
<div class="chart-card__plot" id="sweep-preview-plot"></div>
</div>
`;
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 * Clean up all listeners
*/ */
destroy() { 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(); this.activeListeners.clear();
console.log('BScanClickHandler destroyed');
} }
} }