summaryrefslogtreecommitdiff
path: root/ui/plot_builder.py
diff options
context:
space:
mode:
Diffstat (limited to 'ui/plot_builder.py')
-rw-r--r--ui/plot_builder.py510
1 files changed, 0 insertions, 510 deletions
diff --git a/ui/plot_builder.py b/ui/plot_builder.py
deleted file mode 100644
index 7445bda..0000000
--- a/ui/plot_builder.py
+++ /dev/null
@@ -1,510 +0,0 @@
-"""
-ui/plot_builder.py
-
-Plot Builder — floating window for operators to:
- • Create named subplot rows
- • Drag channels (physical + derived) into any row
- • Name each axis, set Y range, grid, color
- • Reorder rows with ▲▼ arrows
- • Toggle per-trace visibility
- • Export / Import config as JSON
-
-Emits config_applied(ChartConfig) to rebuild the strip chart.
-"""
-
-from __future__ import annotations
-import json
-from copy import deepcopy
-from dataclasses import dataclass, field, asdict
-from typing import List, Optional, Tuple
-
-from PyQt6.QtWidgets import (
- QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
- QScrollArea, QFrame, QLineEdit, QDoubleSpinBox,
- QCheckBox, QComboBox, QColorDialog, QToolButton,
- QSizePolicy, QFileDialog, QMessageBox, QSpinBox,
-)
-from PyQt6.QtCore import Qt, pyqtSignal
-from PyQt6.QtGui import QColor, QFont, QCloseEvent
-
-from devices.device_registry import DeviceRegistry
-from core.signal_processor import SignalProcessor
-
-
-# ══════════════════════════════════════════════════════════════════════════════
-# Data model (same as before, kept self-contained for clarity)
-# ══════════════════════════════════════════════════════════════════════════════
-
-@dataclass
-class TraceSpec:
- device_id: str
- channel_id: str
- label: str = ""
- color: str = "#00d4ff"
- width: float = 1.8
- visible: bool = True
- style: str = "solid" # solid | dash | dot
-
-@dataclass
-class PlotSpec:
- title: str = "Plot"
- y_label: str = ""
- y_auto: bool = True
- y_min: float = -10.0
- y_max: float = 10.0
- grid: bool = True
- height: int = 1 # relative weight in splitter
- traces: List[TraceSpec] = field(default_factory=list)
-
-@dataclass
-class LayoutConfig:
- plots: List[PlotSpec] = field(default_factory=list)
- time_window_s: float = 30.0
- link_x: bool = True
- show_legend: bool = True
-
- def to_json(self) -> str:
- return json.dumps(asdict(self), indent=2)
-
- @staticmethod
- def from_json(s: str) -> "LayoutConfig":
- d = json.loads(s)
- plots = []
- for p in d.get("plots", []):
- traces = [TraceSpec(**t) for t in p.pop("traces", [])]
- plots.append(PlotSpec(**p, traces=traces))
- d.pop("plots")
- return LayoutConfig(**d, plots=plots)
-
-
-def build_default_layout(registry: DeviceRegistry,
- processor: SignalProcessor) -> LayoutConfig:
- cfg = LayoutConfig()
- for dev in registry.all_instances():
- enabled = [ch for ch in dev.info.channels if ch.enabled]
- if not enabled: continue
- p = PlotSpec(title=dev.info.name,
- y_label=enabled[0].unit if enabled else "")
- for ch in enabled:
- p.traces.append(TraceSpec(
- device_id=dev.info.device_id,
- channel_id=ch.channel_id,
- label=ch.name, color=ch.color,
- ))
- if p.traces: cfg.plots.append(p)
- # Add derived channels
- for dc in processor.get_derived():
- p = PlotSpec(title=dc.name, y_label=dc.unit)
- p.traces.append(TraceSpec(
- device_id="derived", channel_id=dc.channel_id,
- label=dc.name, color=dc.color,
- ))
- cfg.plots.append(p)
- return cfg
-
-
-# ══════════════════════════════════════════════════════════════════════════════
-# Color swatch button
-# ══════════════════════════════════════════════════════════════════════════════
-
-class ColorSwatch(QPushButton):
- color_changed = pyqtSignal(str)
-
- def __init__(self, color: str = "#00d4ff"):
- super().__init__()
- self._color = color
- self.setFixedSize(26, 20)
- self._paint()
- self.clicked.connect(self._pick)
-
- def _paint(self):
- self.setStyleSheet(
- f"QPushButton{{background:{self._color};border:1px solid #2a3558;"
- f"border-radius:3px;}}QPushButton:hover{{border-color:#3b82f6;}}")
-
- def _pick(self):
- c = QColorDialog.getColor(QColor(self._color), self)
- if c.isValid():
- self._color = c.name(); self._paint()
- self.color_changed.emit(self._color)
-
- @property
- def color(self): return self._color
- @color.setter
- def color(self, v):
- self._color = v; self._paint()
-
-
-# ══════════════════════════════════════════════════════════════════════════════
-# Trace row inside a plot block
-# ══════════════════════════════════════════════════════════════════════════════
-
-class TraceRow(QFrame):
- removed = pyqtSignal(object)
- changed = pyqtSignal()
-
- def __init__(self, trace: TraceSpec):
- super().__init__()
- self.trace = trace
- self.setObjectName("traceRow")
- self._build()
-
- def _build(self):
- lay = QHBoxLayout(self)
- lay.setContentsMargins(6, 3, 6, 3); lay.setSpacing(6)
-
- self._vis = QCheckBox(); self._vis.setChecked(self.trace.visible)
- self._vis.setToolTip("Visible"); self._vis.toggled.connect(self._on_vis)
- lay.addWidget(self._vis)
-
- self._color = ColorSwatch(self.trace.color)
- self._color.color_changed.connect(self._on_color)
- lay.addWidget(self._color)
-
- src = QLabel(f"{self.trace.device_id} / {self.trace.channel_id}")
- src.setObjectName("traceSource"); src.setMinimumWidth(130)
- lay.addWidget(src)
-
- self._lbl = QLineEdit(self.trace.label)
- self._lbl.setObjectName("traceLabel"); self._lbl.setPlaceholderText("Label…")
- self._lbl.textChanged.connect(lambda t: setattr(self.trace, "label", t))
- lay.addWidget(self._lbl, 1)
-
- self._style = QComboBox(); self._style.setObjectName("traceStyleCb")
- self._style.addItems(["solid", "dash", "dot"])
- self._style.setCurrentText(self.trace.style); self._style.setFixedWidth(64)
- self._style.currentTextChanged.connect(lambda s: setattr(self.trace, "style", s))
- lay.addWidget(self._style)
-
- self._w = QDoubleSpinBox(); self._w.setObjectName("traceWidthSpin")
- self._w.setRange(0.5, 6.0); self._w.setSingleStep(0.5)
- self._w.setValue(self.trace.width); self._w.setFixedWidth(56)
- self._w.valueChanged.connect(lambda v: setattr(self.trace, "width", v))
- lay.addWidget(self._w)
-
- rm = QToolButton(); rm.setText("✕"); rm.setObjectName("traceRemoveBtn")
- rm.setFixedSize(22, 22); rm.clicked.connect(lambda: self.removed.emit(self))
- lay.addWidget(rm)
-
- def _on_vis(self, v): self.trace.visible = v; self.changed.emit()
- def _on_color(self, c): self.trace.color = c; self.changed.emit()
-
-
-# ══════════════════════════════════════════════════════════════════════════════
-# Plot block — one collapsible row per PlotSpec
-# ══════════════════════════════════════════════════════════════════════════════
-
-class PlotBlock(QFrame):
- removed = pyqtSignal(object)
- changed = pyqtSignal()
- move_up = pyqtSignal(object)
- move_dn = pyqtSignal(object)
-
- def __init__(self, spec: PlotSpec, registry: DeviceRegistry,
- processor: SignalProcessor):
- super().__init__()
- self.spec = spec
- self.registry = registry
- self.processor = processor
- self._rows: List[TraceRow] = []
- self.setObjectName("plotBlock")
- self._build()
-
- def _build(self):
- outer = QVBoxLayout(self); outer.setContentsMargins(0,0,0,0); outer.setSpacing(0)
-
- # ── Header ────────────────────────────────────────────────────
- hdr = QWidget(); hdr.setObjectName("plotBlockHeader"); hdr.setFixedHeight(32)
- hl = QHBoxLayout(hdr); hl.setContentsMargins(8,0,6,0); hl.setSpacing(4)
-
- self._title = QLineEdit(self.spec.title)
- self._title.setObjectName("plotBlockTitle")
- self._title.textChanged.connect(lambda t: (setattr(self.spec, "title", t), self.changed.emit()))
- hl.addWidget(self._title, 1)
-
- for txt, sig in [("▲", self.move_up), ("▼", self.move_dn)]:
- b = QToolButton(); b.setText(txt); b.setObjectName("plotMoveBtn")
- b.setFixedSize(22,22); b.clicked.connect(lambda _, s=sig: s.emit(self))
- hl.addWidget(b)
-
- rm = QToolButton(); rm.setText("✕"); rm.setObjectName("plotRemoveBtn")
- rm.setFixedSize(22,22); rm.clicked.connect(lambda: self.removed.emit(self))
- hl.addWidget(rm)
- outer.addWidget(hdr)
-
- # ── Axis / range settings ─────────────────────────────────────
- sett = QWidget(); sett.setObjectName("plotBlockSettings")
- sl = QHBoxLayout(sett); sl.setContentsMargins(8,6,8,6); sl.setSpacing(8)
-
- sl.addWidget(QLabel("Y Axis:"))
- self._ylabel = QLineEdit(self.spec.y_label)
- self._ylabel.setObjectName("plotYLabelEdit"); self._ylabel.setMaximumWidth(130)
- self._ylabel.setPlaceholderText("e.g. Voltage (V)")
- self._ylabel.textChanged.connect(lambda t: setattr(self.spec, "y_label", t))
- sl.addWidget(self._ylabel)
-
- self._auto = QCheckBox("Auto Y"); self._auto.setChecked(self.spec.y_auto)
- self._auto.toggled.connect(self._on_auto)
- sl.addWidget(self._auto)
-
- sl.addWidget(QLabel("Min:"))
- self._ymin = QDoubleSpinBox(); self._ymin.setRange(-1e9,1e9)
- self._ymin.setValue(self.spec.y_min); self._ymin.setFixedWidth(72)
- self._ymin.setEnabled(not self.spec.y_auto)
- self._ymin.valueChanged.connect(lambda v: setattr(self.spec, "y_min", v))
- sl.addWidget(self._ymin)
-
- sl.addWidget(QLabel("Max:"))
- self._ymax = QDoubleSpinBox(); self._ymax.setRange(-1e9,1e9)
- self._ymax.setValue(self.spec.y_max); self._ymax.setFixedWidth(72)
- self._ymax.setEnabled(not self.spec.y_auto)
- self._ymax.valueChanged.connect(lambda v: setattr(self.spec, "y_max", v))
- sl.addWidget(self._ymax)
-
- self._grid = QCheckBox("Grid"); self._grid.setChecked(self.spec.grid)
- self._grid.toggled.connect(lambda v: setattr(self.spec, "grid", v))
- sl.addWidget(self._grid)
-
- sl.addWidget(QLabel("H:"))
- self._height_spin = QSpinBox(); self._height_spin.setRange(1,10)
- self._height_spin.setValue(self.spec.height); self._height_spin.setFixedWidth(44)
- self._height_spin.setToolTip("Relative row height")
- self._height_spin.valueChanged.connect(lambda v: setattr(self.spec, "height", v))
- sl.addWidget(self._height_spin)
- sl.addStretch()
- outer.addWidget(sett)
-
- # ── Trace rows ────────────────────────────────────────────────
- traces_w = QWidget(); traces_w.setObjectName("plotBlockTraces")
- self._tlay = QVBoxLayout(traces_w)
- self._tlay.setContentsMargins(4,2,4,4); self._tlay.setSpacing(2)
-
- for tr in self.spec.traces:
- self._add_trace_row(tr)
-
- # Channel picker + add button
- pick_row = QHBoxLayout()
- self._picker = self._make_picker()
- pick_row.addWidget(self._picker, 1)
- add_btn = QPushButton("+ Add Channel")
- add_btn.setObjectName("addTraceBtn")
- add_btn.clicked.connect(self._on_add_trace)
- pick_row.addWidget(add_btn)
- self._tlay.addLayout(pick_row)
- outer.addWidget(traces_w)
-
- def _make_picker(self) -> QComboBox:
- cb = QComboBox(); cb.setObjectName("channelPickerCb")
- cb.setPlaceholderText("Select channel…")
- for dev in self.registry.all_instances():
- for ch in dev.info.channels:
- if not ch.enabled:
- continue
- cb.addItem(f"{ch.name} ({dev.info.device_id}/{ch.channel_id})",
- userData=(dev.info.device_id, ch.channel_id, ch.name, ch.color))
- for dc in self.processor.get_derived():
- if not dc.enabled:
- continue
- cb.addItem(f"{dc.name} ([derived]/{dc.channel_id})",
- userData=("derived", dc.channel_id, dc.name, dc.color))
- return cb
-
- def _add_trace_row(self, trace: TraceSpec):
- row = TraceRow(trace)
- row.removed.connect(self._remove_trace)
- row.changed.connect(self.changed)
- self._rows.append(row)
- self._tlay.insertWidget(self._tlay.count() - 1, row)
-
- def _remove_trace(self, row: TraceRow):
- if row.trace in self.spec.traces: self.spec.traces.remove(row.trace)
- self._rows.remove(row)
- self._tlay.removeWidget(row); row.deleteLater()
- self.changed.emit()
-
- def _on_add_trace(self):
- data = self._picker.currentData()
- if not data: return
- dev_id, ch_id, ch_name, ch_color = data
- tr = TraceSpec(device_id=dev_id, channel_id=ch_id,
- label=ch_name, color=ch_color)
- self.spec.traces.append(tr)
- self._add_trace_row(tr)
- self.changed.emit()
-
- def _on_auto(self, v: bool):
- self.spec.y_auto = v
- self._ymin.setEnabled(not v); self._ymax.setEnabled(not v)
-
-
-# ══════════════════════════════════════════════════════════════════════════════
-# PlotBuilderWindow
-# ══════════════════════════════════════════════════════════════════════════════
-
-class PlotBuilderWindow(QWidget):
- """
- Floating Plot Builder. Emits config_applied(LayoutConfig) on Apply.
- """
-
- config_applied = pyqtSignal(object) # LayoutConfig
- closed = pyqtSignal()
-
- def __init__(self, registry: DeviceRegistry,
- processor: SignalProcessor,
- current: Optional[LayoutConfig] = None,
- parent=None):
- super().__init__(parent, Qt.WindowType.Window | Qt.WindowType.Tool)
- self.registry = registry
- self.processor = processor
- self.cfg = deepcopy(current) if current \
- else build_default_layout(registry, processor)
-
- self.setWindowTitle("Plot Builder")
- self.setMinimumSize(700, 780)
- self.resize(800, 780)
- self._blocks: List[PlotBlock] = []
- self._build()
- self._populate()
-
- def _build(self):
- root = QVBoxLayout(self); root.setContentsMargins(0,0,0,0); root.setSpacing(0)
-
- # ── Top bar ──────────────────────────────────────────────────
- top = QWidget(); top.setObjectName("cfgTopBar"); top.setFixedHeight(44)
- tl = QHBoxLayout(top); tl.setContentsMargins(12,0,12,0)
- title = QLabel("📐 PLOT BUILDER"); title.setObjectName("cfgTopBarTitle")
- tl.addWidget(title, 1)
- self._live = QCheckBox("Live preview"); self._live.setObjectName("cfgLiveChk")
- tl.addWidget(self._live)
- root.addWidget(top)
-
- # ── Global settings ──────────────────────────────────────────
- glob = QWidget(); glob.setObjectName("cfgGlobalBar")
- gl = QHBoxLayout(glob); gl.setContentsMargins(12,6,12,6); gl.setSpacing(12)
- gl.addWidget(QLabel("Time window:"))
- self._win = QDoubleSpinBox(); self._win.setRange(1,3600)
- self._win.setSuffix(" s"); self._win.setValue(self.cfg.time_window_s)
- self._win.setObjectName("cfgGlobalSpin")
- self._win.valueChanged.connect(self._global_changed)
- gl.addWidget(self._win)
- self._linkx = QCheckBox("Link X"); self._linkx.setChecked(self.cfg.link_x)
- self._linkx.setObjectName("cfgLiveChk"); self._linkx.toggled.connect(self._global_changed)
- gl.addWidget(self._linkx)
- self._legend = QCheckBox("Legend"); self._legend.setChecked(self.cfg.show_legend)
- self._legend.setObjectName("cfgLiveChk"); self._legend.toggled.connect(self._global_changed)
- gl.addWidget(self._legend)
- gl.addStretch()
- add_row_btn = QPushButton("+ Add Plot Row"); add_row_btn.setObjectName("addDeviceButton")
- add_row_btn.clicked.connect(self._add_plot)
- gl.addWidget(add_row_btn)
- root.addWidget(glob)
-
- div = QFrame(); div.setFrameShape(QFrame.Shape.HLine); div.setObjectName("devWindowDivider")
- root.addWidget(div)
-
- # ── Scrollable plot blocks ───────────────────────────────────
- scroll = QScrollArea(); scroll.setWidgetResizable(True); scroll.setObjectName("deviceScroll")
- self._container = QWidget()
- self._blay = QVBoxLayout(self._container)
- self._blay.setContentsMargins(10,10,10,10); self._blay.setSpacing(12)
- self._blay.addStretch()
- scroll.setWidget(self._container)
- root.addWidget(scroll, 1)
-
- # ── Bottom bar ───────────────────────────────────────────────
- btm = QWidget(); btm.setObjectName("cfgBottomBar")
- bl = QHBoxLayout(btm); bl.setContentsMargins(12,8,12,8)
- for lbl, fn in [("⟳ Rebuild from Devices", self._reset),
- ("↓ Export JSON", self._export),
- ("↑ Import JSON", self._import)]:
- b = QPushButton(lbl); b.setObjectName("configButton"); b.clicked.connect(fn)
- bl.addWidget(b)
- bl.addStretch()
- ap = QPushButton("✓ Apply"); ap.setObjectName("applyButton")
- ap.clicked.connect(self._apply)
- bl.addWidget(ap)
- root.addWidget(btm)
-
- def _populate(self):
- for b in self._blocks:
- self._blay.removeWidget(b); b.deleteLater()
- self._blocks.clear()
- for spec in self.cfg.plots:
- self._insert_block(spec)
-
- def _insert_block(self, spec: PlotSpec):
- blk = PlotBlock(spec, self.registry, self.processor)
- blk.removed.connect(self._remove_block)
- blk.changed.connect(self._maybe_live)
- blk.move_up.connect(self._move_up)
- blk.move_dn.connect(self._move_dn)
- self._blocks.append(blk)
- self._blay.insertWidget(self._blay.count() - 1, blk)
-
- def _add_plot(self):
- spec = PlotSpec(title=f"Plot {len(self.cfg.plots)+1}")
- self.cfg.plots.append(spec)
- self._insert_block(spec)
- self._maybe_live()
-
- def _remove_block(self, blk: PlotBlock):
- if blk.spec in self.cfg.plots: self.cfg.plots.remove(blk.spec)
- self._blocks.remove(blk)
- self._blay.removeWidget(blk); blk.deleteLater()
- self._maybe_live()
-
- def _move_up(self, blk: PlotBlock):
- i = self._blocks.index(blk)
- if i == 0: return
- self.cfg.plots.insert(i-1, self.cfg.plots.pop(i))
- self._blocks.insert(i-1, self._blocks.pop(i))
- self._blay.removeWidget(blk); self._blay.insertWidget(i-1, blk)
- self._maybe_live()
-
- def _move_dn(self, blk: PlotBlock):
- i = self._blocks.index(blk)
- if i >= len(self._blocks)-1: return
- self.cfg.plots.insert(i+1, self.cfg.plots.pop(i))
- self._blocks.insert(i+1, self._blocks.pop(i))
- self._blay.removeWidget(blk); self._blay.insertWidget(i+1, blk)
- self._maybe_live()
-
- def _global_changed(self):
- self.cfg.time_window_s = self._win.value()
- self.cfg.link_x = self._linkx.isChecked()
- self.cfg.show_legend = self._legend.isChecked()
- self._maybe_live()
-
- def _maybe_live(self):
- if self._live.isChecked():
- self.config_applied.emit(deepcopy(self.cfg))
-
- def _apply(self):
- self.config_applied.emit(deepcopy(self.cfg))
-
- def _reset(self):
- self.cfg = build_default_layout(self.registry, self.processor)
- self._win.setValue(self.cfg.time_window_s)
- self._populate(); self._maybe_live()
-
- def _export(self):
- path, _ = QFileDialog.getSaveFileName(self, "Export Layout", "layout.json", "JSON (*.json)")
- if path:
- open(path, "w").write(self.cfg.to_json())
-
- def _import(self):
- path, _ = QFileDialog.getOpenFileName(self, "Import Layout", "", "JSON (*.json)")
- if path:
- try:
- self.cfg = LayoutConfig.from_json(open(path).read())
- self._win.setValue(self.cfg.time_window_s)
- self._populate(); self._maybe_live()
- except Exception as e:
- QMessageBox.critical(self, "Import failed", str(e))
-
- def refresh_channels(self):
- """Call after devices/derived channels change."""
- self._populate()
-
- def closeEvent(self, event: QCloseEvent):
- self.closed.emit(); event.accept()