diff --git a/console_backend/src/observation_tab.rs b/console_backend/src/observation_tab.rs index 2afbb19a0..74bd21913 100644 --- a/console_backend/src/observation_tab.rs +++ b/console_backend/src/observation_tab.rs @@ -11,7 +11,8 @@ use crate::utils::serialize_capnproto_builder; #[derive(Clone, Debug)] pub struct ObservationTableRow { - pub prn: String, + pub sat: i16, + pub code: String, pub pseudo_range: f64, // (m) pub carrier_phase: f64, // (cycles) pub cn0: f64, // (dB-Hz) @@ -24,7 +25,8 @@ pub struct ObservationTableRow { impl ObservationTableRow { pub fn new() -> ObservationTableRow { ObservationTableRow { - prn: "".to_string(), + sat: 0, + code: "".to_string(), pseudo_range: 0.0, carrier_phase: 0.0, cn0: 0.0, @@ -94,6 +96,7 @@ impl ObservationTable { self.old_carrier_phase = self.new_carrier_phase.clone(); self.incoming_obs.clear(); self.new_carrier_phase.clear(); + self.rows.clear(); } pub fn obs_check(&mut self, tow: f64, wn: u16, obs_total: u8, obs_count: u8) -> bool { @@ -222,7 +225,8 @@ impl ObservationTab { }; let mut row = ObservationTableRow::new(); - row.prn = format!("{} ({})", obs_fields.sat, obs_fields.code).to_string(); + row.code = format!("{}", obs_fields.code); + row.sat = obs_fields.sat; row.pseudo_range = obs_fields.pseudo_range; row.carrier_phase = obs_fields.carrier_phase; row.cn0 = obs_fields.cn0 / 4.0; @@ -282,7 +286,8 @@ impl ObservationTab { for (idx, (_key, row)) in table.rows.iter().enumerate() { let mut list_item = rows.reborrow().get(idx as u32); - list_item.set_prn(&row.prn); + list_item.set_sat(row.sat); + list_item.set_code(&row.code); list_item.set_pseudo_range(row.pseudo_range); list_item.set_carrier_phase(row.carrier_phase); list_item.set_cn0(row.cn0); diff --git a/resources/ObservationTab.qml b/resources/ObservationTab.qml index 8a331a3df..61686cfe3 100644 --- a/resources/ObservationTab.qml +++ b/resources/ObservationTab.qml @@ -19,7 +19,7 @@ Item { orientation: Qt.Vertical width: parent.width height: parent.height - visible: localTable.populated || remoteTable.populated + visible: true Item { SplitView.minimumHeight: Constants.observationTab.titleAreaHight diff --git a/resources/ObservationTabComponents/ObservationFilterColumn.qml b/resources/ObservationTabComponents/ObservationFilterColumn.qml new file mode 100644 index 000000000..3cd580a60 --- /dev/null +++ b/resources/ObservationTabComponents/ObservationFilterColumn.qml @@ -0,0 +1,37 @@ +import "../Constants" +import "../TableComponents" +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import SwiftConsole 1.0 + +ColumnLayout { + id: obsFilterColumn + + property variant codes: [] + + visible: codes.length > 0 + Layout.alignment: Qt.AlignTop + + Repeater { + model: codes + + CheckBox { + indicator.width: 15 + indicator.height: 15 + spacing: 2 + padding: 2 + verticalPadding: 0.2 + checked: true + onCheckedChanged: { + observationTableModel.filter_prn(modelData, !checked); + observationTableModel.update(); + } + text: modelData + ": " + observationTableModel.codes.filter((x) => { + return x === modelData; + }).length + } + + } + +} diff --git a/resources/ObservationTabComponents/ObservationTable.qml b/resources/ObservationTabComponents/ObservationTable.qml index ecf3562eb..b722f2bf0 100644 --- a/resources/ObservationTabComponents/ObservationTable.qml +++ b/resources/ObservationTabComponents/ObservationTable.qml @@ -14,30 +14,25 @@ ColumnLayout { "family": Constants.genericTable.fontFamily, "pointSize": Constants.largePointSize }) + property variant avgWidth: parent.width / 8 + property variant columnWidths: [parent.width / 8, parent.width / 8, parent.width / 8, parent.width / 8, parent.width / 8, parent.width / 8, parent.width / 16, 3 * parent.width / 16] + property variant columnNames: ["PRN", "Pseudorange (m)", "Carrier Phase (cycles)", "C/N0 (dB-Hz)", "Meas. Doppler (Hz)", "Comp. Doppler (Hz)", "Lock", "Flags"] + property real mouse_x: 0 function update() { observationTableModel.update(); } spacing: 0 + onWidthChanged: { + innerTable.forceLayout(); + } + onHeightChanged: { + innerTable.forceLayout(); + } ObservationTableModel { id: observationTableModel - - onDataPopulated: { - var widthLeft = observationTable.width; - var idealColumnWidths = []; - for (var col = 0; col < headerRepeater.count; col++) { - var idealColumnWidth = Math.min(500, observationTableModel.columnWidth(col, tableFont, headerRepeater.itemAt(col).font)); - idealColumnWidths.push(idealColumnWidth); - widthLeft -= idealColumnWidths[col]; - } - var extraWidth = widthLeft / headerRepeater.count; - for (var col = 0; col < headerRepeater.count; col++) { - headerRepeater.itemAt(col).initialWidth = idealColumnWidths[col] + extraWidth; - } - innerTable.forceLayout(); - } } RowLayout { @@ -94,28 +89,90 @@ ColumnLayout { } - Item { - Layout.fillWidth: true - implicitHeight: header.implicitHeight - clip: true + RowLayout { + spacing: 3 + + ObservationFilterColumn { + codes: observationTableModel ? observationTableModel.gps_codes : 0 + } + + ObservationFilterColumn { + codes: observationTableModel ? observationTableModel.glo_codes : 0 + } + + ObservationFilterColumn { + codes: observationTableModel ? observationTableModel.bds_codes : 0 + } + + ObservationFilterColumn { + codes: observationTableModel ? observationTableModel.gal_codes : 0 + } - Row { - id: header + ObservationFilterColumn { + codes: observationTableModel ? observationTableModel.qzs_codes : 0 + } - width: innerTable.contentWidth - x: -innerTable.contentX - z: 1 - spacing: innerTable.columnSpacing + ObservationFilterColumn { + codes: observationTableModel ? observationTableModel.sbas_codes : 0 + } + + } - Repeater { - id: headerRepeater + HorizontalHeaderView { + id: horizontalHeader - model: observationTableModel ? observationTableModel.columnCount() : 0 + interactive: false + syncView: innerTable + z: Constants.genericTable.headerZOffset - SortableColumnHeading { - initialWidth: Math.min(500, observationTableModel.columnWidth(index, tableFont, font)) - height: Constants.genericTable.cellHeight - table: innerTable + delegate: Rectangle { + implicitWidth: columnWidths[index] + implicitHeight: Constants.genericTable.cellHeight + border.color: Constants.genericTable.borderColor + + Label { + width: parent.width + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + text: columnNames[index] + elide: Text.ElideRight + clip: true + font.family: Constants.genericTable.fontFamily + font.pointSize: Constants.largePointSize + } + + MouseArea { + width: Constants.genericTable.mouseAreaResizeWidth + height: parent.height + anchors.right: parent.right + cursorShape: Qt.SizeHorCursor + onPressed: { + mouse_x = mouseX; + } + onPositionChanged: { + if (pressed) { + var delta_x = (mouseX - mouse_x); + var next_idx = (index + 1) % 8; + var min_width = observationTable.width / 12; + if (columnWidths[index] + delta_x > min_width && columnWidths[next_idx] - delta_x > min_width) { + columnWidths[index] += delta_x; + columnWidths[next_idx] -= delta_x; + } + innerTable.forceLayout(); + } + } + } + + gradient: Gradient { + GradientStop { + position: 0 + color: Constants.genericTable.cellColor + } + + GradientStop { + position: 1 + color: Constants.genericTable.gradientColor } } @@ -127,24 +184,14 @@ ColumnLayout { TableView { id: innerTable - width: Math.min(header.width + 1, parent.width) Layout.fillHeight: true + Layout.fillWidth: true columnSpacing: -1 rowSpacing: -1 clip: true - onWidthChanged: { - // Don't ask why this is needed. It's a hack. - // If you want to find out, just comment out this code. - if (width === 0) { - width = Qt.binding(function() { - return Math.min(header.width + 1, observationTable.width); - }); - forceLayout(); - } - } boundsBehavior: Flickable.StopAtBounds columnWidthProvider: function(column) { - return headerRepeater.itemAt(column).width; + return columnWidths[column]; } model: observationTableModel diff --git a/resources/console_resources.qrc b/resources/console_resources.qrc index 345027d35..db43d4826 100644 --- a/resources/console_resources.qrc +++ b/resources/console_resources.qrc @@ -51,6 +51,7 @@ TrackingTabComponents/TrackingSkyPlotTab.qml ObservationTab.qml ObservationTabComponents/ObservationTable.qml + ObservationTabComponents/ObservationFilterColumn.qml MainDrawerComponents/LicensesPopup.qml UpdateTab.qml UpdateTabComponents/FirmwareVersionAndDownloadLabels.qml diff --git a/src/main/resources/base/console_backend.capnp b/src/main/resources/base/console_backend.capnp index 9442c31eb..e2010023f 100644 --- a/src/main/resources/base/console_backend.capnp +++ b/src/main/resources/base/console_backend.capnp @@ -187,7 +187,7 @@ struct BaselineTableStatus { } struct ObservationTableRow { - prn @0 :Text; + code @0 :Text; pseudoRange @1 :Float64; carrierPhase @2 :Float64; cn0 @3 :Float64; @@ -195,6 +195,7 @@ struct ObservationTableRow { computedDoppler @5 :Float64; lock @6 :UInt16; flags @7 :UInt8; + sat @8: Int16; } struct ObservationStatus { diff --git a/swiftnav_console/observation_tab.py b/swiftnav_console/observation_tab.py index e82a3304a..3d696987b 100644 --- a/swiftnav_console/observation_tab.py +++ b/swiftnav_console/observation_tab.py @@ -1,10 +1,12 @@ -from typing import Dict, Any +from typing import Dict, List, Any from copy import deepcopy +from collections import namedtuple from PySide2.QtCore import Property, Slot, Signal, QAbstractTableModel, Qt, QModelIndex -from PySide2.QtGui import QFont, QFontMetrics, QGuiApplication -from .constants import Keys +from .constants import Keys, QTKeys + +PrnEntry = namedtuple("PrnEntry", ["sat", "code"]) def localPadFloat(num, length, digits=2, allowNegative=True): @@ -36,6 +38,10 @@ def showFlags(flags): return flagStr +def format_prn_string(sat, code): + return "{} ({})".format(sat, code) + + REMOTE_OBSERVATION_TAB: Dict[str, Any] = { Keys.TOW: 0, Keys.WEEK: 0, @@ -49,7 +55,7 @@ def showFlags(flags): } -class ObservationTableModel(QAbstractTableModel): +class ObservationTableModel(QAbstractTableModel): # pylint: disable=too-many-public-methods # pylint: disable=too-many-instance-attributes # Might want to move the column_widths logic into QML and use QML's # FontMetrics, but for now this is ok. @@ -58,17 +64,22 @@ class ObservationTableModel(QAbstractTableModel): week_changed = Signal(int, arguments="week") row_count_changed = Signal(int, arguments="row_count") remote_changed = Signal(bool, arguments="remote") + show_gps_only_changed = Signal(bool, arguments="show_gps_only") + codes_changed = Signal() dataPopulated = Signal() column_metadata = [ - ("PRN", lambda columnValue: columnValue), - ("Pseudorange (m)", lambda columnValue: localPadFloat(columnValue, 1)), - ("Carrier Phase (cycles)", lambda columnValue: localPadFloat(columnValue, 1)), - ("C/N0 (dB-Hz)", lambda columnValue: localPadFloat(columnValue, 1)), - ("Meas. Doppler (Hz)", lambda columnValue: localPadFloat(columnValue, 1)), - ("Comp. Doppler (Hz)", lambda columnValue: localPadFloat(columnValue, 1)), - ("Lock", lambda columnValue: columnValue), - ("Flags", showFlags), + ( + "PRN", + lambda obsData: format_prn_string(obsData["prn"].sat, obsData["prn"].code), + ), + ("Pseudorange (m)", lambda obsData: localPadFloat(obsData["pseudoRange"], 1)), + ("Carrier Phase (cycles)", lambda obsData: localPadFloat(obsData["carrierPhase"], 1)), + ("C/N0 (dB-Hz)", lambda obsData: localPadFloat(obsData["cn0"], 1)), + ("Meas. Doppler (Hz)", lambda obsData: localPadFloat(obsData["measuredDoppler"], 1)), + ("Comp. Doppler (Hz)", lambda obsData: localPadFloat(obsData["computedDoppler"], 1)), + ("Lock", lambda obsData: obsData["lock"]), + ("Flags", lambda obsData: showFlags(obsData["lock"])), ] def __init__(self, parent=None): @@ -79,8 +90,46 @@ def __init__(self, parent=None): self._remote = False self._column_widths = [None] * len(ObservationTableModel.column_metadata) self._columnWidth_calls = [0] * len(self._column_widths) - self._column_widths_seen_data_all_columns = False self.json_col_names = None + self._total_rows = 0 + self._code_filters = set() + self._codes = set() + + def get_codes(self) -> List[List[str]]: + observation_tab = REMOTE_OBSERVATION_TAB if self._remote else LOCAL_OBSERVATION_TAB + return [entry["prn"].code for entry in observation_tab[Keys.ROWS]] + + def get_codes_by_prefix(self, prefix) -> List[List[str]]: + return sorted([code for code in self._codes if code.startswith(prefix)]) + + def get_gps_codes(self) -> List[List[str]]: + return self.get_codes_by_prefix("GPS") + + def get_glo_codes(self) -> List[List[str]]: + return self.get_codes_by_prefix("GLO") + + def get_bds_codes(self) -> List[List[str]]: + return self.get_codes_by_prefix("BDS") + + def get_gal_codes(self) -> List[List[str]]: + return self.get_codes_by_prefix("GAL") + + def get_qzs_codes(self) -> List[List[str]]: + return self.get_codes_by_prefix("QZS") + + def get_sbas_codes(self) -> List[List[str]]: + return self.get_codes_by_prefix("SBAS") + + def set_codes(self, codes) -> None: + self._codes = codes + self.codes_changed.emit() # type: ignore + + @Slot(str, bool) # type: ignore + def filter_prn(self, prn, val) -> None: + if val: + self._code_filters.add(prn) + else: + self._code_filters.discard(prn) def set_tow(self, tow) -> None: """Setter for _tow.""" @@ -106,6 +155,10 @@ def set_remote(self, remote) -> None: def get_remote(self) -> bool: return self._remote + def total_rows(self) -> int: + observation_tab = REMOTE_OBSERVATION_TAB if self._remote else LOCAL_OBSERVATION_TAB + return len(observation_tab[Keys.ROWS]) + def rowCount(self, parent=QModelIndex()): # pylint: disable=unused-argument return len(self._rows) @@ -113,9 +166,7 @@ def columnCount(self, parent=QModelIndex()): # pylint: disable=unused-argument return len(ObservationTableModel.column_metadata) def data(self, index, role=Qt.DisplayRole): # pylint: disable=unused-argument - return ObservationTableModel.column_metadata[index.column()][1]( - self._rows[index.row()][self.json_col_names[index.column()]] - ) + return ObservationTableModel.column_metadata[index.column()][1](self._rows[index.row()]) def headerData(self, section, orientation, role=Qt.DisplayRole): # pylint: disable=unused-argument return ObservationTableModel.column_metadata[section][0] if orientation == Qt.Horizontal else section @@ -127,24 +178,41 @@ def update(self) -> None: self.set_tow(observation_tab[Keys.TOW]) if observation_tab[Keys.WEEK] != self._week: self.set_week(observation_tab[Keys.WEEK]) + codes = list(set(entry["prn"].code for entry in observation_tab[Keys.ROWS])) + if codes != self._codes: + self.set_codes(codes) + # dicts are guaranteed to be in insertion order as of Python 3.7, so # no need to do key lookup # https://stackoverflow.com/questions/39980323/are-dictionaries-ordered-in-python-3-6 rowsToInsert = [] - for rowIdx in range(len(observation_tab[Keys.ROWS])): - row = observation_tab[Keys.ROWS][rowIdx] - for colIdx in range(len(row)): - column = list(row)[colIdx] - try: - modelRow = self._rows[rowIdx] - if row[column] != modelRow[column]: - modelRow[column] = row[column] - modelIdx = self.index(rowIdx, colIdx) - self.dataChanged.emit(modelIdx, modelIdx) # pylint: disable=no-member - except IndexError: - if self.json_col_names is None: - self.json_col_names = list(row.keys()) - rowsToInsert.append(deepcopy(row)) + rowIdx = 0 + for row in observation_tab[Keys.ROWS]: + if row["prn"].code in self._code_filters: + continue + + if rowIdx + 1 > len(self._rows): + rowsToInsert.append(deepcopy(row)) + continue + + current_row = self._rows[rowIdx] + + for colIdx, obsKey in enumerate(row): + if row[obsKey] != current_row[obsKey]: + current_row[obsKey] = row[obsKey] + modelIdx = self.index(rowIdx, colIdx) + self.dataChanged.emit(modelIdx, modelIdx) # pylint: disable=no-member + + rowIdx += 1 + + num_rows_removed = len(self._rows) - rowIdx + + # Remove old rows, if necessary + if num_rows_removed > 0: + self.beginRemoveRows(QModelIndex(), rowIdx, num_rows_removed) + self._rows = self._rows[:rowIdx] + self.endRemoveRows() + self.row_count_changed.emit(self.rowCount()) # type: ignore if len(rowsToInsert) > 0: self.beginInsertRows(QModelIndex(), len(self._rows), len(self._rows) + len(rowsToInsert) - 1) @@ -152,34 +220,8 @@ def update(self) -> None: self.endInsertRows() self.row_count_changed.emit(self.rowCount()) # type: ignore - if ( - len(self._rows) > 0 - and len(self._rows[-1]) == self.columnCount() - and not self._column_widths_seen_data_all_columns - ): + if len(self._rows) > 0 and len(self._rows[-1]) == self.columnCount(): self.dataPopulated.emit() # type: ignore - self._column_widths_seen_data_all_columns = True - - @Slot(int, result=int) # type: ignore - @Slot(int, QFont, result=int) # type: ignore - @Slot(int, QFont, QFont, result=int) # type: ignore - def columnWidth(self, column, tableFont=None, headerFont=None): - # Don't cache until the second call on a column because the first call to this per column - # is done before any data has come in, and columns are just sized to headers. - if not self._column_widths[column] or self._columnWidth_calls[column] < 2: - margin = 8 - defaultFontMetrics = QFontMetrics(QGuiApplication.font()) - tfm = defaultFontMetrics if tableFont is None else QFontMetrics(tableFont) - hfm = defaultFontMetrics if headerFont is None else QFontMetrics(headerFont) - ret = hfm.width(str(self.headerData(column, Qt.Horizontal)) + " ^") + margin - for rowIdx in range(len(self._rows)): - modelIdx = self.index(rowIdx, column) - cellData = str(self.data(modelIdx)) - ret = max(ret, tfm.width(cellData) + margin) - self._column_widths[column] = ret - - self._columnWidth_calls[column] += 1 - return self._column_widths[column] @Slot(float, int, result=str) # type: ignore @Slot(float, int, int, result=str) # type: ignore @@ -190,15 +232,22 @@ def padFloat(self, num, length, digits=2, allowNegative=True): # pylint: disabl # Intentionally do not provide a setter in the property - no setting from QML. week = Property(float, get_week, notify=week_changed) # type: ignore tow = Property(float, get_tow, notify=tow_changed) # type: ignore - row_count = Property(int, rowCount, notify=row_count_changed) # type: ignore + row_count = Property(int, total_rows, notify=row_count_changed) # type: ignore # Except this one - QML needs to specify if the model should be returning local data or remote data. remote = Property(bool, get_remote, set_remote, notify=remote_changed) # type: ignore + gps_codes = Property(QTKeys.QVARIANTLIST, get_gps_codes, notify=codes_changed) # type: ignore + glo_codes = Property(QTKeys.QVARIANTLIST, get_glo_codes, notify=codes_changed) # type: ignore + bds_codes = Property(QTKeys.QVARIANTLIST, get_bds_codes, notify=codes_changed) # type: ignore + gal_codes = Property(QTKeys.QVARIANTLIST, get_gal_codes, notify=codes_changed) # type: ignore + qzs_codes = Property(QTKeys.QVARIANTLIST, get_qzs_codes, notify=codes_changed) # type: ignore + sbas_codes = Property(QTKeys.QVARIANTLIST, get_sbas_codes, notify=codes_changed) # type: ignore + codes = Property(QTKeys.QVARIANTLIST, get_codes, notify=codes_changed) # type: ignore def obs_rows_to_json(rows): return [ { - "prn": entry.prn, + "prn": PrnEntry(entry.sat, entry.code), "pseudoRange": entry.pseudoRange, "carrierPhase": entry.carrierPhase, "cn0": entry.cn0,