Redesign plot tab: per-plot add/remove traces, persist settings, readable axes

- Replace static combo grid with a control bar above each plot:
  add-trace dropdown + "+" button; active traces shown as colored chips with
  "×" to remove (max 4 per plot)
- Save trace selection to ~/.config/we_monitor/plot_settings.json on every
  change; restore automatically once matching signals arrive from the server
- Remove tiny tick-font overrides so axis labels are readable at default size
- Show received record count in the status bar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 21:51:39 +02:00
co-authored by Claude Sonnet 4.6
parent 04578781af
commit 9f0bdec9f1
+154 -66
View File
@@ -17,6 +17,7 @@ import sys
import threading import threading
from collections import deque from collections import deque
from datetime import datetime from datetime import datetime
from pathlib import Path
from PyQt5.QtCore import QObject, Qt, pyqtSignal from PyQt5.QtCore import QObject, Qt, pyqtSignal
from PyQt5.QtGui import QColor, QFont from PyQt5.QtGui import QColor, QFont
@@ -32,7 +33,8 @@ import pyqtgraph as pg
pg.setConfigOption("background", "w") pg.setConfigOption("background", "w")
pg.setConfigOption("foreground", "k") pg.setConfigOption("foreground", "k")
MAX_PLOT_POINTS = 200 MAX_PLOT_POINTS = 200
_SETTINGS_PATH = Path.home() / ".config" / "we_monitor" / "plot_settings.json"
# ── TCP reader ──────────────────────────────────────────────────────────────── # ── TCP reader ────────────────────────────────────────────────────────────────
@@ -269,41 +271,17 @@ class DashboardTab(QWidget):
# ── Plot tab ────────────────────────────────────────────────────────────────── # ── Plot tab ──────────────────────────────────────────────────────────────────
class PlotTab(QWidget): class PlotTab(QWidget):
NUM_PLOTS = 4 NUM_PLOTS = 4
TRACES = 4 MAX_TRACES = 4
COLORS = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728"] COLORS = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728"]
def __init__(self): def __init__(self):
super().__init__() super().__init__()
layout = QVBoxLayout(self) main_layout = QVBoxLayout(self)
layout.setContentsMargins(4, 4, 4, 4) main_layout.setContentsMargins(4, 4, 4, 4)
layout.setSpacing(4) main_layout.setSpacing(4)
# ── trace selectors ─────────────────────────────────────────────────── # ── toolbar ───────────────────────────────────────────────────────────
sel = QWidget()
sel.setMaximumHeight(120)
sel_grid = QGridLayout(sel)
sel_grid.setContentsMargins(0, 0, 0, 0)
sel_grid.setSpacing(3)
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 = QWidget()
tb_layout = QHBoxLayout(tb) tb_layout = QHBoxLayout(tb)
tb_layout.setContentsMargins(0, 0, 0, 0) tb_layout.setContentsMargins(0, 0, 0, 0)
@@ -316,27 +294,68 @@ class PlotTab(QWidget):
self._cursor_label.setStyleSheet("font-size: 11px; color: #333;") self._cursor_label.setStyleSheet("font-size: 11px; color: #333;")
tb_layout.addWidget(self._cursor_label) tb_layout.addWidget(self._cursor_label)
tb_layout.addStretch() tb_layout.addStretch()
layout.addWidget(tb) main_layout.addWidget(tb)
# ── 2×2 pyqtgraph plot grid ─────────────────────────────────────────── # ── data ──────────────────────────────────────────────────────────────
self._traces: list[list[str]] = [[] for _ in range(self.NUM_PLOTS)]
self._saved_traces: list[list[str]] = self._load_settings()
self._series: dict[str, deque] = {}
self._units: dict[str, str] = {}
# ── 2×2 plot grid (each cell: control bar + plot) ─────────────────────
plot_container = QWidget() plot_container = QWidget()
plot_grid = QGridLayout(plot_container) plot_grid = QGridLayout(plot_container)
plot_grid.setContentsMargins(0, 0, 0, 0) plot_grid.setContentsMargins(0, 0, 0, 0)
plot_grid.setSpacing(4) plot_grid.setSpacing(4)
self._plot_widgets: list[pg.PlotWidget] = [] self._plot_widgets: list[pg.PlotWidget] = []
self._vlines: list[pg.InfiniteLine] = [] self._vlines: list[pg.InfiniteLine] = []
self._proxies: list = [] # keep SignalProxy refs alive self._proxies: list = []
self._add_combos: list[QComboBox] = []
self._chip_bars: list[QHBoxLayout] = []
for i in range(self.NUM_PLOTS): for i in range(self.NUM_PLOTS):
cell = QWidget()
cell_vbox = QVBoxLayout(cell)
cell_vbox.setContentsMargins(0, 0, 0, 0)
cell_vbox.setSpacing(2)
# control bar
ctrl = QWidget()
ctrl.setMaximumHeight(30)
ctrl_row = QHBoxLayout(ctrl)
ctrl_row.setContentsMargins(0, 0, 0, 0)
ctrl_row.setSpacing(4)
add_combo = QComboBox()
add_combo.setMinimumWidth(180)
add_combo.addItem("— add trace —")
add_combo.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self._add_combos.append(add_combo)
ctrl_row.addWidget(add_combo)
add_btn = QPushButton("+")
add_btn.setFixedSize(26, 22)
add_btn.clicked.connect(lambda checked, idx=i: self._add_trace(idx))
ctrl_row.addWidget(add_btn)
ctrl_row.addSpacing(8)
chip_bar = QHBoxLayout()
chip_bar.setSpacing(4)
chip_bar.setContentsMargins(0, 0, 0, 0)
self._chip_bars.append(chip_bar)
ctrl_row.addLayout(chip_bar)
ctrl_row.addStretch()
cell_vbox.addWidget(ctrl)
# plot
pw = pg.PlotWidget( pw = pg.PlotWidget(
background="white", background="white",
axisItems={"bottom": pg.DateAxisItem(orientation="bottom")}, axisItems={"bottom": pg.DateAxisItem(orientation="bottom")},
) )
pw.showGrid(x=True, y=True, alpha=0.3) pw.showGrid(x=True, y=True, alpha=0.3)
pw.getAxis("bottom").setStyle(tickFont=QFont("", 7)) pw.addLegend(offset=(0, 0), labelTextSize="8pt")
pw.getAxis("left").setStyle(tickFont=QFont("", 7))
pw.addLegend(offset=(0, 0), labelTextSize="7pt")
pw.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) pw.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
vl = pg.InfiniteLine( vl = pg.InfiniteLine(
@@ -354,14 +373,55 @@ class PlotTab(QWidget):
) )
self._proxies.append(proxy) self._proxies.append(proxy)
plot_grid.addWidget(pw, i // 2, i % 2) cell_vbox.addWidget(pw)
self._plot_widgets.append(pw) self._plot_widgets.append(pw)
plot_grid.addWidget(cell, i // 2, i % 2)
layout.addWidget(plot_container) main_layout.addWidget(plot_container)
# ── data ────────────────────────────────────────────────────────────── # ── trace management ──────────────────────────────────────────────────────
self._series: dict[str, deque] = {}
self._units: dict[str, str] = {} def _add_trace(self, plot_idx: int):
key = self._add_combos[plot_idx].currentText()
traces = self._traces[plot_idx]
if key.startswith("") or key not in self._series:
return
if key in traces or len(traces) >= self.MAX_TRACES:
return
traces.append(key)
self._add_chip(plot_idx, key, len(traces) - 1)
self._save_settings()
self._redraw()
def _add_chip(self, plot_idx: int, key: str, color_idx: int):
short = key.split(".")[-1]
color = self.COLORS[color_idx]
btn = QPushButton(f"{short} ×")
btn.setFixedHeight(22)
btn.setStyleSheet(
f"QPushButton {{ background:{color}; color:white; border-radius:3px;"
f" padding:1px 6px; font-size:11px; }}"
f"QPushButton:hover {{ background:#888; }}"
)
btn.clicked.connect(lambda: self._remove_trace(plot_idx, key))
self._chip_bars[plot_idx].addWidget(btn)
def _remove_trace(self, plot_idx: int, key: str):
if key not in self._traces[plot_idx]:
return
self._traces[plot_idx].remove(key)
self._rebuild_chips(plot_idx)
self._save_settings()
self._redraw()
def _rebuild_chips(self, plot_idx: int):
bar = self._chip_bars[plot_idx]
while bar.count():
item = bar.takeAt(0)
if item.widget():
item.widget().deleteLater()
for j, key in enumerate(self._traces[plot_idx]):
self._add_chip(plot_idx, key, j)
# ── public update called on every new snapshot ──────────────────────────── # ── public update called on every new snapshot ────────────────────────────
@@ -392,24 +452,51 @@ class PlotTab(QWidget):
self._redraw() self._redraw()
# ── combo management ────────────────────────────────────────────────────── # ── combo refresh + saved-trace restore ──────────────────────────────────
def _refresh_combos(self): def _refresh_combos(self):
signals = sorted(self._series.keys()) signals = sorted(self._series.keys())
for row in self._combos: for combo in self._add_combos:
for cb in row: current = combo.currentText()
current = cb.currentText() combo.blockSignals(True)
cb.blockSignals(True) combo.clear()
cb.clear() combo.addItem("— add trace —")
cb.addItem("") for s in signals:
for s in signals: combo.addItem(s)
cb.addItem(s) idx = combo.findText(current)
idx = cb.findText(current) combo.setCurrentIndex(idx if idx >= 0 else 0)
cb.setCurrentIndex(idx if idx >= 0 else 0) combo.blockSignals(False)
cb.blockSignals(False)
def _on_selection_changed(self): for i, saved in enumerate(self._saved_traces):
self._redraw() for key in list(saved):
if key not in self._series:
continue
traces = self._traces[i]
if key not in traces and len(traces) < self.MAX_TRACES:
traces.append(key)
self._add_chip(i, key, len(traces) - 1)
saved.remove(key)
# ── settings persistence ──────────────────────────────────────────────────
def _save_settings(self):
try:
_SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
_SETTINGS_PATH.write_text(
json.dumps({"traces": [list(t) for t in self._traces]}, indent=2)
)
except OSError:
pass
def _load_settings(self) -> list[list[str]]:
try:
data = json.loads(_SETTINGS_PATH.read_text())
saved = data.get("traces", [])
while len(saved) < self.NUM_PLOTS:
saved.append([])
return [list(t) for t in saved[: self.NUM_PLOTS]]
except (OSError, json.JSONDecodeError, KeyError):
return [[] for _ in range(self.NUM_PLOTS)]
# ── drawing ─────────────────────────────────────────────────────────────── # ── drawing ───────────────────────────────────────────────────────────────
@@ -421,9 +508,8 @@ class PlotTab(QWidget):
if pi.legend: if pi.legend:
pi.legend.clear() pi.legend.clear()
for j, cb in enumerate(self._combos[i]): for j, key in enumerate(self._traces[i]):
key = cb.currentText() if key not in self._series:
if key == "" or key not in self._series:
continue continue
series = self._series[key] series = self._series[key]
if not series: if not series:
@@ -459,9 +545,8 @@ class PlotTab(QWidget):
vl.setVisible(True) vl.setVisible(True)
parts = [] parts = []
for j, cb in enumerate(self._combos[plot_idx]): for j, key in enumerate(self._traces[plot_idx]):
key = cb.currentText() if key not in self._series:
if key == "" or key not in self._series:
continue continue
series = list(self._series[key]) series = list(self._series[key])
if not series: if not series:
@@ -506,6 +591,7 @@ class MainWindow(QMainWindow):
self._connector.connect_requested.connect(self._connect) self._connector.connect_requested.connect(self._connect)
self._connector.disconnect_requested.connect(self._disconnect) self._connector.disconnect_requested.connect(self._disconnect)
self._record_count = 0
def _connect(self, host: str, port: int): def _connect(self, host: str, port: int):
err = self._reader.connect_to(host, port) err = self._reader.connect_to(host, port)
@@ -513,8 +599,9 @@ class MainWindow(QMainWindow):
self._connector.set_disconnected(err) self._connector.set_disconnected(err)
self._statusbar.showMessage(f"Connection failed: {err}") self._statusbar.showMessage(f"Connection failed: {err}")
else: else:
self._record_count = 0
self._connector.set_connected(host, port) self._connector.set_connected(host, port)
self._statusbar.showMessage(f"Connected to {host}:{port}") self._statusbar.showMessage(f"Connected to {host}:{port} | Records: 0")
def _disconnect(self): def _disconnect(self):
self._reader.disconnect() self._reader.disconnect()
@@ -524,8 +611,9 @@ class MainWindow(QMainWindow):
def _on_message(self, data: dict): def _on_message(self, data: dict):
self._dashboard.update(data) self._dashboard.update(data)
self._plot.update(data) self._plot.update(data)
self._record_count += 1
self._statusbar.showMessage( self._statusbar.showMessage(
f"Last snapshot: {data.get('ts', '?')}" f"Last snapshot: {data.get('ts', '?')} | Records: {self._record_count}"
) )
def _on_connection_lost(self, reason: str): def _on_connection_lost(self, reason: str):