import sys, os _root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) _gui = os.path.dirname(os.path.abspath(__file__)) for _p in [_root, _gui]: if _p not in sys.path: sys.path.insert(0, _p) from PyQt5.QtWidgets import ( QWidget, QHBoxLayout, QVBoxLayout, QLabel, QListWidget, QListWidgetItem, QTableWidget, QTableWidgetItem, QHeaderView, QSplitter, QGroupBox, ) from PyQt5.QtCore import Qt, QTimer from PyQt5.QtGui import QColor, QBrush from data_model import ReceiverManager, GNSS_NAMES, GNSS_COLORS from sky_widget import SkyPlotWidget _COL_HEADERS = ['GNSS', 'SV', 'Elev °', 'Az °', 'C/N₀', 'Fix', 'Receiver'] class SkyTab(QWidget): def __init__(self, model: ReceiverManager): super().__init__() self._model = model self._store: dict = {} # (rid, gnss_id, sv_id) -> SatelliteData self._table_keys: list = [] # current row order as (rid, gnss_id, sv_id) outer = QVBoxLayout(self) outer.setContentsMargins(4, 4, 4, 4) outer.setSpacing(4) # ── Source selector ─────────────────────────────────────────────────── src_box = QGroupBox("Receiver Sources (check to include)") src_box.setMaximumHeight(80) src_bl = QHBoxLayout(src_box) src_bl.setContentsMargins(4, 2, 4, 2) self._src_list = QListWidget() self._src_list.setFlow(QListWidget.LeftToRight) self._src_list.setWrapping(True) self._src_list.setResizeMode(QListWidget.Adjust) self._src_list.itemChanged.connect(self._refresh_immediate) src_bl.addWidget(self._src_list) outer.addWidget(src_box) # ── Sky plot + satellite table ───────────────────────────────────────── splitter = QSplitter(Qt.Horizontal) self._sky = SkyPlotWidget() splitter.addWidget(self._sky) self._table = QTableWidget() self._table.setColumnCount(len(_COL_HEADERS)) self._table.setHorizontalHeaderLabels(_COL_HEADERS) self._table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents) self._table.horizontalHeader().setStretchLastSection(True) self._table.setEditTriggers(QTableWidget.NoEditTriggers) self._table.setSelectionBehavior(QTableWidget.SelectRows) self._table.setAlternatingRowColors(False) splitter.addWidget(self._table) splitter.setStretchFactor(0, 2) splitter.setStretchFactor(1, 1) outer.addWidget(splitter) # ── Table throttle timer (max ~2 Hz rebuilds) ───────────────────────── self._table_timer = QTimer(self) self._table_timer.setSingleShot(True) self._table_timer.setInterval(500) self._table_timer.timeout.connect(self._rebuild_table_from_store) # ── Model signals ───────────────────────────────────────────────────── model.receiver_added.connect(self._on_receiver_added) model.receiver_removed.connect(self._on_receiver_removed) model.satellite_update.connect(self._on_sat_update) # ── Source list management ──────────────────────────────────────────────── def _on_receiver_added(self, rid: str): item = QListWidgetItem(f" {rid} ") item.setFlags(item.flags() | Qt.ItemIsUserCheckable) item.setCheckState(Qt.Checked) item.setData(Qt.UserRole, rid) self._src_list.addItem(item) def _on_receiver_removed(self, rid: str): for i in range(self._src_list.count()): if self._src_list.item(i).data(Qt.UserRole) == rid: self._src_list.takeItem(i) break keys = [k for k in self._store if k[0] == rid] for k in keys: del self._store[k] self._refresh_immediate() def _selected_rids(self) -> set: result = set() for i in range(self._src_list.count()): item = self._src_list.item(i) if item.checkState() == Qt.Checked: result.add(item.data(Qt.UserRole)) return result def _visible_sats(self) -> list: selected = self._selected_rids() return [(k[0], self._store[k]) for k in self._store if k[0] in selected] # ── Data update ─────────────────────────────────────────────────────────── def _on_sat_update(self, rid: str, sats: list): for sat in sats: self._store[(rid, sat.gnss_id, sat.sv_id)] = sat # Sky widget: always submit latest (render is off-thread, trivially cheap) self._sky.update_data(self._visible_sats()) # Table: schedule a rebuild if one isn't already pending if not self._table_timer.isActive(): self._table_timer.start() def _refresh_immediate(self): """Called on source-checkbox toggle or receiver removal — rebuild now.""" self._table_timer.stop() visible = self._visible_sats() self._sky.update_data(visible) self._rebuild_table(visible) def _rebuild_table_from_store(self): self._rebuild_table(self._visible_sats()) # ── Table ───────────────────────────────────────────────────────────────── def _rebuild_table(self, sats: list): sorted_sats = sorted(sats, key=lambda x: (x[1].gnss_id, x[1].sv_id)) new_keys = [(rid, sat.gnss_id, sat.sv_id) for rid, sat in sorted_sats] if new_keys == self._table_keys: # Only values changed — update in place, scroll position untouched for row, (_, sat) in enumerate(sorted_sats): self._table.item(row, 2).setText(f"{sat.elevation:.0f}") self._table.item(row, 3).setText(f"{sat.azimuth:.0f}") self._table.item(row, 4).setText(f"{sat.cno:.1f}") self._table.item(row, 5).setText("✓" if sat.used_in_fix else "") return # Satellite set changed — full rebuild self._table_keys = new_keys vp = self._table.viewport() vp.setUpdatesEnabled(False) self._table.setSortingEnabled(False) self._table.setRowCount(len(sorted_sats)) for row, (rid, sat) in enumerate(sorted_sats): gnss_name = GNSS_NAMES.get(sat.gnss_id, f'SYS{sat.gnss_id}') bg = QColor(GNSS_COLORS.get(sat.gnss_id, '#9E9E9E')) bg.setAlpha(55) brush = QBrush(bg) def cell(text, br=brush): it = QTableWidgetItem(str(text)) it.setTextAlignment(Qt.AlignCenter) it.setBackground(br) return it self._table.setItem(row, 0, cell(gnss_name)) self._table.setItem(row, 1, cell(sat.sv_id)) self._table.setItem(row, 2, cell(f"{sat.elevation:.0f}")) self._table.setItem(row, 3, cell(f"{sat.azimuth:.0f}")) self._table.setItem(row, 4, cell(f"{sat.cno:.1f}")) self._table.setItem(row, 5, cell("✓" if sat.used_in_fix else "")) self._table.setItem(row, 6, cell(rid)) self._table.setSortingEnabled(True) vp.setUpdatesEnabled(True)