diff --git a/gui/plot_widget.py b/gui/plot_widget.py index 0398f4f..9c08d5e 100644 --- a/gui/plot_widget.py +++ b/gui/plot_widget.py @@ -34,7 +34,9 @@ from PyQt5.QtCore import Qt, QPointF, QRectF, QThread, pyqtSignal from data_model import ReceiverManager -_Y_OPTIONS = ['C/N₀', 'Azimuth', 'Elevation', 'pDOP'] +_Y_OPTIONS = ['C/N₀', 'Azimuth', 'Elevation', 'pDOP', 'Visible Sats', 'Used Sats'] + +_GLOBAL_MODES = {'pDOP', 'Visible Sats', 'Used Sats'} _COLORS = ['#2196F3', '#F44336', '#4CAF50', '#FF9800', '#9C27B0', '#00BCD4', '#FFEB3B', '#795548'] @@ -61,9 +63,10 @@ def _nice_ticks(lo: float, hi: float, n: int = 5) -> list: if base * m >= raw: step = base * m break - first = math.ceil(lo / step) * step + first = math.floor(lo / step) * step # ≤ lo — data never clipped at bottom + last = math.ceil(hi / step) * step # ≥ hi — data never clipped at top ticks, v = [], first - while v <= hi + step * 0.01: + while v <= last + step * 0.01: ticks.append(v) v += step return ticks @@ -148,7 +151,9 @@ class PlotRenderThread(QThread): if ts_max - ts_min < 1: ts_min = ts_max - 1.0 - v_ticks = _nice_ticks(v_min, v_max) + # Add 5 % headroom so data never sits right on the plot edge + pad = (v_max - v_min) * 0.05 if v_max > v_min else 0.5 + v_ticks = _nice_ticks(v_min - pad, v_max + pad) v_min, v_max = v_ticks[0], v_ticks[-1] if v_max - v_min < 1e-9: v_max = v_min + 1.0 @@ -268,6 +273,22 @@ class _Canvas(QWidget): super().resizeEvent(event) self.resized.emit(self.width(), self.height()) + def contextMenuEvent(self, event): + from PyQt5.QtWidgets import QMenu, QFileDialog + if not self._image or self._image.isNull(): + return + menu = QMenu(self) + act_save = menu.addAction("Save as PNG…") + act_copy = menu.addAction("Copy to clipboard") + action = menu.exec_(self.mapToGlobal(event.pos())) + if action == act_save: + path, _ = QFileDialog.getSaveFileName( + self, "Save plot", "", "PNG (*.png)") + if path: + self._image.save(path) + elif action == act_copy: + QApplication.clipboard().setImage(self._image) + # ── Single configurable plot ────────────────────────────────────────────────── @@ -360,7 +381,7 @@ class SinglePlotWidget(QWidget): self._y_cb.blockSignals(True) self._y_cb.setCurrentText(s['y_axis']) self._y_cb.blockSignals(False) - self._val_cb.setEnabled(s['y_axis'] != 'pDOP') + self._val_cb.setEnabled(s['y_axis'] not in _GLOBAL_MODES) if 'history' in s: self._hist_spin.blockSignals(True) self._hist_spin.setValue(s['history']) @@ -424,8 +445,7 @@ class SinglePlotWidget(QWidget): self._submit_render() def _on_y_changed(self, _text: str): - is_pdop = (_text == 'pDOP') - self._val_cb.setEnabled(not is_pdop) + self._val_cb.setEnabled(_text not in _GLOBAL_MODES) for rid in list(self._data): n = self._hist_spin.value() self._data[rid] = [deque(maxlen=n), deque(maxlen=n)] @@ -470,6 +490,13 @@ class SinglePlotWidget(QWidget): if rid not in selected: return y_axis = self._y_cb.currentText() + + if y_axis == 'Visible Sats': + self._push_point(rid, time.time(), len(sats)) + return + if y_axis == 'Used Sats': + self._push_point(rid, time.time(), sum(1 for s in sats if s.used_in_fix)) + return if y_axis == 'pDOP': return