diff --git a/gui_client.py b/gui_client.py
index 89aab0c..668e5cd 100644
--- a/gui_client.py
+++ b/gui_client.py
@@ -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"Plot {i + 1}")
+ 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'■'
+ f' {label}: {nearest_v:.2f} {unit}'
+ )
+
+ self._cursor_label.setText(" ".join(parts))
# ── Main window ───────────────────────────────────────────────────────────────
diff --git a/requirements.txt b/requirements.txt
index 852ba26..f6b403b 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,3 @@
weconnect
PyQt5
-matplotlib
\ No newline at end of file
+pyqtgraph