Replace matplotlib with pyqtgraph in Plot tab
- pg.PlotWidget (2×2 grid) with DateAxisItem for proper time axis - Built-in pan (left drag), zoom (right drag / scroll wheel), Reset zoom button - pg.InfiniteLine cursor with pg.SignalProxy for smooth 30 fps tracking - Cursor value label shows nearest-point values colour-coded per trace - Legend auto-updated via pi.legend.clear() + named plot() calls - matplotlib removed from requirements.txt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+176
-45
@@ -21,19 +21,18 @@ from datetime import datetime
|
||||
from PyQt5.QtCore import QObject, Qt, pyqtSignal
|
||||
from PyQt5.QtGui import QColor, QFont
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication, QFormLayout, QGroupBox, QHBoxLayout, QLabel,
|
||||
QLineEdit, QMainWindow, QPushButton, QSizePolicy,
|
||||
QApplication, QComboBox, QFormLayout, QGridLayout, QGroupBox,
|
||||
QHBoxLayout, QLabel, QLineEdit, QMainWindow, QPushButton, QSizePolicy,
|
||||
QSpinBox, QStatusBar, QTabWidget, QTreeWidget, QTreeWidgetItem,
|
||||
QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Qt5Agg")
|
||||
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
|
||||
from matplotlib.figure import Figure
|
||||
import pyqtgraph as pg
|
||||
|
||||
pg.setConfigOption("background", "w")
|
||||
pg.setConfigOption("foreground", "k")
|
||||
|
||||
MAX_PLOT_POINTS = 200
|
||||
PLOT_COLS = 2
|
||||
|
||||
|
||||
# ── TCP reader ────────────────────────────────────────────────────────────────
|
||||
@@ -270,26 +269,108 @@ class DashboardTab(QWidget):
|
||||
# ── Plot tab ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class PlotTab(QWidget):
|
||||
NUM_PLOTS = 4
|
||||
TRACES = 4
|
||||
COLORS = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728"]
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(4, 4, 4, 4)
|
||||
layout.setSpacing(4)
|
||||
|
||||
self._fig = Figure(tight_layout=True)
|
||||
self._canvas = FigureCanvas(self._fig)
|
||||
self._canvas.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
layout.addWidget(self._canvas)
|
||||
# ── trace selectors ───────────────────────────────────────────────────
|
||||
sel = QWidget()
|
||||
sel.setMaximumHeight(120)
|
||||
sel_grid = QGridLayout(sel)
|
||||
sel_grid.setContentsMargins(0, 0, 0, 0)
|
||||
sel_grid.setSpacing(3)
|
||||
|
||||
self._series: dict[str, deque] = {} # path → deque[(datetime, float)]
|
||||
self._units: dict[str, str] = {} # path → unit string
|
||||
self._axes: dict[str, object] = {} # path → matplotlib Axes
|
||||
self._combos: list[list[QComboBox]] = []
|
||||
for i in range(self.NUM_PLOTS):
|
||||
lbl = QLabel(f"<b>Plot {i + 1}</b>")
|
||||
lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
sel_grid.addWidget(lbl, i, 0)
|
||||
row: list[QComboBox] = []
|
||||
for j in range(self.TRACES):
|
||||
cb = QComboBox()
|
||||
cb.setMinimumWidth(160)
|
||||
cb.addItem("—")
|
||||
cb.currentIndexChanged.connect(self._on_selection_changed)
|
||||
sel_grid.addWidget(cb, i, j + 1)
|
||||
row.append(cb)
|
||||
self._combos.append(row)
|
||||
|
||||
layout.addWidget(sel)
|
||||
|
||||
# ── toolbar row ───────────────────────────────────────────────────────
|
||||
tb = QWidget()
|
||||
tb_layout = QHBoxLayout(tb)
|
||||
tb_layout.setContentsMargins(0, 0, 0, 0)
|
||||
reset_btn = QPushButton("Reset zoom")
|
||||
reset_btn.setFixedWidth(100)
|
||||
reset_btn.clicked.connect(self._reset_zoom)
|
||||
tb_layout.addWidget(reset_btn)
|
||||
self._cursor_label = QLabel("")
|
||||
self._cursor_label.setTextFormat(Qt.RichText)
|
||||
self._cursor_label.setStyleSheet("font-size: 11px; color: #333;")
|
||||
tb_layout.addWidget(self._cursor_label)
|
||||
tb_layout.addStretch()
|
||||
layout.addWidget(tb)
|
||||
|
||||
# ── 2×2 pyqtgraph plot grid ───────────────────────────────────────────
|
||||
plot_container = QWidget()
|
||||
plot_grid = QGridLayout(plot_container)
|
||||
plot_grid.setContentsMargins(0, 0, 0, 0)
|
||||
plot_grid.setSpacing(4)
|
||||
|
||||
self._plot_widgets: list[pg.PlotWidget] = []
|
||||
self._vlines: list[pg.InfiniteLine] = []
|
||||
self._proxies: list = [] # keep SignalProxy refs alive
|
||||
|
||||
for i in range(self.NUM_PLOTS):
|
||||
pw = pg.PlotWidget(
|
||||
background="white",
|
||||
axisItems={"bottom": pg.DateAxisItem(orientation="bottom")},
|
||||
)
|
||||
pw.showGrid(x=True, y=True, alpha=0.3)
|
||||
pw.getAxis("bottom").setStyle(tickFont=QFont("", 7))
|
||||
pw.getAxis("left").setStyle(tickFont=QFont("", 7))
|
||||
pw.addLegend(offset=(0, 0), labelTextSize="7pt")
|
||||
pw.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
|
||||
vl = pg.InfiniteLine(
|
||||
angle=90, movable=False,
|
||||
pen=pg.mkPen("gray", width=1, style=Qt.DashLine),
|
||||
)
|
||||
vl.setVisible(False)
|
||||
pw.addItem(vl)
|
||||
self._vlines.append(vl)
|
||||
|
||||
proxy = pg.SignalProxy(
|
||||
pw.scene().sigMouseMoved,
|
||||
rateLimit=30,
|
||||
slot=lambda evt, idx=i: self._on_mouse_move(evt, idx),
|
||||
)
|
||||
self._proxies.append(proxy)
|
||||
|
||||
plot_grid.addWidget(pw, i // 2, i % 2)
|
||||
self._plot_widgets.append(pw)
|
||||
|
||||
layout.addWidget(plot_container)
|
||||
|
||||
# ── data ──────────────────────────────────────────────────────────────
|
||||
self._series: dict[str, deque] = {}
|
||||
self._units: dict[str, str] = {}
|
||||
|
||||
# ── public update called on every new snapshot ────────────────────────────
|
||||
|
||||
def update(self, snapshot: dict):
|
||||
ts_raw = snapshot.get("ts", "")
|
||||
try:
|
||||
ts = datetime.fromisoformat(ts_raw)
|
||||
ts = datetime.fromisoformat(ts_raw).timestamp()
|
||||
except (ValueError, TypeError):
|
||||
ts = datetime.utcnow()
|
||||
ts = datetime.utcnow().timestamp()
|
||||
|
||||
phys = _extract_phys(snapshot)
|
||||
if not phys:
|
||||
@@ -307,44 +388,94 @@ class PlotTab(QWidget):
|
||||
pass
|
||||
|
||||
if new_keys:
|
||||
self._rebuild_layout()
|
||||
self._refresh_combos()
|
||||
|
||||
self._redraw()
|
||||
|
||||
def _rebuild_layout(self):
|
||||
keys = list(self._series.keys())
|
||||
n = len(keys)
|
||||
cols = min(PLOT_COLS, n)
|
||||
rows = (n + cols - 1) // cols
|
||||
# ── combo management ──────────────────────────────────────────────────────
|
||||
|
||||
self._fig.clear()
|
||||
self._axes = {}
|
||||
def _refresh_combos(self):
|
||||
signals = sorted(self._series.keys())
|
||||
for row in self._combos:
|
||||
for cb in row:
|
||||
current = cb.currentText()
|
||||
cb.blockSignals(True)
|
||||
cb.clear()
|
||||
cb.addItem("—")
|
||||
for s in signals:
|
||||
cb.addItem(s)
|
||||
idx = cb.findText(current)
|
||||
cb.setCurrentIndex(idx if idx >= 0 else 0)
|
||||
cb.blockSignals(False)
|
||||
|
||||
for i, k in enumerate(keys):
|
||||
ax = self._fig.add_subplot(rows, cols, i + 1)
|
||||
label = k.split(".")[-1]
|
||||
ax.set_title(label, fontsize=9, pad=4)
|
||||
ax.set_ylabel(self._units.get(k, ""), fontsize=8)
|
||||
ax.tick_params(axis="both", labelsize=7)
|
||||
ax.grid(True, linewidth=0.4, alpha=0.5)
|
||||
self._axes[k] = ax
|
||||
def _on_selection_changed(self):
|
||||
self._redraw()
|
||||
|
||||
# ── drawing ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _redraw(self):
|
||||
for k, ax in self._axes.items():
|
||||
series = self._series.get(k)
|
||||
for i, pw in enumerate(self._plot_widgets):
|
||||
pi = pw.getPlotItem()
|
||||
pi.clear()
|
||||
pi.addItem(self._vlines[i])
|
||||
if pi.legend:
|
||||
pi.legend.clear()
|
||||
|
||||
for j, cb in enumerate(self._combos[i]):
|
||||
key = cb.currentText()
|
||||
if key == "—" or key not in self._series:
|
||||
continue
|
||||
series = self._series[key]
|
||||
if not series:
|
||||
continue
|
||||
times, values = zip(*series)
|
||||
label = f"{key.split('.')[-1]} [{self._units.get(key, '')}]"
|
||||
pw.plot(
|
||||
list(times), list(values),
|
||||
pen=pg.mkPen(self.COLORS[j], width=1.5),
|
||||
name=label,
|
||||
)
|
||||
|
||||
# ── zoom reset ────────────────────────────────────────────────────────────
|
||||
|
||||
def _reset_zoom(self):
|
||||
for pw in self._plot_widgets:
|
||||
pw.getViewBox().autoRange()
|
||||
|
||||
# ── cursor tracing ────────────────────────────────────────────────────────
|
||||
|
||||
def _on_mouse_move(self, evt, plot_idx: int):
|
||||
pos = evt[0]
|
||||
pw = self._plot_widgets[plot_idx]
|
||||
vl = self._vlines[plot_idx]
|
||||
|
||||
if not pw.sceneBoundingRect().contains(pos):
|
||||
vl.setVisible(False)
|
||||
self._cursor_label.setText("")
|
||||
return
|
||||
|
||||
x = pw.getPlotItem().vb.mapSceneToView(pos).x()
|
||||
vl.setPos(x)
|
||||
vl.setVisible(True)
|
||||
|
||||
parts = []
|
||||
for j, cb in enumerate(self._combos[plot_idx]):
|
||||
key = cb.currentText()
|
||||
if key == "—" or key not in self._series:
|
||||
continue
|
||||
series = list(self._series[key])
|
||||
if not series:
|
||||
continue
|
||||
times, values = zip(*series)
|
||||
ax.clear()
|
||||
ax.set_title(k.split(".")[-1], fontsize=9, pad=4)
|
||||
ax.set_ylabel(self._units.get(k, ""), fontsize=8)
|
||||
ax.tick_params(axis="both", labelsize=7)
|
||||
ax.grid(True, linewidth=0.4, alpha=0.5)
|
||||
ax.plot(times, values, linewidth=1.4, color="#1a6eb5")
|
||||
if len(times) > 1:
|
||||
ax.set_xlim(times[0], times[-1])
|
||||
self._fig.autofmt_xdate(rotation=25, ha="right")
|
||||
self._canvas.draw_idle()
|
||||
_, nearest_v = min(series, key=lambda p: abs(p[0] - x))
|
||||
label = key.split(".")[-1]
|
||||
unit = self._units.get(key, "")
|
||||
color = self.COLORS[j]
|
||||
parts.append(
|
||||
f'<span style="color:{color}">■</span>'
|
||||
f' {label}: <b>{nearest_v:.2f}</b> {unit}'
|
||||
)
|
||||
|
||||
self._cursor_label.setText(" ".join(parts))
|
||||
|
||||
|
||||
# ── Main window ───────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user