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:
+34
-7
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user