plot tab: fix y-axis scaling, add Visible/Used Sats modes, right-click menu

- Fix _nice_ticks: use floor for first tick and ceil for last tick so the
  tick range always fully brackets the data; data was previously clipped
  when the maximum fell between two nice tick values
- Add 5% headroom padding so data does not sit flush against plot edges
- Add 'Visible Sats' and 'Used Sats' y-axis modes (global, like pDOP)
- Right-click context menu on plot canvas: Save as PNG and Copy to clipboard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 12:46:52 +02:00
co-authored by Claude Sonnet 4.6
parent 495f4c15c9
commit 19cf6fa05e
+34 -7
View File
@@ -34,7 +34,9 @@ from PyQt5.QtCore import Qt, QPointF, QRectF, QThread, pyqtSignal
from data_model import ReceiverManager 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', _COLORS = ['#2196F3', '#F44336', '#4CAF50', '#FF9800',
'#9C27B0', '#00BCD4', '#FFEB3B', '#795548'] '#9C27B0', '#00BCD4', '#FFEB3B', '#795548']
@@ -61,9 +63,10 @@ def _nice_ticks(lo: float, hi: float, n: int = 5) -> list:
if base * m >= raw: if base * m >= raw:
step = base * m step = base * m
break 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 ticks, v = [], first
while v <= hi + step * 0.01: while v <= last + step * 0.01:
ticks.append(v) ticks.append(v)
v += step v += step
return ticks return ticks
@@ -148,7 +151,9 @@ class PlotRenderThread(QThread):
if ts_max - ts_min < 1: if ts_max - ts_min < 1:
ts_min = ts_max - 1.0 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] v_min, v_max = v_ticks[0], v_ticks[-1]
if v_max - v_min < 1e-9: if v_max - v_min < 1e-9:
v_max = v_min + 1.0 v_max = v_min + 1.0
@@ -268,6 +273,22 @@ class _Canvas(QWidget):
super().resizeEvent(event) super().resizeEvent(event)
self.resized.emit(self.width(), self.height()) 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 ────────────────────────────────────────────────── # ── Single configurable plot ──────────────────────────────────────────────────
@@ -360,7 +381,7 @@ class SinglePlotWidget(QWidget):
self._y_cb.blockSignals(True) self._y_cb.blockSignals(True)
self._y_cb.setCurrentText(s['y_axis']) self._y_cb.setCurrentText(s['y_axis'])
self._y_cb.blockSignals(False) 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: if 'history' in s:
self._hist_spin.blockSignals(True) self._hist_spin.blockSignals(True)
self._hist_spin.setValue(s['history']) self._hist_spin.setValue(s['history'])
@@ -424,8 +445,7 @@ class SinglePlotWidget(QWidget):
self._submit_render() self._submit_render()
def _on_y_changed(self, _text: str): def _on_y_changed(self, _text: str):
is_pdop = (_text == 'pDOP') self._val_cb.setEnabled(_text not in _GLOBAL_MODES)
self._val_cb.setEnabled(not is_pdop)
for rid in list(self._data): for rid in list(self._data):
n = self._hist_spin.value() n = self._hist_spin.value()
self._data[rid] = [deque(maxlen=n), deque(maxlen=n)] self._data[rid] = [deque(maxlen=n), deque(maxlen=n)]
@@ -470,6 +490,13 @@ class SinglePlotWidget(QWidget):
if rid not in selected: if rid not in selected:
return return
y_axis = self._y_cb.currentText() 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': if y_axis == 'pDOP':
return return