diff options
Diffstat (limited to 'ui/plot_builder.py')
| -rw-r--r-- | ui/plot_builder.py | 510 |
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() |
