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