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