Plot tab: floating tooltip showing trace values at crosshair position
Add PlotTooltip — a frameless child widget that appears next to the mouse cursor whenever it moves over a plot. It lists each active trace with a colored bullet, short name, and the nearest y-value (unit appended). The tooltip is repositioned on every mouse-move event and clamped to stay within the plot widget bounds. Hidden on mouse-exit and on clear_data (reconnect). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -390,6 +390,43 @@ class TimeAxisItem(pg.AxisItem):
|
|||||||
return [datetime.fromtimestamp(v).strftime("%H:%M:%S") for v in values]
|
return [datetime.fromtimestamp(v).strftime("%H:%M:%S") for v in values]
|
||||||
|
|
||||||
|
|
||||||
|
class PlotTooltip(QWidget):
|
||||||
|
"""Floating value readout that follows the crosshair cursor."""
|
||||||
|
def __init__(self, parent: QWidget):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setAttribute(Qt.WA_TransparentForMouseEvents)
|
||||||
|
self.setStyleSheet(
|
||||||
|
"QWidget { background: rgba(18,18,30,215); border: 1px solid #444466;"
|
||||||
|
" border-radius: 3px; }"
|
||||||
|
"QLabel { color: #ddddee; font-size: 10px; border: none; }"
|
||||||
|
)
|
||||||
|
lay = QVBoxLayout(self)
|
||||||
|
lay.setContentsMargins(6, 4, 6, 4)
|
||||||
|
lay.setSpacing(1)
|
||||||
|
self._labels: list[QLabel] = []
|
||||||
|
self.hide()
|
||||||
|
|
||||||
|
def set_values(self, entries: list):
|
||||||
|
if not entries:
|
||||||
|
self.hide()
|
||||||
|
return
|
||||||
|
while len(self._labels) < len(entries):
|
||||||
|
lbl = QLabel()
|
||||||
|
lbl.setTextFormat(Qt.RichText)
|
||||||
|
self.layout().addWidget(lbl)
|
||||||
|
self._labels.append(lbl)
|
||||||
|
for j, lbl in enumerate(self._labels):
|
||||||
|
if j < len(entries):
|
||||||
|
color, name, val = entries[j]
|
||||||
|
lbl.setText(f'<span style="color:{color};">●</span> {name}: {val}')
|
||||||
|
lbl.show()
|
||||||
|
else:
|
||||||
|
lbl.hide()
|
||||||
|
self.adjustSize()
|
||||||
|
self.show()
|
||||||
|
self.raise_()
|
||||||
|
|
||||||
|
|
||||||
class PlotTab(QWidget):
|
class PlotTab(QWidget):
|
||||||
NUM_PLOTS = 8
|
NUM_PLOTS = 8
|
||||||
MAX_TRACES = 4
|
MAX_TRACES = 4
|
||||||
@@ -428,6 +465,7 @@ class PlotTab(QWidget):
|
|||||||
self._add_combos: list[QComboBox] = []
|
self._add_combos: list[QComboBox] = []
|
||||||
self._chip_bars: list[QHBoxLayout] = []
|
self._chip_bars: list[QHBoxLayout] = []
|
||||||
self._date_labels: list[QLabel] = []
|
self._date_labels: list[QLabel] = []
|
||||||
|
self._tooltips: list[PlotTooltip] = []
|
||||||
|
|
||||||
for i in range(self.NUM_PLOTS):
|
for i in range(self.NUM_PLOTS):
|
||||||
cell = QWidget()
|
cell = QWidget()
|
||||||
@@ -506,6 +544,7 @@ class PlotTab(QWidget):
|
|||||||
|
|
||||||
cell_vbox.addWidget(pw)
|
cell_vbox.addWidget(pw)
|
||||||
self._plot_widgets.append(pw)
|
self._plot_widgets.append(pw)
|
||||||
|
self._tooltips.append(PlotTooltip(pw))
|
||||||
plot_grid.addWidget(cell, i // 2, i % 2)
|
plot_grid.addWidget(cell, i // 2, i % 2)
|
||||||
|
|
||||||
scroll = QScrollArea()
|
scroll = QScrollArea()
|
||||||
@@ -563,6 +602,8 @@ class PlotTab(QWidget):
|
|||||||
self._redraw_timer.stop()
|
self._redraw_timer.stop()
|
||||||
self._series.clear()
|
self._series.clear()
|
||||||
self._units.clear()
|
self._units.clear()
|
||||||
|
for tt in self._tooltips:
|
||||||
|
tt.hide()
|
||||||
self._redraw()
|
self._redraw()
|
||||||
|
|
||||||
def update(self, snapshot: dict):
|
def update(self, snapshot: dict):
|
||||||
@@ -689,19 +730,45 @@ class PlotTab(QWidget):
|
|||||||
|
|
||||||
# ── cursor tracing ────────────────────────────────────────────────────────
|
# ── cursor tracing ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _y_at(self, key: str, x: float):
|
||||||
|
series = self._series.get(key)
|
||||||
|
if not series:
|
||||||
|
return None
|
||||||
|
return min(series, key=lambda p: abs(p[0] - x))[1]
|
||||||
|
|
||||||
def _on_mouse_move(self, evt, plot_idx: int):
|
def _on_mouse_move(self, evt, plot_idx: int):
|
||||||
pos = evt[0]
|
pos = evt[0]
|
||||||
pw = self._plot_widgets[plot_idx]
|
pw = self._plot_widgets[plot_idx]
|
||||||
vl = self._vlines[plot_idx]
|
vl = self._vlines[plot_idx]
|
||||||
|
tt = self._tooltips[plot_idx]
|
||||||
|
|
||||||
if not pw.sceneBoundingRect().contains(pos):
|
if not pw.sceneBoundingRect().contains(pos):
|
||||||
vl.setVisible(False)
|
vl.setVisible(False)
|
||||||
|
tt.hide()
|
||||||
return
|
return
|
||||||
|
|
||||||
x = pw.getPlotItem().vb.mapSceneToView(pos).x()
|
x = pw.getPlotItem().vb.mapSceneToView(pos).x()
|
||||||
vl.setPos(x)
|
vl.setPos(x)
|
||||||
vl.setVisible(True)
|
vl.setVisible(True)
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
for j, key in enumerate(self._traces[plot_idx]):
|
||||||
|
y = self._y_at(key, x)
|
||||||
|
if y is None:
|
||||||
|
continue
|
||||||
|
unit = self._units.get(key, "")
|
||||||
|
val_str = (f"{y:g} {unit}").strip()
|
||||||
|
entries.append((self.COLORS[j], _trace_label(key), val_str))
|
||||||
|
|
||||||
|
tt.set_values(entries)
|
||||||
|
if entries:
|
||||||
|
wpos = pw.mapFromScene(pos)
|
||||||
|
tx = int(wpos.x()) + 14
|
||||||
|
ty = int(wpos.y()) - tt.height() // 2
|
||||||
|
tx = min(tx, pw.width() - tt.width() - 4)
|
||||||
|
ty = max(4, min(ty, pw.height() - tt.height() - 4))
|
||||||
|
tt.move(tx, ty)
|
||||||
|
|
||||||
|
|
||||||
# ── Main window ───────────────────────────────────────────────────────────────
|
# ── Main window ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user