From 41588bc6291de3a4d037e37c4dc8ec6015fdfd32 Mon Sep 17 00:00:00 2001 From: Aliaksei Stratsilatau Date: Thu, 23 Apr 2026 12:53:43 -0400 Subject: [PATCH 1/7] refactor: unify Signals plugin (absorbs FilteredCharts) --- src/Plugins/Tools/Signals/ChartsView.qml | 228 +++++ src/Plugins/Tools/Signals/ColorChooser.qml | 84 ++ .../Tools/Signals/FilterKalmanSimple.qml | 135 +++ .../Tools/Signals/FilterRunningAvg.qml | 115 +++ .../{SignalButton.qml => MenuColor.qml} | 31 +- src/Plugins/Tools/Signals/MenuFilters.qml | 132 +++ src/Plugins/Tools/Signals/MenuItem.qml | 289 ++++++ src/Plugins/Tools/Signals/MenuPage.qml | 217 ++++ src/Plugins/Tools/Signals/MenuSet.qml | 151 +++ src/Plugins/Tools/Signals/PageButton.qml | 75 ++ src/Plugins/Tools/Signals/REFACTOR_NOTES.md | 143 +++ src/Plugins/Tools/Signals/Signals.qml | 947 +++++++++++++++--- src/Plugins/Tools/Signals/SignalsMenu.qml | 309 ++++++ .../Tools/Signals/SignalsMenuPopup.qml | 38 + src/Plugins/Tools/Signals/SignalsView.qml | 212 ---- 15 files changed, 2749 insertions(+), 357 deletions(-) create mode 100644 src/Plugins/Tools/Signals/ChartsView.qml create mode 100644 src/Plugins/Tools/Signals/ColorChooser.qml create mode 100644 src/Plugins/Tools/Signals/FilterKalmanSimple.qml create mode 100644 src/Plugins/Tools/Signals/FilterRunningAvg.qml rename src/Plugins/Tools/Signals/{SignalButton.qml => MenuColor.qml} (62%) create mode 100644 src/Plugins/Tools/Signals/MenuFilters.qml create mode 100644 src/Plugins/Tools/Signals/MenuItem.qml create mode 100644 src/Plugins/Tools/Signals/MenuPage.qml create mode 100644 src/Plugins/Tools/Signals/MenuSet.qml create mode 100644 src/Plugins/Tools/Signals/PageButton.qml create mode 100644 src/Plugins/Tools/Signals/REFACTOR_NOTES.md create mode 100644 src/Plugins/Tools/Signals/SignalsMenu.qml create mode 100644 src/Plugins/Tools/Signals/SignalsMenuPopup.qml delete mode 100644 src/Plugins/Tools/Signals/SignalsView.qml diff --git a/src/Plugins/Tools/Signals/ChartsView.qml b/src/Plugins/Tools/Signals/ChartsView.qml new file mode 100644 index 000000000..6f9847cd5 --- /dev/null +++ b/src/Plugins/Tools/Signals/ChartsView.qml @@ -0,0 +1,228 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick +import QtCharts +import QtQuick.Controls +import QtQml + +Item { + id: chartItem + + property var facts: [] + + property bool openGL: false + property bool smoothLines: ui.smooth + + property real lineWidth: ui.antialiasing ? 1.5 : 1 + property real lineWidthCmd: ui.antialiasing ? 2.1 : 2 + + property var speedFactor: [0.2, 0.5, 1, 2, 4] + property real speedFactorValue: 1 + + property bool resetEnable: false + + onFactsChanged: if (resetEnable) { + chartView.reset(); + resetEnable = false; + } + + Connections { + target: apx.fleet.current.mandala + function onTelemetryDecoded() { + chartView.appendData(); + } + } + + function updateSeriesColor() { + for (var i = 0; i < facts.length; ++i) { + if (!chartView.series(i)) + continue; + if (!facts || !facts[i] || !facts[i].opts) + continue; + if (chartView.series(i).color !== facts[i].opts.color) + chartView.series(i).color = facts[i].opts.color; + } + } + + function cycleSpeed() { + var idx = speedFactor.indexOf(speedFactorValue); + if (idx < 0 || idx >= speedFactor.length - 1) + speedFactorValue = speedFactor[0]; + else + speedFactorValue = speedFactor[idx + 1]; + } + + ChartView { + id: chartView + + antialiasing: ui.antialiasing + legend.visible: false + margins.top: 0 + margins.left: 0 + margins.bottom: 0 + margins.right: 0 + + anchors.fill: parent + property int margin: -8 + anchors.topMargin: margin + anchors.bottomMargin: margin + anchors.leftMargin: margin + anchors.rightMargin: margin + + plotAreaColor: "black" + backgroundColor: "black" + backgroundRoundness: 0 + dropShadowEnabled: false + + property int samples: Math.min(1000, Math.max(25, width / (3 * speedFactorValue))) + property int time: 0 + property bool dataExist: false + + ValueAxis { + id: axisX + property real t: chartView.time + Behavior on t { + enabled: ui.smooth && chartView.dataExist + NumberAnimation { + duration: 500 + } + } + min: t - chartView.samples + 20 + max: t + visible: false + gridVisible: false + labelsVisible: false + lineVisible: false + shadesVisible: false + titleVisible: false + } + ValueAxis { + id: axisY + min: -0 + max: 0 + tickCount: 4 + labelsColor: "white" + labelsFont.pixelSize: Qt.application.font.pixelSize * 0.7 + gridLineColor: "#555" + } + + property real dataPadding: 0.05 + property real dataPaddingZero: 0.05 + property var sdata: [] + property int timeRescale: 0 + + function reset() { + chartView.removeAllSeries(); + chartView.sdata = []; + chartView.time = 0; + axisY.min = -dataPaddingZero; + axisY.max = dataPaddingZero; + axisY.tickCount = 4; + axisY.applyNiceNumbers(); + } + + function appendData() { + var t = time + 1; + for (var i = 0; i < facts.length; ++i) { + appendDataValue(facts[i], t, i); + } + // Calc scale - reduce + if ((t - timeRescale) > 21) { + timeRescale = t; + var d = sdata.length - samples * facts.length; + if (d > 0) + sdata.splice(0, d); + var p = apx.seriesBounds(sdata); + var min = p.x - dataPadding; + var max = p.y + dataPadding; + if (min === max) { + min -= dataPaddingZero; + max += dataPaddingZero; + } + var bmod = false; + if (axisY.min < min) { + axisY.min = min; + bmod = true; + } + if (axisY.max > max) { + axisY.max = max; + bmod = true; + } + if (bmod) { + axisY.tickCount = 4; + axisY.applyNiceNumbers(); + } + } + time = t; + dataExist = true; + } + + function appendDataValue(fact, t, i) { + if (i >= chartView.count) + addFactSeries(fact); + var s = chartView.series(i); + + var value = fact.value !== undefined ? fact.value : eval(fact.name); + + if (!isFinite(value)) + value = 0; + s.append(t, value); + sdata.push(value); + // Instant rescale - grow + if (axisY.max < value) { + axisY.max = value + dataPadding; + } + if (axisY.min > value) { + axisY.min = value - dataPadding; + } + // Remove old + var cnt = samples; + if (s.count > cnt) + s.removePoints(0, s.count - cnt); + } + + function addFactSeries(fact) { + var s = chartView.createSeries(ChartView.SeriesTypeLine, fact.title, axisX, axisY); + s.useOpenGL = Qt.binding(function () { + return openGL; + }); + s.capStyle = Qt.RoundCap; + + var color = fact.opts ? fact.opts.color : undefined; + if (!color) + color = Qt.rgba(1, 1, 1, 1); + + if (fact.name && fact.name.startsWith("cmd")) { + s.width = Qt.binding(function () { + return lineWidthCmd; + }); + s.color = Qt.hsla(color.hslHue, color.hslSaturation / 2, color.hslLightness * 1.2, 1); + } else { + s.width = Qt.binding(function () { + return lineWidth; + }); + s.color = color; + } + return s; + } + } +} diff --git a/src/Plugins/Tools/Signals/ColorChooser.qml b/src/Plugins/Tools/Signals/ColorChooser.qml new file mode 100644 index 000000000..fe7995fa9 --- /dev/null +++ b/src/Plugins/Tools/Signals/ColorChooser.qml @@ -0,0 +1,84 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls + +import Apx.Common +import APX.Facts + +Item { + id: colorChooser + + property var color: fact.value !== undefined ? fact.value : "#ffffff" + property var space: Style.spacing * 1.2 + + implicitWidth: 280 * ui.scale + implicitHeight: 100 * ui.scale + + Rectangle { + id: colorBox + border.width: 0 + color: "#282828" + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: colorPreview.top + + GridLayout { + columns: 12 + anchors.fill: parent + anchors.margins: space + columnSpacing: space + rowSpacing: space + + Repeater { + model: [ + "#ff8a8a", "#ffc0cb", "#eee8aa", "#ffffe0", "#98fb98", "#acbf69", "#add8e6", "#4169e1", "#cf9fff", "#d8bfd8", "#ffebcd", "#ffffff", + "#ff7f50", "#ff69b4", "#ffd580", "#ffff8f", "#00ff00", "#6b8e23", "#87ceeb", "#0000ff", "#da70d6", "#dda0dd", "#deb887", "#d3d3d3", + "#ff4500", "#ff1493", "#ffa500", "#ffff00", "#32cd32", "#556b2f", "#00bfff", "#0000cd", "#bf40bf", "#ee82ee", "#d2691e", "#808080", + "#ff0000", "#dc143c", "#ff8c00", "#ffd700", "#008000", "#3c4d03", "#1e90ff", "#000080", "#800080", "#ba55d3", "#a52a2a", "#000000" + ] + + delegate: Rectangle { + property bool chosen: mouseArea.containsMouse + Layout.fillWidth: true + Layout.fillHeight: true + color: modelData + border.width: 1 + border.color: chosen ? "#b0c4de" : "transparent" + opacity: chosen ? 1 : 0.85 + scale: chosen ? 1.15 : 1.0 + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: fact.value = modelData + } + } + } + } + } + RowLayout { + id: colorPreview + anchors.bottom: parent.bottom + spacing: space + + Rectangle { + color: colorChooser.color + width: 24 * ui.scale + height: 16 * ui.scale + border.width: ui.scale + border.color: "#383838" + Layout.topMargin: space + Layout.leftMargin: space + opacity: 0.85 + } + + Label { + text: colorChooser.color + font.pixelSize: Style.fontSize * 0.7 + Layout.topMargin: space + Layout.alignment: Qt.AlignVCenter + } + } +} diff --git a/src/Plugins/Tools/Signals/FilterKalmanSimple.qml b/src/Plugins/Tools/Signals/FilterKalmanSimple.qml new file mode 100644 index 000000000..d0c4cd24f --- /dev/null +++ b/src/Plugins/Tools/Signals/FilterKalmanSimple.qml @@ -0,0 +1,135 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick + +import APX.Facts + +Fact { + id: ksFilter + + flags: Fact.Group + + property bool changes: false + property var coefs: [1, 1] + property var data: ({}) + + // Kalman state + property real kState: 0 + property real kCovariance: 0.1 + property bool initialized: false + + onChangesChanged: { if (changes) menuFilters.changes = true; } + + function filterValue(input) { + if (!initialized) { + kState = input; + kCovariance = 0.1; + initialized = true; + } + // Time update - prediction + var x0 = kState; + var p0 = kCovariance + coefs[0]; + + // Measurement update - correction + var k = p0 / (p0 + coefs[1]); + kState = x0 + k * (input - x0); + kCovariance = (1 - k) * p0; + return kState; + } + + function resetState() { + initialized = false; + } + + function load() { + for (var i = 0; i < size; ++i) { + var f = child(i); + var v = data[settingName(f)]; + if (v !== undefined) + f.value = v; + } + updateCoefs(); + } + + function save() { + data = {}; + for (var i = 0; i < size; ++i) { + var f = child(i); + var s = f.text.trim(); + if (s === "") + continue; + data[settingName(f)] = s; + } + updateCoefs(); + return data; + } + + function settingName(f) { + var n = f.name; + if (n.includes("_")) + return n.slice(0, n.indexOf("_")); + return n; + } + + function fillData() { + if (typeof value === 'object' && !Array.isArray(value) && value !== null) { + data = value; + load(); + } + } + + function updateFilterValue() { + ksFilter.value = "Km=" + ksMeasNoise.value + ",Ke=" + ksEnvNoise.value; + changes = true; + } + + function updateCoefs() { + coefs = [ksMeasNoise.value, ksEnvNoise.value]; + changes = false; + } + + Fact { + id: ksMeasNoise + name: "measurement_noise" + title: qsTr("Measurement noise") + descr: qsTr("Coefficient of measurement noise") + flags: Fact.Float + value: 1 + min: 0 + max: 10000 + precision: 3 + onValueChanged: updateFilterValue() + } + Fact { + id: ksEnvNoise + name: "environment_noise" + title: qsTr("Environment noise") + descr: qsTr("Coefficient of environment noise") + flags: Fact.Float + value: 1 + min: 0 + max: 10000 + precision: 3 + onValueChanged: updateFilterValue() + } +} + diff --git a/src/Plugins/Tools/Signals/FilterRunningAvg.qml b/src/Plugins/Tools/Signals/FilterRunningAvg.qml new file mode 100644 index 000000000..fb31fdd7c --- /dev/null +++ b/src/Plugins/Tools/Signals/FilterRunningAvg.qml @@ -0,0 +1,115 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick + +import APX.Facts + +Fact { + id: raFilter + + flags: Fact.Group + + property bool changes: false + property var data: ({}) + property var coef: 0.5 + + onChangesChanged: { if (changes) menuFilters.changes = true; } + + function applyFilter(v) { + return coef_value + (v - coef_value) * coef; + } + + property real coef_value: 0 + property bool initialized: false + + function filterValue(input) { + if (!initialized) { + coef_value = input; + initialized = true; + } + coef_value = coef_value + (input - coef_value) * coef; + return coef_value; + } + + function resetState() { + initialized = false; + } + + function load() { + for (var i = 0; i < size; ++i) { + var f = child(i); + var v = data[settingName(f)]; + if (v !== undefined) + f.value = v; + } + updateCoef(); + } + + function save() { + data = {}; + for (var i = 0; i < size; ++i) { + var f = child(i); + var s = f.text.trim(); + if (s === "") + continue; + data[settingName(f)] = s; + } + updateCoef(); + return data; + } + + function settingName(f) { + var n = f.name; + if (n.includes("_")) + return n.slice(0, n.indexOf("_")); + return n; + } + + function fillData() { + if (typeof value === 'object' && !Array.isArray(value) && value !== null) { + data = value; + load(); + } + } + + function updateCoef() { + coef = raCoef.value; + changes = false; + } + + Fact { + id: raCoef + name: "coefficient" + title: qsTr("Coefficient") + descr: qsTr("Coefficient for filtration") + flags: Fact.Float + value: 0.5 + min: 0 + max: 1 + precision: 3 + onValueChanged: { + raFilter.value = "K=" + value; + changes = true; + } + } +} + diff --git a/src/Plugins/Tools/Signals/SignalButton.qml b/src/Plugins/Tools/Signals/MenuColor.qml similarity index 62% rename from src/Plugins/Tools/Signals/SignalButton.qml rename to src/Plugins/Tools/Signals/MenuColor.qml index 4985ed648..f5ec38151 100644 --- a/src/Plugins/Tools/Signals/SignalButton.qml +++ b/src/Plugins/Tools/Signals/MenuColor.qml @@ -20,28 +20,21 @@ * along with this program. If not, see . */ import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import Apx.Common +import APX.Facts -TextButton { - Layout.fillHeight: true - checkable: true - ButtonGroup.group: buttonGroup +Fact { + value: "#ffffff" - property var values: [] - onActivated: signals.facts=Qt.binding(function(){return values}) + property bool changes: false - toolTip: getToolTip(values) - - function getToolTip(facts) - { - var s=[] - for(var i=0;i"+fact.descr+"") - } - return s.join("
") + onValueChanged: changes = true + onChangesChanged: { if (changes) menuItem.changes = true; } + Component.onCompleted: { + var opt = opts; + opt.page = "qrc:/Signals/ColorChooser.qml"; + opts = opt; } + } + diff --git a/src/Plugins/Tools/Signals/MenuFilters.qml b/src/Plugins/Tools/Signals/MenuFilters.qml new file mode 100644 index 000000000..791ae3c0a --- /dev/null +++ b/src/Plugins/Tools/Signals/MenuFilters.qml @@ -0,0 +1,132 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick + +import APX.Facts + +// Filter selector + params for a single chart item. +// Exposes value as the currently selected filter type string. +// To add a new filter type: +// 1. Create FilterMyType.qml with filterValue(input)/resetState() API +// 2. Add instance below with name matching the type string +// 3. Add the type string to fTypes.enumStrings +// 4. Handle it in MenuItem.qml updateValue() filter loop +Fact { + id: menuFilters + + property bool changes: false + property var data: ({}) + + signal removeTriggered + + onChangesChanged: { if (changes) menuItem.changes = true; } + + // Returns currently active filter type string ("none", "running_avg", "kalman_smp") + property string filterType: fTypes.text + + function getFilterType() { return fTypes.text; } + function getRunningAvgCoef() { return fRunningAvg.coef; } + function getKalmanSimpleCoefs() { return fKalmanSimple.coefs; } + + // Apply all enabled filters in sequence (future: loop over filter list) + // For current single-filter model, applies selected filter type + function applyFilters(v, stateObj) { + var type = fTypes.text; + if (type === "running_avg") { + return fRunningAvg.filterValue(v); + } else if (type === "kalman_smp") { + return fKalmanSimple.filterValue(v); + } + return v; + } + + function resetFilterState() { + fRunningAvg.resetState(); + fKalmanSimple.resetState(); + } + + function load() { + for (var i = 0; i < size; ++i) { + var f = child(i); + var v = data[settingName(f)]; + if (v !== undefined) + f.value = v; + } + fRunningAvg.fillData(); + fKalmanSimple.fillData(); + changes = false; + } + + function save() { + data = {}; + for (var i = 0; i < size; ++i) { + var f = child(i); + var s = f.text.trim(); + if (f.size !== 0) + s = f.save(); + if (s === "") + continue; + data[settingName(f)] = s; + } + changes = false; + return data; + } + + function settingName(f) { + var n = f.name; + if (n.includes("_")) + return n.slice(0, n.indexOf("_")); + return n; + } + + function fillData() { + if (typeof value === 'object' && !Array.isArray(value) && value !== null) { + data = value; + load(); + } + } + + Fact { + id: fTypes + name: "filters" + title: qsTr("Filter") + descr: qsTr("Selecting the filter to use") + flags: Fact.Enum + enumStrings: ["none", "running_avg", "kalman_smp"] + onTextChanged: menuFilters.value = text + onValueChanged: changes = true + } + FilterRunningAvg { + id: fRunningAvg + name: "running_avg" + title: qsTr("Running average") + descr: qsTr("Running average filter settings") + } + FilterKalmanSimple { + id: fKalmanSimple + name: "kalman_smp" + title: qsTr("Kalman simple") + descr: qsTr("Simple kalman filter settings") + } + +} + diff --git a/src/Plugins/Tools/Signals/MenuItem.qml b/src/Plugins/Tools/Signals/MenuItem.qml new file mode 100644 index 000000000..65f02823e --- /dev/null +++ b/src/Plugins/Tools/Signals/MenuItem.qml @@ -0,0 +1,289 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick + +import APX.Facts +import Apx.Common + +Fact { + id: menuItem + + flags: Fact.Group + precision: 2 + icon: "rectangle" + + property bool changes: false + property bool newItem: false + property var data: ({}) + + // Runtime computed value (filtered) + property var currentValue: undefined + property string warningMsg: "" + + signal addTriggered + signal removeTriggered + + // Warning/alarm state — propagated to PageButton + property bool hasWarning: false + property bool hasAlarm: false + + // Expose for PageButton tooltip + property string itemTitle: mTitle.text ? mTitle.text : mBind.text + property string itemColor: mColor.value ? mColor.value : "#ffffff" + + Component.onCompleted: { + load(data); + updateTitle(); + updateDescr(); + mTitle.valueChanged.connect(updateTitle); + mBind.valueChanged.connect(updateDescr); + mColor.valueChanged.connect(function() { updateDescr(); setColor(); }); + mFilters.valueChanged.connect(updateDescr); + mFact2Save.valueChanged.connect(updateDescr); + } + + onCurrentValueChanged: saveValue2Fact() + + function load() { + for (var i = 0; i < menuItem.size; ++i) { + var f = child(i); + var v = data[settingName(f)]; + if (v !== undefined) + f.value = v; + } + mFilters.fillData(); + mColor.value = data.color ? data.color : ""; + changes = false; + setColor(); + } + + function save() { + data = {}; + for (var i = 0; i < menuItem.size; ++i) { + var f = child(i); + var s = f.text.trim(); + if (f.size !== 0) + s = f.save(); + if (s === "") + continue; + data[settingName(f)] = s; + } + changes = false; + setColor(); + return data; + } + + function settingName(f) { + var n = f.name; + if (n.includes("_")) + return n.slice(0, n.indexOf("_")); + return n; + } + + function updateTitle() { + if (newItem) + return; + title = mTitle.text ? mTitle.text : mBind.text; + } + + function updateDescr() { + if (newItem) + return; + var descrList = []; + for (var i = 0; i < menuItem.size; ++i) { + var f = child(i); + if (!f.name) + continue; + if (f.name === "title") + continue; + if (f.text === "") + continue; + if (f.name === "color") + descrList.push(f.name.toUpperCase() + ": " + f.text.toUpperCase() + ""); + else + descrList.push(f.name.toUpperCase() + ": " + f.text); + } + descr = descrList.length > 0 ? descrList.join(", ") : ""; + } + + function setColor() { + var opt = menuItem.opts; + opt.color = mColor.value ? mColor.value : "#ffffff"; + opt.iconColor = opt.color; + menuItem.opts = opt; + mColor.changes = false; + if (menuPage) + menuPage.updatePageValues(); + if (signalsWidget) + signalsWidget.updateSeriesColors(); + } + + // Telemetry update — computes filtered value and evaluates warn/alarm + function updateValue() { + try { + var expr = mBind.text; + if (!expr || expr === "") + return; + var v = new Function('return ' + expr)(); + if (v === undefined) + throw new Error(qsTr("expression is undefined")); + + // On first value, seed filter state + if (currentValue === undefined) { + mFilters.resetFilterState(); + currentValue = v; + } + + // Run filter chain + var filtered = mFilters.applyFilters(v); + currentValue = filtered; + + // Evaluate warning/alarm expressions + var warnExpr = mWarn.text; + var alarmExpr = mAlarm.text; + hasWarning = warnExpr ? !!new Function('value', 'return ' + warnExpr)(filtered) : false; + hasAlarm = alarmExpr ? !!new Function('value', 'return ' + alarmExpr)(filtered) : false; + + } catch (e) { + emitWarning(e.message); + } + } + + function saveValue2Fact() { + var fname = mFact2Save.text; + if (!fname || fname === "") + return; + if (!apx.fleet.current.mandala.fact(fname, true)) + return; + if (!fname.includes("sns.scr")) { + emitWarning(qsTr("Unacceptable variable name. Use 'sns.scr' vars for saving!")); + return; + } + apx.fleet.current.mandala.fact(fname, true).setRawValueLocal(currentValue); + } + + function emitWarning(msg) { + if (warningMsg === msg && warnTimer.running) + return; + warningMsg = msg; + console.warn(qsTr("Chart") + " " + title + ": " + msg); + warnTimer.restart(); + } + + function hasScr(val) { + if (!val || val !== mFact2Save.text) + return false; + emitWarning(val + " " + qsTr("variable already used")); + return true; + } + + Timer { + id: warnTimer + interval: 10000 + } + + Fact { + id: mTitle + name: "chartname" + title: qsTr("Title") + descr: qsTr("Chart name") + flags: Fact.Text + onTextChanged: changes = true + } + Fact { + id: mBind + name: "bind" + title: qsTr("Expression") + descr: "Math.atan(est.att.pitch/est.att.roll)" + flags: Fact.Text + onTextChanged: changes = true + } + MenuColor { + id: mColor + name: "color" + title: qsTr("Color") + descr: qsTr("Chart color") + } + MenuFilters { + id: mFilters + name: "filt" + title: qsTr("Filters") + descr: qsTr("Filter settings") + } + Fact { + name: "warn" + id: mWarn + title: qsTr("Warning") + descr: qsTr("Expression for warning (receives 'value')") + flags: Fact.Text + onValueChanged: changes = true + } + Fact { + name: "alarm" + id: mAlarm + title: qsTr("Alarm") + descr: "value>1.8 || (value>0 && value<1)" + flags: Fact.Text + onValueChanged: changes = true + } + Fact { + name: "act" + title: qsTr("Action") + descr: "cmd.proc.action=proc_action_reset" + flags: Fact.Text + onValueChanged: changes = true + } + Fact { + id: mFact2Save + name: "save" + title: qsTr("Save to") + descr: qsTr("Variable for saving chart value (sns.scr.*)") + flags: Fact.Int + units: "mandala" + onTextChanged: { + signalsWidget.checkScrMatches(text); + changes = true; + } + } + + // Actions + Fact { + flags: (Fact.Action | Fact.Apply) + title: qsTr("Add") + enabled: newItem && mBind && mBind.value + icon: "plus-circle" + onTriggered: { + menuItem.menuBack(); + addTriggered(); + } + } + Fact { + flags: (Fact.Action | Fact.Remove) + title: qsTr("Remove") + visible: !newItem + icon: "delete" + onTriggered: { + removeTriggered(); + menuItem.deleteFact(); + } + } +} diff --git a/src/Plugins/Tools/Signals/MenuPage.qml b/src/Plugins/Tools/Signals/MenuPage.qml new file mode 100644 index 000000000..a084e1158 --- /dev/null +++ b/src/Plugins/Tools/Signals/MenuPage.qml @@ -0,0 +1,217 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick + +import APX.Facts + +Fact { + id: menuPage + + flags: (Fact.Group | Fact.FlatModel) + + // Page properties + property bool pinned: false + property real speed: 1.0 + + // Values list for the chart renderer — array of MenuItem objects + property var values: [] + + // Warning/alarm aggregated from items + property bool hasWarning: false + property bool hasAlarm: false + + // Set to true when created directly from Signals.qml (page-button flow) + // to show the per-page Save button. False when used inside SignalsMenu popup. + property bool isDirectEdit: false + + Component.onCompleted: { + pTitle.value = title; + } + + function addNewItem() { + mMenuNewItem.trigger(); + } + + // Rebuild the values array from current items + function updatePageValues() { + var list = []; + for (var i = 0; i < mItems.size; ++i) { + var it = mItems.child(i); + list.push(it); + } + values = list; + // Rebuild opts colors for chart + updateWarnings(); + } + + function updateWarnings() { + var warn = false; + var alarm = false; + for (var i = 0; i < mItems.size; ++i) { + var it = mItems.child(i); + if (it.hasWarning) + warn = true; + if (it.hasAlarm) + alarm = true; + } + hasWarning = warn; + hasAlarm = alarm; + } + + function updateChartsValues() { + for (var i = 0; i < mItems.size; ++i) + mItems.child(i).updateValue(); + updateWarnings(); + } + + function save() { + var items = []; + for (var i = 0; i < mItems.size; ++i) { + var item = mItems.child(i).save(); + if (!item.bind) + continue; + items.push(item); + } + return { + name: pTitle.value, + pin: mPin.value ? true : false, + speed: mSpeed.value, + items: items + }; + } + + function load(pageData) { + pTitle.value = pageData.name ? pageData.name : title; + pinned = pageData.pin ? true : false; + mPin.value = pinned; + speed = pageData.speed !== undefined ? pageData.speed : 1.0; + mSpeed.value = speed; + mItems.deleteChildren(); + var items = pageData.items ? pageData.items : []; + for (var i in items) + createItem(items[i]); + updatePageValues(); + } + + function createItem(itemData) { + if (!itemData.bind || itemData.bind === "") + return; + var c = createFact(mItems, "MenuItem.qml", { "data": itemData }); + c.parentFact = mItems; + c.removeTriggered.connect(function () { updatePageValues(); }); + c.titleChanged.connect(updatePageValues); + return c; + } + + function createFact(parent, url, opts) { + var component = Qt.createComponent(url); + if (component.status === Component.Ready) { + var c = component.createObject(parent, opts); + c.parentFact = parent; + return c; + } + console.warn("MenuPage.createFact: failed to load " + url + ": " + component.errorString()); + } + + function checkScrs(val) { + var matches = false; + for (var i = 0; i < mItems.size; ++i) + if (mItems.child(i).hasScr(val)) + matches = true; + return matches; + } + + // Page title fact + Fact { + id: pTitle + title: qsTr("Page name") + descr: qsTr("Short name shown on tab") + flags: Fact.Text + icon: "rename-box" + onValueChanged: { + menuPage.title = value; + } + } + Fact { + id: mPin + name: "pin" + title: qsTr("Pinned") + descr: qsTr("Show this page stacked with other pinned pages") + flags: Fact.Bool + onValueChanged: { + menuPage.pinned = value > 0; + if (typeof signalsWidget !== 'undefined' && signalsWidget) + signalsWidget.updateLayout(); + } + } + Fact { + id: mSpeed + name: "speed" + title: qsTr("Speed") + descr: qsTr("Chart scroll speed factor") + flags: Fact.Float + enumStrings: ["0.2", "0.5", "1", "2", "4"] + value: 1.0 + precision: 1 + onValueChanged: { + menuPage.speed = value; + } + } + + // Add new item action + MenuItem { + id: mMenuNewItem + title: qsTr("Add new chart") + descr: qsTr("Create and configure a new chart item") + icon: "plus-circle" + newItem: true + onAddTriggered: createItem(save()) + } + + Fact { + id: mItems + title: qsTr("Items") + flags: (Fact.Group | Fact.Section | Fact.DragChildren) + onSizeChanged: { + menuPage.updatePageValues(); + if (typeof signalsWidget !== 'undefined' && signalsWidget) + signalsWidget.updateLayout(); + } + } + + Fact { + flags: (Fact.Action | Fact.Apply) + title: qsTr("Save") + visible: menuPage.isDirectEdit + icon: "check-circle" + onTriggered: { + if (typeof signalsWidget !== 'undefined' && signalsWidget) + signalsWidget.saveSettings(); + } + } + Fact { + flags: (Fact.Action | Fact.Remove) + title: qsTr("Remove page") + icon: "delete" + onTriggered: menuPage.deleteFact() + } +} diff --git a/src/Plugins/Tools/Signals/MenuSet.qml b/src/Plugins/Tools/Signals/MenuSet.qml new file mode 100644 index 000000000..9876f86f4 --- /dev/null +++ b/src/Plugins/Tools/Signals/MenuSet.qml @@ -0,0 +1,151 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick + +import APX.Facts + +// One set — a named collection of pages. +// Mirrors NumbersMenuSet but contains pages (MenuPage) instead of values. +Fact { + id: setFact + + flags: (Fact.Group | Fact.FlatModel) + + property var pages: [] // from config / JSON + + signal selected(var num) + + Fact { + id: setTitle + title: qsTr("Description") + flags: Fact.Text + icon: "rename-box" + value: setFact.title + onValueChanged: setFact.title = value + } + + // Add a new blank page (up to 10) + Fact { + title: qsTr("Add page") + icon: "plus-circle" + flags: Fact.Action + enabled: mPages.size < 10 + onTriggered: { + var n = mPages.size + 1; + var pageData = { name: "P" + n, pin: false, speed: 1.0, items: [] }; + createPage(pageData); + } + } + + Fact { + id: mPages + title: qsTr("Pages") + flags: (Fact.Group | Fact.Section) + onSizeChanged: updateDescr() + } + + Component.onCompleted: { + updateSetItems(); + } + + function save() { + var savedPages = []; + for (var i = 0; i < mPages.size; ++i) { + var pg = mPages.child(i); + savedPages.push(pg.save()); + } + return { + title: title, + pages: savedPages + }; + } + + function updateSetItems() { + mPages.deleteChildren(); + var plist = pages ? pages : []; + for (var i in plist) + createPage(plist[i]); + updateDescr(); + } + + function createPage(pageData) { + if (mPages.size >= 10) + return null; + var component = Qt.createComponent("MenuPage.qml"); + if (component.status !== Component.Ready) { + console.warn("MenuSet: cannot load MenuPage.qml: " + component.errorString()); + return null; + } + var pg = component.createObject(mPages, { + "title": pageData.name ? pageData.name : ("P" + (mPages.size + 1)) + }); + pg.parentFact = mPages; + pg.load(pageData); + pg.destroyed.connect(updateDescr); + return pg; + } + + function getPages() { + var list = []; + for (var i = 0; i < mPages.size; ++i) + list.push(mPages.child(i)); + return list; + } + + function updateDescr() { + if (!setFact) + return; + var s = []; + for (var i = 0; i < mPages.size; ++i) + s.push(mPages.child(i).title); + descr = s.join(", "); + } + + function checkScrs(val) { + var matches = false; + for (var i = 0; i < mPages.size; ++i) + if (mPages.child(i).checkScrs(val)) + matches = true; + return matches; + } + + Fact { + flags: (Fact.Action | Fact.Remove) + title: qsTr("Remove set") + icon: "delete" + onTriggered: { + if (setFact.active) + selected(0); + setFact.destroy(); + } + } + Fact { + flags: (Fact.Action | Fact.Apply) + title: qsTr("Select and save") + visible: !setFact.active + icon: "check-circle" + onTriggered: { + setFact.menuBack(); + setFact.selected(setFact.num); + } + } +} diff --git a/src/Plugins/Tools/Signals/PageButton.qml b/src/Plugins/Tools/Signals/PageButton.qml new file mode 100644 index 000000000..bfca193e2 --- /dev/null +++ b/src/Plugins/Tools/Signals/PageButton.qml @@ -0,0 +1,75 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Controls.Material + +import Apx.Common + +TextButton { + id: pageBtn + + Layout.fillHeight: true + checkable: true + ButtonGroup.group: pageButtonGroup + + // The MenuPage Fact this button represents + property var page: null + + textColor: { + if (page && page.hasAlarm) + return Material.color(Material.Red); + if (page && page.hasWarning) + return Material.color(Material.Orange); + if (checked) + return Material.color(Material.Yellow); + return Material.primaryTextColor; + } + + // Pinned indicator — a subtle border + background: Rectangle { + color: checked + ? Qt.darker(Material.color(Material.BlueGrey), 1.5) + : "transparent" + border.width: (page && page.pinned) ? 1 : 0 + border.color: Material.color(Material.Cyan) + radius: height / 6 + } + + toolTip: buildToolTip() + + function buildToolTip() { + if (!page) + return text; + var s = []; + s.push("" + page.title + ""); + var values = page.values; + for (var i = 0; i < values.length; ++i) { + var it = values[i]; + s.push("" + it.itemTitle + ""); + } + if (page.pinned) + s.push(qsTr("(pinned)")); + return s.join("
"); + } +} diff --git a/src/Plugins/Tools/Signals/REFACTOR_NOTES.md b/src/Plugins/Tools/Signals/REFACTOR_NOTES.md new file mode 100644 index 000000000..cfcc7b50e --- /dev/null +++ b/src/Plugins/Tools/Signals/REFACTOR_NOTES.md @@ -0,0 +1,143 @@ +# Signals Plugin Refactor Notes + +## Source Analysis + +### FilteredCharts components → new name in Signals/ + +| Fc* file | New name | Role | +|---|---|---| +| FcChartsView.qml | ChartsView.qml | QtCharts renderer (replaces SignalsView.qml) | +| FcButton.qml | PageButton.qml | Per-page tab button | +| FcMenuSet.qml | MenuSet.qml | Per-page Fact-tree editor (now per-set) | +| FcMenuChart.qml | MenuItem.qml | Per-item editor | +| FcMenuFilters.qml | MenuFilters.qml | Filter selector per item | +| FcFilterRunningAvg.qml | FilterRunningAvg.qml | Running-avg filter params | +| FcFilterKalmanSimple.qml | FilterKalmanSimple.qml | Kalman filter params | +| FcMenuColor.qml | MenuColor.qml | Color fact that points to ColorChooser page | +| FcColorChooser.qml | ColorChooser.qml | 12×4 palette grid | +| FilteredCharts.qml | → absorbed into Signals.qml | Top-level widget | +| FilteredChartsPlugin.qml | → deleted | Plugin registration not needed | + +### Signals components → fate + +| Signals file | Fate | +|---|---| +| SignalsPlugin.qml | Keep (is the plugin registration) | +| Signals.qml | Rewrite — new top-level widget | +| SignalsView.qml | Delete — ChartsView.qml is a superset | +| SignalButton.qml | Delete — PageButton.qml replaces it | + +--- + +## Fact Tree Design + +``` +Signals (plugin, Rectangle root) + SignalsModel (ObjectModel — loads signals.json) + ┌─ [active index = json.active.signals] + └─ sets: array + └─ SignalsMenu (Fact, like NumbersMenu) + ├─ MenuSet #0 (Fact.Group+FlatModel) ← active set + │ ├─ title + │ └─ pages: MenuPage #0..N + │ ├─ name (string) + │ ├─ pin (bool) + │ ├─ speed (float [0.2,0.5,1,2,4]) + │ └─ items: MenuItem #0..M + │ ├─ bind (expression) + │ ├─ title (optional) + │ ├─ color (hex) + │ ├─ MenuFilters + │ │ ├─ FilterRunningAvg + │ │ └─ FilterKalmanSimple + │ ├─ warn + │ ├─ alarm + │ ├─ act + │ └─ save (sns.scr.* target) + └─ MenuSet #1 … +``` + +Mapping to Numbers pattern: +- `NumbersModel` → `SignalsModel` (ObjectModel, loads signals.json, exposes `edit()`) +- `NumbersMenu` → `SignalsMenu` (Fact, set editor, save/load signals.json) +- `NumbersMenuSet` → `MenuSet` (per set; has pages instead of values) +- `NumbersMenuNumber` → `MenuItem` (per item; adds color + filters + save) + +--- + +## JSON Schema + +```json +{ + "active": { "signals": 0 }, + "sets": [ + { + "title": "default", + "pages": [ + { + "name": "R", + "pin": false, + "speed": 1.0, + "items": [ + { "bind": "est.att.roll", "title": "", "color": "", "filters": [], "warn": "", "alarm": "", "act": "", "save": "" } + ] + } + ] + } + ] +} +``` + +Legacy migration: if file has top-level `page` (string) + `signalas` (array), wrap into one set "default" with one page. + +--- + +## Default Set — Binding Map + +Matches today's hardcoded Signals.qml buttons: + +| Page name | Items (bind expressions) | +|---|---| +| R | cmd.att.roll, est.att.roll | +| P | cmd.att.pitch, est.att.pitch | +| Y | cmd.pos.bearing, cmd.att.yaw, est.att.yaw | +| Axy | est.acc.x, est.acc.y | +| Az | est.acc.z | +| G | est.gyro.x, est.gyro.y, est.gyro.z | +| Pt | est.pos.altitude, est.pos.vspeed, est.air.airspeed | +| Ctr | ctr.att.ail, ctr.att.elv, ctr.att.rud, ctr.eng.thr, ctr.eng.prop, ctr.str.rud | +| RC | cmd.rc.roll, cmd.rc.pitch, cmd.rc.thr, cmd.rc.yaw | +| Usr | est.usr.u1, est.usr.u2, est.usr.u3, est.usr.u4, est.usr.u5, est.usr.u6 | + +--- + +## Adding a New Filter Type (future) + +1. Create `FilterMyType.qml` in `Signals/` — a `Fact { flags: Fact.Group }` that exposes `coef`/`coefs` and implements `applyFilter(v)` returning filtered value. +2. Add `FilterMyType { id: fMyType; name: "my_type" … }` inside `MenuFilters.qml`. +3. Add `"my_type"` to the `enumStrings` of `fTypes` in `MenuFilters.qml`. +4. Add a `case "my_type":` branch in `MenuItem.qml`'s `updateValue()`. + +--- + +## Files Summary (post-refactor) + +### Added to Signals/ +- ChartsView.qml +- PageButton.qml +- MenuSet.qml (replaces FcMenuSet — now manages pages, not sets) +- MenuPage.qml (new — per-page Fact editor) +- MenuItem.qml +- MenuFilters.qml +- FilterRunningAvg.qml +- FilterKalmanSimple.qml +- MenuColor.qml +- ColorChooser.qml +- SignalsMenu.qml (like NumbersMenu — manages sets) + +### Deleted from Signals/ +- SignalsView.qml +- SignalButton.qml + +### Deleted entirely +- src/Plugins/Tools/FilteredCharts/ (whole directory) diff --git a/src/Plugins/Tools/Signals/Signals.qml b/src/Plugins/Tools/Signals/Signals.qml index b79e19bbb..563f94ec7 100644 --- a/src/Plugins/Tools/Signals/Signals.qml +++ b/src/Plugins/Tools/Signals/Signals.qml @@ -23,181 +23,876 @@ import QtQuick import QtQuick.Layouts import QtQuick.Controls import QtQuick.Controls.Material -import QtCore import Apx.Common - +// Root widget for the Signals plugin. +// Accessible from child QML files via id: signalsWidget (context property set below). Rectangle { - id: control - - implicitHeight: layout.implicitHeight - implicitWidth: layout.implicitWidth + id: signalsWidget + implicitHeight: mainLayout.implicitHeight + implicitWidth: mainLayout.implicitWidth border.width: 0 color: "#000" + // Active pages — array of MenuPage Fact objects rebuilt from the active set + property var activePages: [] + // Currently selected (non-pinned) page + property var currentPage: null + // Pinned pages shown stacked + property var pinnedPages: [] + // Title of the active set + property string activeSetTitle: "" + // Index of the active set in the JSON + property int activeSetIndex: 0 + + // ----------------------------------------------------------------- + // Public API — called from child components (MenuItem, MenuPage, etc.) + // ----------------------------------------------------------------- + + function saveSettings() { + var json = _loadJson(); + json.sets = []; + var activeSets = _buildSetsFromPages(); + json.sets = activeSets.sets; + if (!json.active) json.active = {}; + json.active["signals"] = activeSetIndex; + application.prefs.saveFile("signals.json", JSON.stringify(json, ' ', 2)); + } + + function checkScrMatches(val) { + if (!val || val === "") return false; + var matches = false; + for (var i = 0; i < activePages.length; ++i) { + if (activePages[i].checkScrs(val)) matches = true; + } + return matches; + } + + function updateLayout() { + pinnedPages = activePages.filter(function(p) { return p.pinned; }); + } + + function updateSeriesColors() { + singleChart.updateSeriesColor(); + } + + function activatePage(page) { + if (!page) return; + currentPage = page; + singleChart.resetEnable = true; + singleChart.facts = Qt.binding(function() { + return signalsWidget.currentPage ? signalsWidget.currentPage.values : []; + }); + singleChart.speedFactorValue = Qt.binding(function() { + return signalsWidget.currentPage ? signalsWidget.currentPage.speed : 1.0; + }); + } + + // ----------------------------------------------------------------- + // Initialization + // ----------------------------------------------------------------- + + Component.onCompleted: { + loadSettings(); + } + + function loadSettings() { + // Destroy old page facts + _destroyActivePages(); + + var json = _loadJson(); + + // Legacy migration: {page, signalas} -> {active, sets} + if (json && json.signalas && !json.sets) + json = _migrateLegacy(json); + + var sets = (json && json.sets) ? json.sets : []; + if (sets.length === 0) sets = [_buildDefaultSet()]; + + var idx = 0; + if (json && json.active) + idx = json.active["signals"] || 0; + if (idx < 0 || idx >= sets.length) idx = 0; + + activeSetIndex = idx; + var activeSet = sets[idx]; + activeSetTitle = activeSet.title || "default"; + + var pages = activeSet.pages || []; + var newPages = []; + for (var i = 0; i < pages.length && i < 10; ++i) { + var pg = _createMenuPage(pages[i]); + if (pg) newPages.push(pg); + } + activePages = newPages; + pinnedPages = newPages.filter(function(p) { return p.pinned; }); + currentPage = newPages.length > 0 ? newPages[0] : null; + + if (currentPage) { + singleChart.resetEnable = true; + singleChart.facts = Qt.binding(function() { + return signalsWidget.currentPage ? signalsWidget.currentPage.values : []; + }); + singleChart.speedFactorValue = Qt.binding(function() { + return signalsWidget.currentPage ? signalsWidget.currentPage.speed : 1.0; + }); + } else { + singleChart.facts = []; + } + } + + function _loadJson() { + var f = application.prefs.loadFile("signals.json"); + return f ? JSON.parse(f) : {}; + } + + function _destroyActivePages() { + for (var i = 0; i < activePages.length; ++i) { + if (activePages[i] && typeof activePages[i].deleteFact === 'function') + activePages[i].deleteFact(); + } + activePages = []; + } + + function _createMenuPage(pageData) { + var component = Qt.createComponent("MenuPage.qml"); + if (component.status !== Component.Ready) { + console.warn("Signals: cannot load MenuPage.qml: " + component.errorString()); + return null; + } + var pg = component.createObject(signalsWidget, { + "title": pageData.name ? pageData.name : "P", + "isDirectEdit": true + }); + pg.parentFact = apx.fleet.local; + pg.load(pageData); + return pg; + } + + // Collect current page data back into sets JSON structure + function _buildSetsFromPages() { + var savedPages = []; + for (var i = 0; i < activePages.length; ++i) + savedPages.push(activePages[i].save()); + + // Load existing sets, replace active one + var json = _loadJson(); + var sets = (json && json.sets) ? JSON.parse(JSON.stringify(json.sets)) : []; + if (sets.length === 0) sets.push({ title: activeSetTitle, pages: [] }); + if (activeSetIndex >= sets.length) activeSetIndex = 0; + sets[activeSetIndex].pages = savedPages; + sets[activeSetIndex].title = activeSetTitle; + return { sets: sets }; + } + + // ----------------------------------------------------------------- + // Telemetry update + // ----------------------------------------------------------------- + + Connections { + target: apx.fleet.current.mandala + function onTelemetryDecoded() { + for (var i = 0; i < activePages.length; ++i) + activePages[i].updateChartsValues(); + } + } + + // ----------------------------------------------------------------- + // Layout + // ----------------------------------------------------------------- + ColumnLayout { - id: layout + id: mainLayout anchors.fill: parent spacing: 0 - SignalsView { - id: signals + // Pinned pages stacked above the main view + Repeater { + id: pinnedChartsRepeater + model: pinnedPages + + delegate: ChartsView { + Layout.fillWidth: true + Layout.preferredHeight: 80 * ui.scale + Layout.minimumHeight: 20 + facts: modelData.values + speedFactorValue: modelData.speed + } + } + + // Main (non-pinned) single chart + ChartsView { + id: singleChart facts: [] Layout.fillWidth: true Layout.fillHeight: true Layout.minimumHeight: 20 - Layout.preferredHeight: 130*ui.scale + Layout.preferredHeight: 130 * ui.scale } - TextInput { - id: textInput + ButtonGroup { + id: pageButtonGroup + } + // Bottom bar: page tabs, set label, speed button + RowLayout { + id: bottomBar Layout.fillWidth: true - Layout.minimumHeight: Style.fontSize - - // clip: true - // focus: true - visible: false + Layout.margins: Style.spacing + spacing: 3 + Layout.maximumHeight: 24 * ui.scale - horizontalAlignment: Text.AlignRight - verticalAlignment: Text.AlignVCenter + Repeater { + id: pageTabsRepeater + model: activePages - font: apx.font_narrow(Style.fontSize) + delegate: PageButton { + page: modelData + text: modelData.title + Layout.fillHeight: true + ButtonGroup.group: pageButtonGroup + checked: modelData === signalsWidget.currentPage + onClicked: { + if (checked) { + // already active — open page editor + if (modelData) modelData.trigger(); + } else { + signalsWidget.activatePage(modelData); + } + } + } + } - color: activeFocus?Material.color(Material.Yellow):Material.primaryTextColor - text: "est.air.airspeed" + Item { Layout.fillWidth: true } - activeFocusOnTab: true - selectByMouse: true + // Set name label — click opens sets editor + TextButton { + text: activeSetTitle || qsTr("default") + Layout.fillHeight: true + Layout.minimumWidth: height * 3 + toolTip: qsTr("Click to edit chart sets") + onClicked: openSetsEditor() + } - onEditingFinished: { - updateFacts() + // Speed button — cycles per-page speed factor + TextButton { + text: currentPage ? (currentPage.speed + "x") : "1x" + Layout.fillHeight: true + Layout.minimumWidth: height * 3 + toolTip: qsTr("Chart scroll speed") + onClicked: { + if (!currentPage) return; + var factors = [0.2, 0.5, 1, 2, 4]; + var idx = factors.indexOf(currentPage.speed); + var next = (idx >= 0 && idx < factors.length - 1) + ? factors[idx + 1] : factors[0]; + currentPage.speed = next; + singleChart.speedFactorValue = next; + saveSettings(); + } } - onActiveFocusChanged: { - if(activeFocus)selectAll(); + } + } + + // + button (top-right) — opens current page item editor + IconButton { + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: Style.spacing + size: Style.buttonSize * 0.7 + iconName: "plus" + toolTip: qsTr("Edit current page items") + opacity: ui.effects ? (hovered ? 1 : 0.5) : 1 + onTriggered: { + if (currentPage) + currentPage.addNewItem(); + } + } + + // ----------------------------------------------------------------- + // Sets editor popup + // ----------------------------------------------------------------- + + property var setsEditorPopup: null + + function openSetsEditor() { + if (setsEditorPopup) return; + var c = Qt.createComponent("SignalsMenuPopup.qml", Component.PreferSynchronous, ui.window); + if (c.status === Component.Ready) { + var obj = c.createObject(ui.window); + setsEditorPopup = obj; + obj.accepted.connect(function() { loadSettings(); }); + obj.closed.connect(function() { setsEditorPopup = null; }); + obj.open(); + } else { + console.warn("Signals: cannot open sets editor: " + c.errorString()); + } + } + + // ----------------------------------------------------------------- + // Legacy migration + // ----------------------------------------------------------------- + + function _migrateLegacy(oldJson) { + var items = (oldJson.signalas || []) + .map(function(it) { + return { + bind: it.bind || it.name || "", + title: it.title || "", + color: it.color || "", + filters: [], + warn: it.warn || "", + alarm: it.alarm || "", + act: it.act || "", + save: it.save || "" + }; + }) + .filter(function(it) { return it.bind !== ""; }); + + return { + active: { signals: 0 }, + sets: [{ + title: "default", + pages: [{ + name: oldJson.page || "page 1", + pin: false, + speed: 1.0, + items: items + }] + }] + }; + } + + // ----------------------------------------------------------------- + // Default set (mirrors the hardcoded buttons from the old Signals.qml) + // ----------------------------------------------------------------- + + function _buildDefaultSet() { + return { + title: "default", + pages: [ + { name: "R", pin: false, speed: 1.0, items: [ + { bind: "mandala.cmd.att.roll.value", title: "roll cmd" }, + { bind: "mandala.est.att.roll.value", title: "roll" } + ]}, + { name: "P", pin: false, speed: 1.0, items: [ + { bind: "mandala.cmd.att.pitch.value", title: "pitch cmd" }, + { bind: "mandala.est.att.pitch.value", title: "pitch" } + ]}, + { name: "Y", pin: false, speed: 1.0, items: [ + { bind: "mandala.cmd.pos.bearing.value", title: "bearing cmd" }, + { bind: "mandala.cmd.att.yaw.value", title: "yaw cmd" }, + { bind: "mandala.est.att.yaw.value", title: "yaw" } + ]}, + { name: "Axy", pin: false, speed: 1.0, items: [ + { bind: "mandala.est.acc.x.value", title: "Ax" }, + { bind: "mandala.est.acc.y.value", title: "Ay" } + ]}, + { name: "Az", pin: false, speed: 1.0, items: [ + { bind: "mandala.est.acc.z.value", title: "Az" } + ]}, + { name: "G", pin: false, speed: 1.0, items: [ + { bind: "mandala.est.gyro.x.value", title: "Gx" }, + { bind: "mandala.est.gyro.y.value", title: "Gy" }, + { bind: "mandala.est.gyro.z.value", title: "Gz" } + ]}, + { name: "Pt", pin: false, speed: 1.0, items: [ + { bind: "mandala.est.pos.altitude.value", title: "alt" }, + { bind: "mandala.est.pos.vspeed.value", title: "vspd" }, + { bind: "mandala.est.air.airspeed.value", title: "airspeed" } + ]}, + { name: "Ctr", pin: false, speed: 1.0, items: [ + { bind: "mandala.ctr.att.ail.value", title: "ail" }, + { bind: "mandala.ctr.att.elv.value", title: "elv" }, + { bind: "mandala.ctr.att.rud.value", title: "rud" }, + { bind: "mandala.ctr.eng.thr.value", title: "thr" }, + { bind: "mandala.ctr.eng.prop.value", title: "prop" }, + { bind: "mandala.ctr.str.rud.value", title: "str.rud" } + ]}, + { name: "RC", pin: false, speed: 1.0, items: [ + { bind: "mandala.cmd.rc.roll.value", title: "RC roll" }, + { bind: "mandala.cmd.rc.pitch.value", title: "RC pitch" }, + { bind: "mandala.cmd.rc.thr.value", title: "RC thr" }, + { bind: "mandala.cmd.rc.yaw.value", title: "RC yaw" } + ]}, + { name: "Usr", pin: false, speed: 1.0, items: [ + { bind: "mandala.est.usr.u1.value", title: "u1" }, + { bind: "mandala.est.usr.u2.value", title: "u2" }, + { bind: "mandala.est.usr.u3.value", title: "u3" }, + { bind: "mandala.est.usr.u4.value", title: "u4" }, + { bind: "mandala.est.usr.u5.value", title: "u5" }, + { bind: "mandala.est.usr.u6.value", title: "u6" } + ]} + ] + }; + } +} +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import QtQuick.Controls.Material + +import Apx.Common + +// Root widget — usable as signalsWidget from child QML files. +Rectangle { + id: signalsWidget + + implicitHeight: mainLayout.implicitHeight + implicitWidth: mainLayout.implicitWidth + border.width: 0 + color: "#000" + + // Active set — a MenuSet Fact instance, rebuilt when set changes + property var activeSet: null + // Ordered pages from the active set + property var activePages: [] + // Currently selected page (for the single-view tab slot) + property var currentPage: null + + // ----------------------------------------------------------------- + // Public API used by child components + // ----------------------------------------------------------------- + + function saveSettings() { + var fjson = application.prefs.loadFile("signals.json"); + var json = fjson ? JSON.parse(fjson) : {}; + if (!json.active) json.active = {}; + json.sets = []; + var activeIdx = 0; + for (var i = 0; i < setsFact.size; ++i) { + var sf = setsFact.child(i); + json.sets.push(sf.save()); + if (sf.active) activeIdx = i; + } + json.active["signals"] = activeIdx; + application.prefs.saveFile("signals.json", JSON.stringify(json, ' ', 2)); + } + + function checkScrMatches(val) { + if (!activeSet) return false; + return activeSet.checkScrs(val); + } + + function activatePage(page) { + if (currentPage === page) return; + currentPage = page; + // Update chart view + singleChart.resetEnable = true; + singleChart.facts = Qt.binding(function() { return currentPage ? currentPage.values : []; }); + singleChart.speedFactorValue = page ? page.speed : 1.0; + } + + function updateLayout() { + // Rebuild page buttons and pinned charts + rebuildPageButtons(); + rebuildPinnedCharts(); + } + + function updateSeriesColors() { + singleChart.updateSeriesColor(); + for (var i = 0; i < pinnedChartsRepeater.count; ++i) { + var item = pinnedChartsRepeater.itemAt(i); + if (item && item.chartView) + item.chartView.updateSeriesColor(); + } + } + + // ----------------------------------------------------------------- + // Load/rebuild sets from signals.json + // ----------------------------------------------------------------- + + Component.onCompleted: { + loadSettings(); + } + + function loadSettings() { + // Destroy old set facts + setsFact.deleteChildren(); + + var f = application.prefs.loadFile("signals.json"); + var json = f ? JSON.parse(f) : {}; + + // Legacy migration + if (json && json.signalas && !json.sets) + json = migrateLegacy(json); + + var sets = []; + var activeIdx = 0; + + if (json && json.sets) { + for (var i in json.sets) { + var s = json.sets[i]; + if (s && s.pages) sets.push(s); } - onVisibleChanged: if(visible)forceActiveFocus() - Component.onCompleted: updateFacts() - - property var facts: [] - function updateFacts() - { - var flist=[] - var list=textInput.text.split(',') - for(var i=0;i= sets.length) + activeIdx = 0; + + // Create MenuSet facts + for (var i in sets) { + var ms = createMenuSet(sets[i]); + ms.active = (parseInt(i) === activeIdx); + } + + rebuildFromActiveSet(); + } + + function createMenuSet(setData) { + var component = Qt.createComponent("MenuSet.qml"); + if (component.status !== Component.Ready) { + console.warn("Signals: cannot load MenuSet.qml: " + component.errorString()); + return null; + } + var ms = component.createObject(setsFact, { + "title": setData.title || "set", + "pages": setData.pages || [] + }); + ms.parentFact = setsFact; + return ms; + } + + // Find the active set and rebuild pages/buttons/charts + function rebuildFromActiveSet() { + activeSet = null; + for (var i = 0; i < setsFact.size; ++i) { + var sf = setsFact.child(i); + if (sf.active) { activeSet = sf; break; } + } + if (!activeSet && setsFact.size > 0) + activeSet = setsFact.child(0); + + activePages = activeSet ? activeSet.getPages() : []; + currentPage = activePages.length > 0 ? activePages[0] : null; + + rebuildPageButtons(); + rebuildPinnedCharts(); + + if (currentPage) { + singleChart.resetEnable = true; + singleChart.facts = Qt.binding(function() { return currentPage ? currentPage.values : []; }); + singleChart.speedFactorValue = currentPage.speed; + } else { + singleChart.facts = []; + } + } + + // ----------------------------------------------------------------- + // Page buttons + // ----------------------------------------------------------------- + + property var pageButtons: [] + + function rebuildPageButtons() { + // Destroy old + for (var i = 0; i < pageButtons.length; ++i) + pageButtons[i].destroy(); + pageButtons = []; + + if (!activePages) return; + var btns = []; + for (var i = 0; i < activePages.length; ++i) { + var pg = activePages[i]; + var btn = pageBtnComponent.createObject(bottomBar, { + "page": pg, + "text": Qt.binding(function() { return pg.title; }) + }); + btn.page = pg; + btns.push(btn); + } + pageButtons = btns; + // Select first + if (pageButtons.length > 0) + pageButtonGroup.checkedButton = pageButtons[0]; + } + + // ----------------------------------------------------------------- + // Pinned charts (stacked) + // ----------------------------------------------------------------- + + property var pinnedPages: [] + + function rebuildPinnedCharts() { + pinnedPages = activePages.filter(function(p) { return p.pinned; }); + } + + // ----------------------------------------------------------------- + // Telemetry update + // ----------------------------------------------------------------- - if(plusButton.checked) - signals.facts=flist + Connections { + target: apx.fleet.current.mandala + function onTelemetryDecoded() { + for (var i = 0; i < activePages.length; ++i) + activePages[i].updateChartsValues(); + } + } + + // ----------------------------------------------------------------- + // Fact container for MenuSet instances (not shown in menu UI here) + // ----------------------------------------------------------------- + + import APX.Facts + + property var setsFact: _setsFact + + // We can't embed a Fact as a property in Rectangle with APX.Facts import + // so use a loader trick — see _setsFact defined below. + + // ----------------------------------------------------------------- + // Layout + // ----------------------------------------------------------------- + + ColumnLayout { + id: mainLayout + anchors.fill: parent + spacing: 0 + + // Pinned pages (stacked, only shown when at least one page is pinned) + Repeater { + id: pinnedChartsRepeater + model: pinnedPages + + delegate: Item { + property alias chartView: _pinnedChart + Layout.fillWidth: true + Layout.preferredHeight: 80 * ui.scale + Layout.minimumHeight: 20 + + ChartsView { + id: _pinnedChart + anchors.fill: parent + facts: modelData.values + speedFactorValue: modelData.speed + } } } + // Single (non-pinned) chart view for the active tab + ChartsView { + id: singleChart + facts: [] + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumHeight: 20 + Layout.preferredHeight: 130 * ui.scale + } + ButtonGroup { - id: buttonGroup - //buttons: bottomArea.buttons + id: pageButtonGroup } + // Bottom bar: page tabs + set label + speed button RowLayout { - id: bottomArea + id: bottomBar Layout.fillWidth: true Layout.margins: Style.spacing spacing: 3 - Layout.maximumHeight: 24*ui.scale - SignalButton { - text: "R" - values: [ mandala.cmd.att.roll, mandala.est.att.roll ] - } - SignalButton { - text: "P" - values: [ mandala.cmd.att.pitch, mandala.est.att.pitch ] - } - SignalButton { - text: "Y" - values: [ mandala.cmd.pos.bearing, mandala.cmd.att.yaw, mandala.est.att.yaw ] - } - SignalButton { - text: "Axy" - values: [ mandala.est.acc.x, mandala.est.acc.y ] - } - SignalButton { - text: "Az" - values: [ mandala.est.acc.z ] - } - SignalButton { - text: "G" - values: [ mandala.est.gyro.x, mandala.est.gyro.y, mandala.est.gyro.z ] - } - SignalButton { - text: "Pt" - values: [ mandala.est.pos.altitude, mandala.est.pos.vspeed, mandala.est.air.airspeed ] - } - SignalButton { - text: "Ctr" - values: [ mandala.ctr.att.ail, mandala.ctr.att.elv, mandala.ctr.att.rud, mandala.ctr.eng.thr, mandala.ctr.eng.prop, mandala.ctr.str.rud ] - } - SignalButton { - text: "RC" - values: [ mandala.cmd.rc.roll, mandala.cmd.rc.pitch, mandala.cmd.rc.thr, mandala.cmd.rc.yaw ] - } - SignalButton { - text: "Usr" - values: [ mandala.est.usr.u1, mandala.est.usr.u2, mandala.est.usr.u3, mandala.est.usr.u4, mandala.est.usr.u5, mandala.est.usr.u6 ] - } + Layout.maximumHeight: 24 * ui.scale - SignalButton { - id: plusButton - text: "+" - values: textInput.facts - onCheckedChanged: { - if(!checked) - textInput.visible=false - } - onPressed: { - if(checked) - textInput.visible=!textInput.visible - } + // Page tab buttons are added here dynamically via rebuildPageButtons() + // (they are children of bottomBar and part of pageButtonGroup) + + Item { Layout.fillWidth: true } // spacer + + // Set name label — click opens sets editor + TextButton { + id: setLabel + text: activeSet ? activeSet.title : "" + Layout.fillHeight: true + Layout.minimumWidth: height * 3 + toolTip: qsTr("Click to edit chart sets") + onClicked: openSetsEditor() } + // Speed button — cycles per-page speed TextButton { - text: signals.speedFactorValue+"x" - onClicked: signals.changeSpeed() + id: speedBtn + text: currentPage ? (currentPage.speed + "x") : "1x" Layout.fillHeight: true - Layout.minimumWidth: height*3 + Layout.minimumWidth: height * 3 + onClicked: { + if (!currentPage) return; + var factors = [0.2, 0.5, 1, 2, 4]; + var idx = factors.indexOf(currentPage.speed); + var next = (idx >= 0 && idx < factors.length - 1) ? factors[idx + 1] : factors[0]; + currentPage.speed = next; + singleChart.speedFactorValue = next; + saveSettings(); + } } } } - property string currentPage: buttonGroup.checkedButton.text - - Settings { - category: "signals" - property alias page: control.currentPage - property alias custom: textInput.text - } - Component.onCompleted: { - for(var i=0;i + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick + +import APX.Facts + +// Top-level sets editor — mirrors NumbersMenu. +// Loaded as a pinned Fact menu popup. +// Persists to signals.json using same {active, sets} structure as numbers.json. +Fact { + id: setsFact + + property var defaults + property string settingsName: "signals" + property bool destroyOnClose: true + + name: settingsName + flags: (Fact.Group | Fact.DragChildren) + title: qsTr("Signals") + ": " + settingsName + descr: qsTr("Signals chart sets editor") + icon: "poll" + + signal accepted() + + Component.onCompleted: open() + + function open() { + if (!parentFact) { + var p = parent; + parentFact = apx.fleet.local; + parent = p; + } + loadSettings(); + } + + function close() { + if (!destroyOnClose) { + setsFact.deleteChildren(); + loadSettings(); + menuBack(); + return; + } + setsFact.deleteChildren(); + menuBack(); + parentFact = null; + } + + function loadSettings() { + var sets = []; + var f = application.prefs.loadFile("signals.json"); + var json = f ? JSON.parse(f) : {}; + + // Legacy migration: {page, signalas} → {active, sets} + if (json && json.signalas && !json.sets) { + json = migrateLegacy(json); + } + + var currentSetIdx = -1; + + if (json && json.sets) { + for (var i in json.sets) { + var set = json.sets[i]; + if (!set || !set.pages) continue; + sets.push(set); + } + var setIdx = json.active ? json.active[settingsName] : 0; + if (setIdx >= 0 && setIdx < sets.length) + currentSetIdx = setIdx; + else if (sets.length > 0) + currentSetIdx = 0; + } + + // First-run: generate default set + if (sets.length <= 0 || !json.active) { + var defSet = buildDefaultSet(); + sets.push(defSet); + currentSetIdx = 0; + } + + for (var i in sets) { + var c = createSetFact(sets[i]); + c.selected.connect(select); + c.selected.connect(saveSettings); + } + select(currentSetIdx); + } + + function saveSettings() { + var fjson = application.prefs.loadFile("signals.json"); + var json = fjson ? JSON.parse(fjson) : {}; + if (!json.active) + json.active = {}; + json.active[settingsName] = 0; + json.sets = []; + for (var i = 0; i < size; ++i) { + var setf = child(i); + var set = setf.save(); + if (!set) continue; + json.sets.push(set); + if (setf.active) + json.active[settingsName] = i; + } + application.prefs.saveFile("signals.json", JSON.stringify(json, ' ', 2)); + accepted(); + close(); + } + + function createSetFact(setData) { + var component = Qt.createComponent("MenuSet.qml"); + if (component.status !== Component.Ready) { + console.warn("SignalsMenu: cannot load MenuSet.qml: " + component.errorString()); + return null; + } + var c = component.createObject(setsFact, { + "title": setData.title ? setData.title : "set", + "pages": setData.pages ? setData.pages : [] + }); + c.parentFact = setsFact; + return c; + } + + function select(num) { + for (var i = 0; i < setsFact.size; ++i) { + var set = setsFact.child(i); + set.active = (set.num === num); + } + } + + // Legacy migration: old flat {page, signalas} → {active, sets} + function migrateLegacy(oldJson) { + var items = oldJson.signalas ? oldJson.signalas : []; + var mappedItems = items.map(function(it) { + return { + bind: it.bind || it.name || "", + title: it.title || "", + color: it.color || "", + filters: [], + warn: it.warn || "", + alarm: it.alarm || "", + act: it.act || "", + save: it.save || "" + }; + }).filter(function(it) { return it.bind !== ""; }); + + return { + active: { signals: 0 }, + sets: [{ + title: "default", + pages: [{ + name: oldJson.page || "page 1", + pin: false, + speed: 1.0, + items: mappedItems + }] + }] + }; + } + + // Default set matching the hardcoded Signals.qml pages + function buildDefaultSet() { + return { + title: "default", + pages: [ + { + name: "R", + pin: false, + speed: 1.0, + items: [ + { bind: "mandala.cmd.att.roll.value", title: "roll cmd" }, + { bind: "mandala.est.att.roll.value", title: "roll" } + ] + }, + { + name: "P", + pin: false, + speed: 1.0, + items: [ + { bind: "mandala.cmd.att.pitch.value", title: "pitch cmd" }, + { bind: "mandala.est.att.pitch.value", title: "pitch" } + ] + }, + { + name: "Y", + pin: false, + speed: 1.0, + items: [ + { bind: "mandala.cmd.pos.bearing.value", title: "bearing cmd" }, + { bind: "mandala.cmd.att.yaw.value", title: "yaw cmd" }, + { bind: "mandala.est.att.yaw.value", title: "yaw" } + ] + }, + { + name: "Axy", + pin: false, + speed: 1.0, + items: [ + { bind: "mandala.est.acc.x.value", title: "Ax" }, + { bind: "mandala.est.acc.y.value", title: "Ay" } + ] + }, + { + name: "Az", + pin: false, + speed: 1.0, + items: [ + { bind: "mandala.est.acc.z.value", title: "Az" } + ] + }, + { + name: "G", + pin: false, + speed: 1.0, + items: [ + { bind: "mandala.est.gyro.x.value", title: "Gx" }, + { bind: "mandala.est.gyro.y.value", title: "Gy" }, + { bind: "mandala.est.gyro.z.value", title: "Gz" } + ] + }, + { + name: "Pt", + pin: false, + speed: 1.0, + items: [ + { bind: "mandala.est.pos.altitude.value", title: "alt" }, + { bind: "mandala.est.pos.vspeed.value", title: "vspd" }, + { bind: "mandala.est.air.airspeed.value", title: "airspeed" } + ] + }, + { + name: "Ctr", + pin: false, + speed: 1.0, + items: [ + { bind: "mandala.ctr.att.ail.value", title: "ail" }, + { bind: "mandala.ctr.att.elv.value", title: "elv" }, + { bind: "mandala.ctr.att.rud.value", title: "rud" }, + { bind: "mandala.ctr.eng.thr.value", title: "thr" }, + { bind: "mandala.ctr.eng.prop.value", title: "prop" }, + { bind: "mandala.ctr.str.rud.value", title: "str.rud" } + ] + }, + { + name: "RC", + pin: false, + speed: 1.0, + items: [ + { bind: "mandala.cmd.rc.roll.value", title: "RC roll" }, + { bind: "mandala.cmd.rc.pitch.value", title: "RC pitch" }, + { bind: "mandala.cmd.rc.thr.value", title: "RC thr" }, + { bind: "mandala.cmd.rc.yaw.value", title: "RC yaw" } + ] + }, + { + name: "Usr", + pin: false, + speed: 1.0, + items: [ + { bind: "mandala.est.usr.u1.value", title: "u1" }, + { bind: "mandala.est.usr.u2.value", title: "u2" }, + { bind: "mandala.est.usr.u3.value", title: "u3" }, + { bind: "mandala.est.usr.u4.value", title: "u4" }, + { bind: "mandala.est.usr.u5.value", title: "u5" }, + { bind: "mandala.est.usr.u6.value", title: "u6" } + ] + } + ] + }; + } + + // Actions + Fact { + title: qsTr("Add set") + flags: Fact.Action + icon: "plus-circle" + onTriggered: { + var newSet = { title: "#" + (setsFact.size + 1), pages: [] }; + var c = createSetFact(newSet); + c.selected.connect(select); + c.selected.connect(saveSettings); + c.trigger(); + } + } + + Fact { + title: qsTr("Save") + flags: (Fact.Action | Fact.Apply) + icon: "check-circle" + onTriggered: saveSettings() + } +} diff --git a/src/Plugins/Tools/Signals/SignalsMenuPopup.qml b/src/Plugins/Tools/Signals/SignalsMenuPopup.qml new file mode 100644 index 000000000..4fa13418a --- /dev/null +++ b/src/Plugins/Tools/Signals/SignalsMenuPopup.qml @@ -0,0 +1,38 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick + +import Apx.Menu + +FactMenuPopup { + id: popup + pinned: true + + signal accepted() + + fact: menuFact + SignalsMenu { + id: menuFact + onAccepted: popup.accepted() + } + onClosed: menuFact.destroy() +} diff --git a/src/Plugins/Tools/Signals/SignalsView.qml b/src/Plugins/Tools/Signals/SignalsView.qml deleted file mode 100644 index b1b5bd04a..000000000 --- a/src/Plugins/Tools/Signals/SignalsView.qml +++ /dev/null @@ -1,212 +0,0 @@ -/* - * APX Autopilot project - * - * Copyright (c) 2003-2020, Aliaksei Stratsilatau - * All rights reserved - * - * This file is part of APX Ground Control. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program. If not, see . - */ -import QtQuick -import QtCharts -import QtQuick.Controls -import QtQml - -Item { - id: chartItem - //clip: true - property var facts: [] - - property bool openGL: false //apx.settings.graphics.opengl.value - property bool smoothLines: ui.smooth - - - property real speed: 0 - property real lineWidth: ui.antialiasing?1.5:1 - property real lineWidthCmd: ui.antialiasing?2.1:2 - - property var speedFactor: [ 1, 2, 4, 0.5, 0.2 ] - property real speedFactorValue: speed<0?speedFactor[0]:speed>=speedFactor.length?speedFactor[speedFactor.length-1]:speedFactor[speed] - - onFactsChanged: { - chartView.reset() - } - - Connections { - target: apx.fleet.current.mandala - function onTelemetryDecoded(){ chartView.appendData() } - } - - ChartView { - id: chartView - - antialiasing: ui.antialiasing - legend.visible: false - margins.top: 0 - margins.left: 0 - margins.bottom: 0 - margins.right: 0 - - anchors.fill: parent - property int margin: -8 - anchors.topMargin: margin - anchors.bottomMargin: margin - anchors.leftMargin: margin - anchors.rightMargin: margin - //onPlotAreaChanged: margin=-plotArea.y/3 - - plotAreaColor: "black" - backgroundColor: "black" - backgroundRoundness: 0 - dropShadowEnabled: false - - property int samples: Math.min(1000,Math.max(25,width/(3*speedFactorValue))) - property int time: 0 - - property bool dataExist: false - - ValueAxis { - id: axisX - property real t: chartView.time - Behavior on t { enabled: ui.smooth && chartView.dataExist; NumberAnimation {duration: 500; } } - min: t-chartView.samples+20 - max: t - //min: -chartView.samples //t-chartView.samples+20 - //max: 0 //t - visible: false - gridVisible: false - labelsVisible: false - lineVisible: false - shadesVisible: false - titleVisible: false - } - ValueAxis { - id: axisY - min: -0 - max: 0 - tickCount: 4 - labelsColor: "white" - labelsFont.pixelSize: Qt.application.font.pixelSize * 0.7 - gridLineColor: "#555" - } - - - property real dataPadding: 0.05 - property real dataPaddingZero: 0.05 - property var sdata: [] - property int timeRescale: 0 - - function reset() - { - chartView.removeAllSeries(); - chartView.sdata=[] - chartView.time=0 - axisY.min=-dataPaddingZero - axisY.max=dataPaddingZero - axisY.tickCount=4 - axisY.applyNiceNumbers() - speed=0 - } - - function appendData() - { - var t=time+1; - var v=0 - var fact={} - for(var i=0;i21){ - timeRescale=t - var d=sdata.length-samples*facts.length - if(d>0)sdata.splice(0,d) - var p=apx.seriesBounds(sdata) - var min=p.x-dataPadding - var max=p.y+dataPadding - if(min==max){ - min-=dataPaddingZero - max+=dataPaddingZero - } - var bmod=false - if(axisY.minmax){ - axisY.max=max - bmod=true - } - if(bmod){ - axisY.tickCount=4 - axisY.applyNiceNumbers() - } - } - time=t - dataExist=true - } - - function appendDataValue(fact, t, i){ - if(i>=chartView.count)addFactSeries(fact) - var s=chartView.series(i) - - var value=fact.value!=undefined?fact.value:eval(fact.name) - - if(!isFinite(value))value=0 - s.append(t,value); - sdata.push(value) - //instant rescale - grow - if(axisY.maxvalue){ - axisY.min=value-dataPadding; - } - //remove old - var cnt=samples - if(s.count>cnt) s.removePoints(0,s.count-cnt) - } - - function addFactSeries(fact) - { - var s = chartView.createSeries(ui.antialiasing?ChartView.SeriesTypeLine:ChartView.SeriesTypeLine,fact.title,axisX, axisY) - s.useOpenGL = Qt.binding(function(){return openGL}) - s.capStyle=Qt.RoundCap - //s.opacity=0.7 - - var color = fact.opts.color - if(!color) color = Qt.rgba(1,1,1,1) - - if(fact.name.startsWith("cmd")){ - s.width=Qt.binding(function(){return lineWidthCmd}) - s.color=Qt.hsla(color.hslHue, color.hslSaturation/2, color.hslLightness*1.2, 1) - }else{ - s.width=Qt.binding(function(){return lineWidth}) - s.color=color - } - return s - } - - } - - - function changeSpeed() - { - if((speed+1) Date: Thu, 23 Apr 2026 13:18:32 -0400 Subject: [PATCH 2/7] fix: Timer in Fact children, eval for bind expressions, defer chart append --- src/Plugins/Tools/Signals/ChartsView.qml | 14 +- src/Plugins/Tools/Signals/MenuItem.qml | 63 +-- src/Plugins/Tools/Signals/Signals.qml | 467 ----------------------- 3 files changed, 47 insertions(+), 497 deletions(-) diff --git a/src/Plugins/Tools/Signals/ChartsView.qml b/src/Plugins/Tools/Signals/ChartsView.qml index 6f9847cd5..15bebb478 100644 --- a/src/Plugins/Tools/Signals/ChartsView.qml +++ b/src/Plugins/Tools/Signals/ChartsView.qml @@ -48,7 +48,9 @@ Item { Connections { target: apx.fleet.current.mandala function onTelemetryDecoded() { - chartView.appendData(); + // Defer so MenuItem.updateValue() (triggered by the same signal + // via Signals.qml) has a chance to write its filtered value first. + Qt.callLater(chartView.appendData); } } @@ -181,7 +183,15 @@ Item { addFactSeries(fact); var s = chartView.series(i); - var value = fact.value !== undefined ? fact.value : eval(fact.name); + // MenuItem objects expose currentValue (filtered); plain mandala Fact + // objects expose value. Fall back to eval(name) for legacy paths. + var value; + if (fact.currentValue !== undefined) + value = fact.currentValue; + else if (fact.value !== undefined) + value = fact.value; + else + value = eval(fact.name); if (!isFinite(value)) value = 0; diff --git a/src/Plugins/Tools/Signals/MenuItem.qml b/src/Plugins/Tools/Signals/MenuItem.qml index 65f02823e..dddf433ef 100644 --- a/src/Plugins/Tools/Signals/MenuItem.qml +++ b/src/Plugins/Tools/Signals/MenuItem.qml @@ -38,6 +38,7 @@ Fact { // Runtime computed value (filtered) property var currentValue: undefined property string warningMsg: "" + property real _warnTimestamp: 0 signal addTriggered signal removeTriggered @@ -139,33 +140,43 @@ Fact { // Telemetry update — computes filtered value and evaluates warn/alarm function updateValue() { + var expr = mBind.text; + if (!expr || expr === "") + return; + var v; try { - var expr = mBind.text; - if (!expr || expr === "") - return; - var v = new Function('return ' + expr)(); - if (v === undefined) - throw new Error(qsTr("expression is undefined")); + // 'mandala' is available as a QML context property; + // expressions like "mandala.est.att.roll.value" or + // "Math.atan(mandala.est.att.pitch.value / mandala.est.att.roll.value)" work. + v = eval(expr); + } catch (e) { + // Fallback: treat as a plain mandala fact path + try { + var _f = apx.fleet.current.mandala.fact(expr, false); + if (_f) v = _f.value; + } catch (e2) {} + } + if (v === undefined || !isFinite(v)) + return; - // On first value, seed filter state - if (currentValue === undefined) { - mFilters.resetFilterState(); - currentValue = v; - } + // Seed filter state on first valid value + if (currentValue === undefined) + mFilters.resetFilterState(); - // Run filter chain - var filtered = mFilters.applyFilters(v); - currentValue = filtered; + // Run filter chain + var filtered = mFilters.applyFilters(v); + currentValue = filtered; + menuItem.value = filtered; // ChartsView reads fact.value - // Evaluate warning/alarm expressions + // Evaluate warning/alarm expressions + try { var warnExpr = mWarn.text; + hasWarning = warnExpr ? !!eval(warnExpr.replace(/\bvalue\b/g, filtered)) : false; + } catch (e) { hasWarning = false; } + try { var alarmExpr = mAlarm.text; - hasWarning = warnExpr ? !!new Function('value', 'return ' + warnExpr)(filtered) : false; - hasAlarm = alarmExpr ? !!new Function('value', 'return ' + alarmExpr)(filtered) : false; - - } catch (e) { - emitWarning(e.message); - } + hasAlarm = alarmExpr ? !!eval(alarmExpr.replace(/\bvalue\b/g, filtered)) : false; + } catch (e) { hasAlarm = false; } } function saveValue2Fact() { @@ -182,11 +193,12 @@ Fact { } function emitWarning(msg) { - if (warningMsg === msg && warnTimer.running) + var now = Date.now(); + if (warningMsg === msg && (now - _warnTimestamp) < 10000) return; warningMsg = msg; + _warnTimestamp = now; console.warn(qsTr("Chart") + " " + title + ": " + msg); - warnTimer.restart(); } function hasScr(val) { @@ -196,11 +208,6 @@ Fact { return true; } - Timer { - id: warnTimer - interval: 10000 - } - Fact { id: mTitle name: "chartname" diff --git a/src/Plugins/Tools/Signals/Signals.qml b/src/Plugins/Tools/Signals/Signals.qml index 563f94ec7..7ec23d29a 100644 --- a/src/Plugins/Tools/Signals/Signals.qml +++ b/src/Plugins/Tools/Signals/Signals.qml @@ -429,470 +429,3 @@ Rectangle { }; } } -/* - * APX Autopilot project - * - * Copyright (c) 2003-2020, Aliaksei Stratsilatau - * All rights reserved - * - * This file is part of APX Ground Control. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program. If not, see . - */ -import QtQuick -import QtQuick.Layouts -import QtQuick.Controls -import QtQuick.Controls.Material - -import Apx.Common - -// Root widget — usable as signalsWidget from child QML files. -Rectangle { - id: signalsWidget - - implicitHeight: mainLayout.implicitHeight - implicitWidth: mainLayout.implicitWidth - border.width: 0 - color: "#000" - - // Active set — a MenuSet Fact instance, rebuilt when set changes - property var activeSet: null - // Ordered pages from the active set - property var activePages: [] - // Currently selected page (for the single-view tab slot) - property var currentPage: null - - // ----------------------------------------------------------------- - // Public API used by child components - // ----------------------------------------------------------------- - - function saveSettings() { - var fjson = application.prefs.loadFile("signals.json"); - var json = fjson ? JSON.parse(fjson) : {}; - if (!json.active) json.active = {}; - json.sets = []; - var activeIdx = 0; - for (var i = 0; i < setsFact.size; ++i) { - var sf = setsFact.child(i); - json.sets.push(sf.save()); - if (sf.active) activeIdx = i; - } - json.active["signals"] = activeIdx; - application.prefs.saveFile("signals.json", JSON.stringify(json, ' ', 2)); - } - - function checkScrMatches(val) { - if (!activeSet) return false; - return activeSet.checkScrs(val); - } - - function activatePage(page) { - if (currentPage === page) return; - currentPage = page; - // Update chart view - singleChart.resetEnable = true; - singleChart.facts = Qt.binding(function() { return currentPage ? currentPage.values : []; }); - singleChart.speedFactorValue = page ? page.speed : 1.0; - } - - function updateLayout() { - // Rebuild page buttons and pinned charts - rebuildPageButtons(); - rebuildPinnedCharts(); - } - - function updateSeriesColors() { - singleChart.updateSeriesColor(); - for (var i = 0; i < pinnedChartsRepeater.count; ++i) { - var item = pinnedChartsRepeater.itemAt(i); - if (item && item.chartView) - item.chartView.updateSeriesColor(); - } - } - - // ----------------------------------------------------------------- - // Load/rebuild sets from signals.json - // ----------------------------------------------------------------- - - Component.onCompleted: { - loadSettings(); - } - - function loadSettings() { - // Destroy old set facts - setsFact.deleteChildren(); - - var f = application.prefs.loadFile("signals.json"); - var json = f ? JSON.parse(f) : {}; - - // Legacy migration - if (json && json.signalas && !json.sets) - json = migrateLegacy(json); - - var sets = []; - var activeIdx = 0; - - if (json && json.sets) { - for (var i in json.sets) { - var s = json.sets[i]; - if (s && s.pages) sets.push(s); - } - if (json.active) - activeIdx = json.active["signals"] || 0; - } - - if (sets.length === 0) - sets.push(buildDefaultSet()); - - if (activeIdx < 0 || activeIdx >= sets.length) - activeIdx = 0; - - // Create MenuSet facts - for (var i in sets) { - var ms = createMenuSet(sets[i]); - ms.active = (parseInt(i) === activeIdx); - } - - rebuildFromActiveSet(); - } - - function createMenuSet(setData) { - var component = Qt.createComponent("MenuSet.qml"); - if (component.status !== Component.Ready) { - console.warn("Signals: cannot load MenuSet.qml: " + component.errorString()); - return null; - } - var ms = component.createObject(setsFact, { - "title": setData.title || "set", - "pages": setData.pages || [] - }); - ms.parentFact = setsFact; - return ms; - } - - // Find the active set and rebuild pages/buttons/charts - function rebuildFromActiveSet() { - activeSet = null; - for (var i = 0; i < setsFact.size; ++i) { - var sf = setsFact.child(i); - if (sf.active) { activeSet = sf; break; } - } - if (!activeSet && setsFact.size > 0) - activeSet = setsFact.child(0); - - activePages = activeSet ? activeSet.getPages() : []; - currentPage = activePages.length > 0 ? activePages[0] : null; - - rebuildPageButtons(); - rebuildPinnedCharts(); - - if (currentPage) { - singleChart.resetEnable = true; - singleChart.facts = Qt.binding(function() { return currentPage ? currentPage.values : []; }); - singleChart.speedFactorValue = currentPage.speed; - } else { - singleChart.facts = []; - } - } - - // ----------------------------------------------------------------- - // Page buttons - // ----------------------------------------------------------------- - - property var pageButtons: [] - - function rebuildPageButtons() { - // Destroy old - for (var i = 0; i < pageButtons.length; ++i) - pageButtons[i].destroy(); - pageButtons = []; - - if (!activePages) return; - var btns = []; - for (var i = 0; i < activePages.length; ++i) { - var pg = activePages[i]; - var btn = pageBtnComponent.createObject(bottomBar, { - "page": pg, - "text": Qt.binding(function() { return pg.title; }) - }); - btn.page = pg; - btns.push(btn); - } - pageButtons = btns; - // Select first - if (pageButtons.length > 0) - pageButtonGroup.checkedButton = pageButtons[0]; - } - - // ----------------------------------------------------------------- - // Pinned charts (stacked) - // ----------------------------------------------------------------- - - property var pinnedPages: [] - - function rebuildPinnedCharts() { - pinnedPages = activePages.filter(function(p) { return p.pinned; }); - } - - // ----------------------------------------------------------------- - // Telemetry update - // ----------------------------------------------------------------- - - Connections { - target: apx.fleet.current.mandala - function onTelemetryDecoded() { - for (var i = 0; i < activePages.length; ++i) - activePages[i].updateChartsValues(); - } - } - - // ----------------------------------------------------------------- - // Fact container for MenuSet instances (not shown in menu UI here) - // ----------------------------------------------------------------- - - import APX.Facts - - property var setsFact: _setsFact - - // We can't embed a Fact as a property in Rectangle with APX.Facts import - // so use a loader trick — see _setsFact defined below. - - // ----------------------------------------------------------------- - // Layout - // ----------------------------------------------------------------- - - ColumnLayout { - id: mainLayout - anchors.fill: parent - spacing: 0 - - // Pinned pages (stacked, only shown when at least one page is pinned) - Repeater { - id: pinnedChartsRepeater - model: pinnedPages - - delegate: Item { - property alias chartView: _pinnedChart - Layout.fillWidth: true - Layout.preferredHeight: 80 * ui.scale - Layout.minimumHeight: 20 - - ChartsView { - id: _pinnedChart - anchors.fill: parent - facts: modelData.values - speedFactorValue: modelData.speed - } - } - } - - // Single (non-pinned) chart view for the active tab - ChartsView { - id: singleChart - facts: [] - Layout.fillWidth: true - Layout.fillHeight: true - Layout.minimumHeight: 20 - Layout.preferredHeight: 130 * ui.scale - } - - ButtonGroup { - id: pageButtonGroup - } - - // Bottom bar: page tabs + set label + speed button - RowLayout { - id: bottomBar - Layout.fillWidth: true - Layout.margins: Style.spacing - spacing: 3 - Layout.maximumHeight: 24 * ui.scale - - // Page tab buttons are added here dynamically via rebuildPageButtons() - // (they are children of bottomBar and part of pageButtonGroup) - - Item { Layout.fillWidth: true } // spacer - - // Set name label — click opens sets editor - TextButton { - id: setLabel - text: activeSet ? activeSet.title : "" - Layout.fillHeight: true - Layout.minimumWidth: height * 3 - toolTip: qsTr("Click to edit chart sets") - onClicked: openSetsEditor() - } - - // Speed button — cycles per-page speed - TextButton { - id: speedBtn - text: currentPage ? (currentPage.speed + "x") : "1x" - Layout.fillHeight: true - Layout.minimumWidth: height * 3 - onClicked: { - if (!currentPage) return; - var factors = [0.2, 0.5, 1, 2, 4]; - var idx = factors.indexOf(currentPage.speed); - var next = (idx >= 0 && idx < factors.length - 1) ? factors[idx + 1] : factors[0]; - currentPage.speed = next; - singleChart.speedFactorValue = next; - saveSettings(); - } - } - } - } - - // + button (top-right) — opens active page item editor - IconButton { - anchors.top: parent.top - anchors.right: parent.right - anchors.margins: Style.spacing - size: Style.buttonSize * 0.7 - iconName: "plus" - toolTip: qsTr("Edit current page items") - opacity: ui.effects ? (hovered ? 1 : 0.5) : 1 - onTriggered: { - if (currentPage) - currentPage.addNewItem(); - } - } - - // ----------------------------------------------------------------- - // Sets editor popup - // ----------------------------------------------------------------- - - function openSetsEditor() { - if (setsEditorPopup) return; - var c = Qt.createComponent("SignalsMenuPopup.qml", Component.PreferSynchronous, ui.window); - if (c.status === Component.Ready) { - var obj = c.createObject(ui.window); - setsEditorPopup = obj; - obj.accepted.connect(function() { - loadSettings(); - }); - obj.closed.connect(function() { - setsEditorPopup = null; - }); - obj.open(); - } else { - console.warn("Signals: cannot open sets editor: " + c.errorString()); - } - } - - property var setsEditorPopup: null - - // ----------------------------------------------------------------- - // Legacy migration helper - // ----------------------------------------------------------------- - - function migrateLegacy(oldJson) { - var items = (oldJson.signalas || []).map(function(it) { - return { - bind: it.bind || it.name || "", - title: it.title || "", - color: it.color || "", - filters: [], - warn: it.warn || "", - alarm: it.alarm || "", - act: it.act || "", - save: it.save || "" - }; - }).filter(function(it) { return it.bind !== ""; }); - - return { - active: { signals: 0 }, - sets: [{ - title: "default", - pages: [{ - name: oldJson.page || "page 1", - pin: false, - speed: 1.0, - items: items - }] - }] - }; - } - - // ----------------------------------------------------------------- - // Default set factory - // ----------------------------------------------------------------- - - function buildDefaultSet() { - return { - title: "default", - pages: [ - { name: "R", pin: false, speed: 1.0, items: [ - { bind: "mandala.cmd.att.roll.value", title: "roll cmd" }, - { bind: "mandala.est.att.roll.value", title: "roll" } - ]}, - { name: "P", pin: false, speed: 1.0, items: [ - { bind: "mandala.cmd.att.pitch.value", title: "pitch cmd" }, - { bind: "mandala.est.att.pitch.value", title: "pitch" } - ]}, - { name: "Y", pin: false, speed: 1.0, items: [ - { bind: "mandala.cmd.pos.bearing.value", title: "bearing cmd" }, - { bind: "mandala.cmd.att.yaw.value", title: "yaw cmd" }, - { bind: "mandala.est.att.yaw.value", title: "yaw" } - ]}, - { name: "Axy", pin: false, speed: 1.0, items: [ - { bind: "mandala.est.acc.x.value", title: "Ax" }, - { bind: "mandala.est.acc.y.value", title: "Ay" } - ]}, - { name: "Az", pin: false, speed: 1.0, items: [ - { bind: "mandala.est.acc.z.value", title: "Az" } - ]}, - { name: "G", pin: false, speed: 1.0, items: [ - { bind: "mandala.est.gyro.x.value", title: "Gx" }, - { bind: "mandala.est.gyro.y.value", title: "Gy" }, - { bind: "mandala.est.gyro.z.value", title: "Gz" } - ]}, - { name: "Pt", pin: false, speed: 1.0, items: [ - { bind: "mandala.est.pos.altitude.value", title: "alt" }, - { bind: "mandala.est.pos.vspeed.value", title: "vspd" }, - { bind: "mandala.est.air.airspeed.value", title: "airspeed" } - ]}, - { name: "Ctr", pin: false, speed: 1.0, items: [ - { bind: "mandala.ctr.att.ail.value", title: "ail" }, - { bind: "mandala.ctr.att.elv.value", title: "elv" }, - { bind: "mandala.ctr.att.rud.value", title: "rud" }, - { bind: "mandala.ctr.eng.thr.value", title: "thr" }, - { bind: "mandala.ctr.eng.prop.value", title: "prop" }, - { bind: "mandala.ctr.str.rud.value", title: "str.rud" } - ]}, - { name: "RC", pin: false, speed: 1.0, items: [ - { bind: "mandala.cmd.rc.roll.value", title: "RC roll" }, - { bind: "mandala.cmd.rc.pitch.value", title: "RC pitch" }, - { bind: "mandala.cmd.rc.thr.value", title: "RC thr" }, - { bind: "mandala.cmd.rc.yaw.value", title: "RC yaw" } - ]}, - { name: "Usr", pin: false, speed: 1.0, items: [ - { bind: "mandala.est.usr.u1.value", title: "u1" }, - { bind: "mandala.est.usr.u2.value", title: "u2" }, - { bind: "mandala.est.usr.u3.value", title: "u3" }, - { bind: "mandala.est.usr.u4.value", title: "u4" }, - { bind: "mandala.est.usr.u5.value", title: "u5" }, - { bind: "mandala.est.usr.u6.value", title: "u6" } - ]} - ] - }; - } - - // Component for dynamic page buttons - Component { - id: pageBtnComponent - PageButton { - ButtonGroup.group: pageButtonGroup - } - } -} From 234635271d914358dc9243f399d9ce11826ceda8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:32:27 +0000 Subject: [PATCH 3/7] fix: apply review feedback - binding, null-checks, cached fns, license header Agent-Logs-Url: https://github.com/uavos/apx-gcs/sessions/f2cf86b8-011c-44d0-8252-723f090feda1 Co-authored-by: uavinda <160358+uavinda@users.noreply.github.com> --- src/Plugins/Tools/Signals/ColorChooser.qml | 21 +++++++ src/Plugins/Tools/Signals/MenuItem.qml | 66 ++++++++++++++++------ src/Plugins/Tools/Signals/Signals.qml | 1 - src/Plugins/Tools/Signals/SignalsMenu.qml | 2 + 4 files changed, 72 insertions(+), 18 deletions(-) diff --git a/src/Plugins/Tools/Signals/ColorChooser.qml b/src/Plugins/Tools/Signals/ColorChooser.qml index fe7995fa9..302effade 100644 --- a/src/Plugins/Tools/Signals/ColorChooser.qml +++ b/src/Plugins/Tools/Signals/ColorChooser.qml @@ -1,3 +1,24 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ import QtQuick import QtQuick.Layouts import QtQuick.Controls diff --git a/src/Plugins/Tools/Signals/MenuItem.qml b/src/Plugins/Tools/Signals/MenuItem.qml index dddf433ef..b7094354b 100644 --- a/src/Plugins/Tools/Signals/MenuItem.qml +++ b/src/Plugins/Tools/Signals/MenuItem.qml @@ -40,6 +40,36 @@ Fact { property string warningMsg: "" property real _warnTimestamp: 0 + // Cached compiled functions – rebuilt only when expressions change + property var _bindFn: null + property var _warnFn: null + property var _alarmFn: null + + function _recompileBindFn() { + var expr = mBind.text; + if (!expr || expr === "") { _bindFn = null; return; } + try { + // eval creates a closure in QML scope, giving the compiled function + // access to context properties (mandala, apx, Math, etc.). + // Expressions are user-authored in signals.json — not external input. + _bindFn = eval("(function() { return (" + expr + "); })"); + } catch(e) { _bindFn = null; } + } + function _recompileWarnFn() { + var expr = mWarn.text; + if (!expr || expr === "") { _warnFn = null; return; } + try { + _warnFn = new Function("value", "return !!(" + expr + ")"); + } catch(e) { _warnFn = null; } + } + function _recompileAlarmFn() { + var expr = mAlarm.text; + if (!expr || expr === "") { _alarmFn = null; return; } + try { + _alarmFn = new Function("value", "return !!(" + expr + ")"); + } catch(e) { _alarmFn = null; } + } + signal addTriggered signal removeTriggered @@ -52,7 +82,20 @@ Fact { property string itemColor: mColor.value ? mColor.value : "#ffffff" Component.onCompleted: { + // Wire expression changes to recompile cached functions before loading + // so the initial load triggers them automatically. + mBind.textChanged.connect(_recompileBindFn); + mWarn.textChanged.connect(_recompileWarnFn); + mAlarm.textChanged.connect(_recompileAlarmFn); + load(data); + + // Explicit recompile after load() in case textChanged did not fire + // (e.g. the Fact text was already equal to the loaded value). + _recompileBindFn(); + _recompileWarnFn(); + _recompileAlarmFn(); + updateTitle(); updateDescr(); mTitle.valueChanged.connect(updateTitle); @@ -140,19 +183,14 @@ Fact { // Telemetry update — computes filtered value and evaluates warn/alarm function updateValue() { - var expr = mBind.text; - if (!expr || expr === "") - return; + if (!_bindFn) return; var v; try { - // 'mandala' is available as a QML context property; - // expressions like "mandala.est.att.roll.value" or - // "Math.atan(mandala.est.att.pitch.value / mandala.est.att.roll.value)" work. - v = eval(expr); + v = _bindFn(); } catch (e) { // Fallback: treat as a plain mandala fact path try { - var _f = apx.fleet.current.mandala.fact(expr, false); + var _f = apx.fleet.current.mandala.fact(mBind.text, false); if (_f) v = _f.value; } catch (e2) {} } @@ -168,15 +206,9 @@ Fact { currentValue = filtered; menuItem.value = filtered; // ChartsView reads fact.value - // Evaluate warning/alarm expressions - try { - var warnExpr = mWarn.text; - hasWarning = warnExpr ? !!eval(warnExpr.replace(/\bvalue\b/g, filtered)) : false; - } catch (e) { hasWarning = false; } - try { - var alarmExpr = mAlarm.text; - hasAlarm = alarmExpr ? !!eval(alarmExpr.replace(/\bvalue\b/g, filtered)) : false; - } catch (e) { hasAlarm = false; } + // Evaluate warning/alarm using pre-compiled functions + try { hasWarning = _warnFn ? _warnFn(filtered) : false; } catch(e) { hasWarning = false; } + try { hasAlarm = _alarmFn ? _alarmFn(filtered) : false; } catch(e) { hasAlarm = false; } } function saveValue2Fact() { diff --git a/src/Plugins/Tools/Signals/Signals.qml b/src/Plugins/Tools/Signals/Signals.qml index 7ec23d29a..c391af7ab 100644 --- a/src/Plugins/Tools/Signals/Signals.qml +++ b/src/Plugins/Tools/Signals/Signals.qml @@ -289,7 +289,6 @@ Rectangle { var next = (idx >= 0 && idx < factors.length - 1) ? factors[idx + 1] : factors[0]; currentPage.speed = next; - singleChart.speedFactorValue = next; saveSettings(); } } diff --git a/src/Plugins/Tools/Signals/SignalsMenu.qml b/src/Plugins/Tools/Signals/SignalsMenu.qml index b59de2f3d..b97ac2a00 100644 --- a/src/Plugins/Tools/Signals/SignalsMenu.qml +++ b/src/Plugins/Tools/Signals/SignalsMenu.qml @@ -98,6 +98,7 @@ Fact { for (var i in sets) { var c = createSetFact(sets[i]); + if (!c) continue; c.selected.connect(select); c.selected.connect(saveSettings); } @@ -294,6 +295,7 @@ Fact { onTriggered: { var newSet = { title: "#" + (setsFact.size + 1), pages: [] }; var c = createSetFact(newSet); + if (!c) return; c.selected.connect(select); c.selected.connect(saveSettings); c.trigger(); From 3449c435dc47a90e2bf1542d2dc41ffde2b12ecd Mon Sep 17 00:00:00 2001 From: Aliaksei Stratsilatau Date: Thu, 23 Apr 2026 14:52:34 -0400 Subject: [PATCH 4/7] refactor --- src/Plugins/Tools/Signals/CMakeLists.txt | 26 +- src/Plugins/Tools/Signals/FilterItem.qml | 180 +++++++++++++ .../Tools/Signals/FilterKalmanSimple.qml | 63 ++--- .../Tools/Signals/FilterRunningAvg.qml | 37 +-- .../Tools/Signals/MenuFilterItemPage.qml | 100 +++++++ src/Plugins/Tools/Signals/MenuFilters.qml | 196 +++++++++----- src/Plugins/Tools/Signals/MenuFiltersPage.qml | 100 +++++++ src/Plugins/Tools/Signals/MenuItem.qml | 88 +++++- src/Plugins/Tools/Signals/MenuPage.qml | 32 ++- src/Plugins/Tools/Signals/MenuSet.qml | 25 +- src/Plugins/Tools/Signals/MenuSetPage.qml | 100 +++++++ src/Plugins/Tools/Signals/PageButton.qml | 24 +- src/Plugins/Tools/Signals/README.md | 11 +- src/Plugins/Tools/Signals/REFACTOR_NOTES.md | 143 ---------- src/Plugins/Tools/Signals/REFACTOR_PROMPT.md | 251 ++++++++++++++++++ src/Plugins/Tools/Signals/Signals.qml | 151 +++-------- src/Plugins/Tools/Signals/SignalsMenu.qml | 185 ++----------- src/Plugins/Tools/Signals/SignalsModel.qml | 170 ++++++++++++ 18 files changed, 1300 insertions(+), 582 deletions(-) create mode 100644 src/Plugins/Tools/Signals/FilterItem.qml create mode 100644 src/Plugins/Tools/Signals/MenuFilterItemPage.qml create mode 100644 src/Plugins/Tools/Signals/MenuFiltersPage.qml create mode 100644 src/Plugins/Tools/Signals/MenuSetPage.qml delete mode 100644 src/Plugins/Tools/Signals/REFACTOR_NOTES.md create mode 100644 src/Plugins/Tools/Signals/REFACTOR_PROMPT.md create mode 100644 src/Plugins/Tools/Signals/SignalsModel.qml diff --git a/src/Plugins/Tools/Signals/CMakeLists.txt b/src/Plugins/Tools/Signals/CMakeLists.txt index 8328020ed..992c95555 100644 --- a/src/Plugins/Tools/Signals/CMakeLists.txt +++ b/src/Plugins/Tools/Signals/CMakeLists.txt @@ -1,3 +1,25 @@ -apx_plugin(SRCS "*.qml") +set(SIGNALS_QML + ChartsView.qml + ColorChooser.qml + FilterItem.qml + FilterKalmanSimple.qml + FilterRunningAvg.qml + MenuColor.qml + MenuFilterItemPage.qml + MenuFilters.qml + MenuFiltersPage.qml + MenuItem.qml + MenuPage.qml + MenuSet.qml + MenuSetPage.qml + PageButton.qml + Signals.qml + SignalsModel.qml + SignalsMenu.qml + SignalsMenuPopup.qml + SignalsPlugin.qml +) -apx_qrc(${MODULE} PREFIX ${MODULE} SRCS "*.qml") +apx_plugin(SRCS ${SIGNALS_QML}) + +apx_qrc(${MODULE} PREFIX ${MODULE} SRCS ${SIGNALS_QML}) diff --git a/src/Plugins/Tools/Signals/FilterItem.qml b/src/Plugins/Tools/Signals/FilterItem.qml new file mode 100644 index 000000000..2b8c226db --- /dev/null +++ b/src/Plugins/Tools/Signals/FilterItem.qml @@ -0,0 +1,180 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick + +import APX.Facts + +Fact { + id: filterItem + + flags: (Fact.Group | Fact.FlatModel) + + property var data: ({}) + property bool changes: false + property var paramsFact: null + + signal removeTriggered + + readonly property var registry: ({ + "running_avg": { + title: qsTr("Running average"), + url: "FilterRunningAvg.qml" + }, + "kalman_smp": { + title: qsTr("Kalman simple"), + url: "FilterKalmanSimple.qml" + } + }) + + Component.onCompleted: { + var opt = opts; + opt.page = "qrc:/Signals/MenuFilterItemPage.qml"; + opts = opt; + load(data); + } + + function filterTitle(typeName) { + var meta = registry[typeName]; + return meta ? meta.title : typeName; + } + + function createParamsFact(typeName) { + if (paramsFact) + paramsFact.deleteFact(); + + var meta = registry[typeName] || registry.running_avg; + var component = Qt.createComponent(meta.url); + if (component.status !== Component.Ready) { + console.warn("FilterItem: cannot load " + meta.url + ": " + component.errorString()); + paramsFact = null; + return null; + } + + paramsFact = component.createObject(filterParams, {}); + if (!paramsFact) { + console.warn("FilterItem: failed to create params fact for " + typeName); + return null; + } + paramsFact.parentFact = filterParams; + paramsFact.title = meta.title; + paramsFact.descr = meta.title; + updateDescr(); + return paramsFact; + } + + function updateDescr() { + var parts = []; + parts.push(filterEnabled.value > 0 ? qsTr("enabled") : qsTr("disabled")); + if (paramsFact && paramsFact.text) + parts.push(paramsFact.text); + descr = parts.join(", "); + title = filterTitle(filterType.text); + } + + function load(filterData) { + data = filterData || {}; + + var typeName = data.type ? data.type : "running_avg"; + var typeIndex = filterType.enumStrings.indexOf(typeName); + filterType.value = typeIndex >= 0 ? typeIndex : 0; + filterEnabled.value = data.enabled === undefined ? true : !!data.enabled; + + createParamsFact(filterType.text); + if (paramsFact && typeof paramsFact.loadFromObject === "function") + paramsFact.loadFromObject(data); + + changes = false; + updateDescr(); + } + + function save() { + var result = { + type: filterType.text, + enabled: filterEnabled.value > 0 + }; + + if (paramsFact && typeof paramsFact.save === "function") { + var params = paramsFact.save(); + for (var key in params) + result[key] = params[key]; + } + + data = result; + changes = false; + updateDescr(); + return result; + } + + function applyFilter(input) { + if (!(filterEnabled.value > 0) || !paramsFact || typeof paramsFact.filterValue !== "function") + return input; + return paramsFact.filterValue(input); + } + + function resetState() { + if (paramsFact && typeof paramsFact.resetState === "function") + paramsFact.resetState(); + } + + Fact { + id: filterType + name: "type" + title: qsTr("Type") + descr: qsTr("Filter type") + flags: Fact.Enum + enumStrings: ["running_avg", "kalman_smp"] + onValueChanged: { + createParamsFact(text); + changes = true; + updateDescr(); + } + } + + Fact { + id: filterEnabled + name: "enabled" + title: qsTr("Enabled") + descr: qsTr("Enable this filter in the chain") + flags: Fact.Bool + value: true + onValueChanged: { + changes = true; + updateDescr(); + } + } + + Fact { + id: filterParams + title: qsTr("Parameters") + flags: (Fact.Group | Fact.Section) + } + + Fact { + flags: (Fact.Action | Fact.Remove) + title: qsTr("Remove filter") + icon: "delete" + onTriggered: { + removeTriggered(); + filterItem.deleteFact(); + } + } +} diff --git a/src/Plugins/Tools/Signals/FilterKalmanSimple.qml b/src/Plugins/Tools/Signals/FilterKalmanSimple.qml index d0c4cd24f..1b211b81a 100644 --- a/src/Plugins/Tools/Signals/FilterKalmanSimple.qml +++ b/src/Plugins/Tools/Signals/FilterKalmanSimple.qml @@ -29,7 +29,8 @@ Fact { flags: Fact.Group property bool changes: false - property var coefs: [1, 1] + property real q: 1 + property real r: 1 property var data: ({}) // Kalman state @@ -37,8 +38,6 @@ Fact { property real kCovariance: 0.1 property bool initialized: false - onChangesChanged: { if (changes) menuFilters.changes = true; } - function filterValue(input) { if (!initialized) { kState = input; @@ -47,10 +46,10 @@ Fact { } // Time update - prediction var x0 = kState; - var p0 = kCovariance + coefs[0]; + var p0 = kCovariance + q; // Measurement update - correction - var k = p0 / (p0 + coefs[1]); + var k = p0 / (p0 + r); kState = x0 + k * (input - x0); kCovariance = (1 - k) * p0; return kState; @@ -60,56 +59,36 @@ Fact { initialized = false; } - function load() { - for (var i = 0; i < size; ++i) { - var f = child(i); - var v = data[settingName(f)]; - if (v !== undefined) - f.value = v; - } + function loadFromObject(obj) { + data = obj || {}; + ksMeasNoise.value = data.r !== undefined ? data.r : (data.measurement_noise !== undefined ? data.measurement_noise : 1); + ksEnvNoise.value = data.q !== undefined ? data.q : (data.environment_noise !== undefined ? data.environment_noise : 1); updateCoefs(); } - function save() { - data = {}; - for (var i = 0; i < size; ++i) { - var f = child(i); - var s = f.text.trim(); - if (s === "") - continue; - data[settingName(f)] = s; - } - updateCoefs(); - return data; - } - - function settingName(f) { - var n = f.name; - if (n.includes("_")) - return n.slice(0, n.indexOf("_")); - return n; - } - - function fillData() { - if (typeof value === 'object' && !Array.isArray(value) && value !== null) { - data = value; - load(); - } - } - function updateFilterValue() { ksFilter.value = "Km=" + ksMeasNoise.value + ",Ke=" + ksEnvNoise.value; changes = true; } function updateCoefs() { - coefs = [ksMeasNoise.value, ksEnvNoise.value]; + r = ksMeasNoise.value; + q = ksEnvNoise.value; changes = false; } + function save() { + updateCoefs(); + data = { + r: ksMeasNoise.value, + q: ksEnvNoise.value + }; + return data; + } + Fact { id: ksMeasNoise - name: "measurement_noise" + name: "r" title: qsTr("Measurement noise") descr: qsTr("Coefficient of measurement noise") flags: Fact.Float @@ -121,7 +100,7 @@ Fact { } Fact { id: ksEnvNoise - name: "environment_noise" + name: "q" title: qsTr("Environment noise") descr: qsTr("Coefficient of environment noise") flags: Fact.Float diff --git a/src/Plugins/Tools/Signals/FilterRunningAvg.qml b/src/Plugins/Tools/Signals/FilterRunningAvg.qml index fb31fdd7c..db613d2f9 100644 --- a/src/Plugins/Tools/Signals/FilterRunningAvg.qml +++ b/src/Plugins/Tools/Signals/FilterRunningAvg.qml @@ -32,8 +32,6 @@ Fact { property var data: ({}) property var coef: 0.5 - onChangesChanged: { if (changes) menuFilters.changes = true; } - function applyFilter(v) { return coef_value + (v - coef_value) * coef; } @@ -54,43 +52,18 @@ Fact { initialized = false; } - function load() { - for (var i = 0; i < size; ++i) { - var f = child(i); - var v = data[settingName(f)]; - if (v !== undefined) - f.value = v; - } + function loadFromObject(obj) { + data = obj || {}; + raCoef.value = data.coef !== undefined ? data.coef : (data.coefficient !== undefined ? data.coefficient : 0.5); updateCoef(); } function save() { - data = {}; - for (var i = 0; i < size; ++i) { - var f = child(i); - var s = f.text.trim(); - if (s === "") - continue; - data[settingName(f)] = s; - } updateCoef(); + data = { coef: raCoef.value }; return data; } - function settingName(f) { - var n = f.name; - if (n.includes("_")) - return n.slice(0, n.indexOf("_")); - return n; - } - - function fillData() { - if (typeof value === 'object' && !Array.isArray(value) && value !== null) { - data = value; - load(); - } - } - function updateCoef() { coef = raCoef.value; changes = false; @@ -98,7 +71,7 @@ Fact { Fact { id: raCoef - name: "coefficient" + name: "coef" title: qsTr("Coefficient") descr: qsTr("Coefficient for filtration") flags: Fact.Float diff --git a/src/Plugins/Tools/Signals/MenuFilterItemPage.qml b/src/Plugins/Tools/Signals/MenuFilterItemPage.qml new file mode 100644 index 000000000..b0da17efa --- /dev/null +++ b/src/Plugins/Tools/Signals/MenuFilterItemPage.qml @@ -0,0 +1,100 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import APX.Facts + +import Apx.Common +import Apx.Menu + +Item { + id: root + + property var pageFact: fact ? fact : null + property var paramsFact: pageFact ? pageFact.child(2) : null + + function triggerFact(itemFact) { + if (!itemFact) + return; + if (itemFact.treeType === Fact.Action || itemFact.dataType === Fact.Apply || itemFact.dataType === Fact.Remove || itemFact.dataType === Fact.Stop) { + itemFact.trigger(); + return; + } + menuPage.factButtonTriggered(itemFact); + } + + clip: true + + ColumnLayout { + anchors.fill: parent + spacing: Style.spacing + + FactButton { + Layout.fillWidth: true + visible: root.pageFact !== null + fact: root.pageFact ? root.pageFact.child(0) : null + noFactTrigger: true + onTriggered: root.triggerFact(fact) + } + + FactButton { + Layout.fillWidth: true + visible: root.pageFact !== null + fact: root.pageFact ? root.pageFact.child(1) : null + noFactTrigger: true + onTriggered: root.triggerFact(fact) + } + + ListView { + id: listView + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + spacing: 0 + model: root.paramsFact ? root.paramsFact.model : null + + delegate: Loader { + active: modelData ? modelData.visible : false + visible: active + width: listView.width + height: active ? MenuStyle.itemSize : 0 + sourceComponent: Component { + FactButton { + fact: modelData ? modelData : null + noFactTrigger: true + size: MenuStyle.itemSize + onTriggered: { + listView.currentIndex = index; + root.triggerFact(fact); + } + } + } + } + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } + } + } +} diff --git a/src/Plugins/Tools/Signals/MenuFilters.qml b/src/Plugins/Tools/Signals/MenuFilters.qml index 791ae3c0a..1b7cd96e0 100644 --- a/src/Plugins/Tools/Signals/MenuFilters.qml +++ b/src/Plugins/Tools/Signals/MenuFilters.qml @@ -23,110 +23,170 @@ import QtQuick import APX.Facts -// Filter selector + params for a single chart item. -// Exposes value as the currently selected filter type string. +// Ordered filter chain for a single chart item. // To add a new filter type: -// 1. Create FilterMyType.qml with filterValue(input)/resetState() API -// 2. Add instance below with name matching the type string -// 3. Add the type string to fTypes.enumStrings -// 4. Handle it in MenuItem.qml updateValue() filter loop +// 1. Create FilterMyType.qml with loadFromObject()/save()/filterValue()/resetState() +// 2. Register the type in FilterItem.qml registry and enumStrings +// 3. The chain will pick it up automatically Fact { id: menuFilters property bool changes: false - property var data: ({}) + property var data: [] - signal removeTriggered + property var newFilterTypeFact: newFilterType + property var addFilterFact: addFilterAction + property var filtersFact: filterValues + + Component.onCompleted: { + var opt = opts; + opt.page = "qrc:/Signals/MenuFiltersPage.qml"; + opts = opt; + } onChangesChanged: { if (changes) menuItem.changes = true; } - // Returns currently active filter type string ("none", "running_avg", "kalman_smp") - property string filterType: fTypes.text - - function getFilterType() { return fTypes.text; } - function getRunningAvgCoef() { return fRunningAvg.coef; } - function getKalmanSimpleCoefs() { return fKalmanSimple.coefs; } - - // Apply all enabled filters in sequence (future: loop over filter list) - // For current single-filter model, applies selected filter type - function applyFilters(v, stateObj) { - var type = fTypes.text; - if (type === "running_avg") { - return fRunningAvg.filterValue(v); - } else if (type === "kalman_smp") { - return fKalmanSimple.filterValue(v); + function createFilter(filterData) { + var component = Qt.createComponent("FilterItem.qml"); + if (component.status !== Component.Ready) { + console.warn("MenuFilters: cannot load FilterItem.qml: " + component.errorString()); + return null; } - return v; + + var filterFact = component.createObject(filterValues, { + "data": filterData || { + type: "running_avg", + enabled: true + } + }); + if (!filterFact) { + console.warn("MenuFilters: failed to create FilterItem instance"); + return null; + } + filterFact.parentFact = filterValues; + filterFact.removeTriggered.connect(function() { + updateDescr(); + changes = true; + }); + filterFact.titleChanged.connect(updateDescr); + updateDescr(); + return filterFact; } function resetFilterState() { - fRunningAvg.resetState(); - fKalmanSimple.resetState(); + for (var i = 0; i < filterValues.size; ++i) + filterValues.child(i).resetState(); } - function load() { - for (var i = 0; i < size; ++i) { - var f = child(i); - var v = data[settingName(f)]; - if (v !== undefined) - f.value = v; + function applyFilters(v) { + var result = v; + for (var i = 0; i < filterValues.size; ++i) + result = filterValues.child(i).applyFilter(result); + return result; + } + + function normalizeData(rawData) { + if (Array.isArray(rawData)) + return rawData; + + if (rawData && typeof rawData === "object") { + if (rawData.type) + return [rawData]; + + if (rawData.filters && Array.isArray(rawData.filters)) + return rawData.filters; + + // Legacy single-selector shape + var legacyType = rawData.filters || rawData.filterType; + if (legacyType === "running_avg") + return [{ + type: "running_avg", + enabled: true, + coef: rawData.running_avg || rawData.coef || rawData.coefficient || 0.5 + }]; + if (legacyType === "kalman_smp") + return [{ + type: "kalman_smp", + enabled: true, + r: rawData.measurement_noise !== undefined ? rawData.measurement_noise : (rawData.r !== undefined ? rawData.r : 1), + q: rawData.environment_noise !== undefined ? rawData.environment_noise : (rawData.q !== undefined ? rawData.q : 1) + }]; } - fRunningAvg.fillData(); - fKalmanSimple.fillData(); + + return []; + } + + function load() { + filterValues.deleteChildren(); + var list = normalizeData(data); + for (var i = 0; i < list.length; ++i) + createFilter(list[i]); + menuFilters.value = list; changes = false; + updateDescr(); } function save() { - data = {}; - for (var i = 0; i < size; ++i) { - var f = child(i); - var s = f.text.trim(); - if (f.size !== 0) - s = f.save(); - if (s === "") - continue; - data[settingName(f)] = s; - } + data = []; + for (var i = 0; i < filterValues.size; ++i) + data.push(filterValues.child(i).save()); + menuFilters.value = data; changes = false; + updateDescr(); return data; } - function settingName(f) { - var n = f.name; - if (n.includes("_")) - return n.slice(0, n.indexOf("_")); - return n; + function updateDescr() { + var labels = []; + for (var i = 0; i < filterValues.size; ++i) + labels.push(filterValues.child(i).title); + descr = labels.length > 0 ? labels.join(", ") : qsTr("No filters"); } function fillData() { - if (typeof value === 'object' && !Array.isArray(value) && value !== null) { + if (value !== undefined && value !== null) { data = value; load(); } } Fact { - id: fTypes - name: "filters" - title: qsTr("Filter") - descr: qsTr("Selecting the filter to use") + id: newFilterType + name: "new_filter_type" + title: qsTr("New filter type") + descr: qsTr("Filter type to append to the chain") flags: Fact.Enum - enumStrings: ["none", "running_avg", "kalman_smp"] - onTextChanged: menuFilters.value = text - onValueChanged: changes = true - } - FilterRunningAvg { - id: fRunningAvg - name: "running_avg" - title: qsTr("Running average") - descr: qsTr("Running average filter settings") + enumStrings: ["running_avg", "kalman_smp"] } - FilterKalmanSimple { - id: fKalmanSimple - name: "kalman_smp" - title: qsTr("Kalman simple") - descr: qsTr("Simple kalman filter settings") + + Fact { + id: addFilterAction + title: qsTr("Add filter") + descr: qsTr("Append a new filter to the chain") + flags: Fact.Action + icon: "plus-circle" + onTriggered: { + createFilter({ + type: newFilterType.text, + enabled: true + }); + changes = true; + } } + Fact { + id: filterValues + title: qsTr("Filters") + descr: qsTr("Ordered filter chain") + flags: (Fact.Group | Fact.Section | Fact.DragChildren) + onSizeChanged: { + updateDescr(); + changes = true; + } + onItemMoved: { + updateDescr(); + changes = true; + } + } } diff --git a/src/Plugins/Tools/Signals/MenuFiltersPage.qml b/src/Plugins/Tools/Signals/MenuFiltersPage.qml new file mode 100644 index 000000000..cbffb7cf8 --- /dev/null +++ b/src/Plugins/Tools/Signals/MenuFiltersPage.qml @@ -0,0 +1,100 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import APX.Facts + +import Apx.Common +import Apx.Menu + +Item { + id: root + + property var pageFact: fact ? fact : null + property var listFact: pageFact ? pageFact.filtersFact : null + + function triggerFact(itemFact) { + if (!itemFact) + return; + if (itemFact.treeType === Fact.Action || itemFact.dataType === Fact.Apply || itemFact.dataType === Fact.Remove || itemFact.dataType === Fact.Stop) { + itemFact.trigger(); + return; + } + menuPage.factButtonTriggered(itemFact); + } + + clip: true + + ColumnLayout { + anchors.fill: parent + spacing: Style.spacing + + FactButton { + Layout.fillWidth: true + visible: root.pageFact !== null + fact: root.pageFact ? root.pageFact.newFilterTypeFact : null + noFactTrigger: true + onTriggered: root.triggerFact(fact) + } + + FactButton { + Layout.fillWidth: true + visible: root.pageFact !== null + fact: root.pageFact ? root.pageFact.addFilterFact : null + noFactTrigger: true + onTriggered: root.triggerFact(fact) + } + + ListView { + id: listView + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + spacing: 0 + model: root.listFact ? root.listFact.model : null + + delegate: Loader { + active: modelData ? modelData.visible : false + visible: active + width: listView.width + height: active ? MenuStyle.itemSize : 0 + sourceComponent: Component { + FactButton { + fact: modelData ? modelData : null + noFactTrigger: true + size: MenuStyle.itemSize + onTriggered: { + listView.currentIndex = index; + root.triggerFact(fact); + } + } + } + } + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } + } + } +} diff --git a/src/Plugins/Tools/Signals/MenuItem.qml b/src/Plugins/Tools/Signals/MenuItem.qml index b7094354b..73530980c 100644 --- a/src/Plugins/Tools/Signals/MenuItem.qml +++ b/src/Plugins/Tools/Signals/MenuItem.qml @@ -20,6 +20,7 @@ * along with this program. If not, see . */ import QtQuick +import QtQuick.Controls.Material import APX.Facts import Apx.Common @@ -38,6 +39,7 @@ Fact { // Runtime computed value (filtered) property var currentValue: undefined property string warningMsg: "" + property string alertText: "" property real _warnTimestamp: 0 // Cached compiled functions – rebuilt only when expressions change @@ -79,7 +81,7 @@ Fact { // Expose for PageButton tooltip property string itemTitle: mTitle.text ? mTitle.text : mBind.text - property string itemColor: mColor.value ? mColor.value : "#ffffff" + property var itemColor: menuItem.opts && menuItem.opts.color ? menuItem.opts.color : Material.color(Material.Blue + menuItem.num * 2) Component.onCompleted: { // Wire expression changes to recompile cached functions before loading @@ -108,12 +110,21 @@ Fact { onCurrentValueChanged: saveValue2Fact() function load() { + if (data.filters === undefined && data.filt !== undefined) + data.filters = data.filt; + if (data.warning === undefined && data.warn !== undefined) + data.warning = data.warn; + if (data.title === undefined && data.chartname !== undefined) + data.title = data.chartname; for (var i = 0; i < menuItem.size; ++i) { var f = child(i); + if (f.transientFact) + continue; var v = data[settingName(f)]; if (v !== undefined) f.value = v; } + syncBindSelector(); mFilters.fillData(); mColor.value = data.color ? data.color : ""; changes = false; @@ -124,6 +135,8 @@ Fact { data = {}; for (var i = 0; i < menuItem.size; ++i) { var f = child(i); + if (f.transientFact) + continue; var s = f.text.trim(); if (f.size !== 0) s = f.save(); @@ -138,11 +151,24 @@ Fact { function settingName(f) { var n = f.name; + if (!n || n.startsWith("_")) + return ""; if (n.includes("_")) return n.slice(0, n.indexOf("_")); return n; } + function syncBindSelector() { + var expr = mBind.text; + var prefix = "mandala."; + var suffix = ".value"; + if (expr && expr.startsWith(prefix) && expr.endsWith(suffix) && expr.indexOf("(") < 0 && expr.indexOf(" ") < 0) { + mBindFact.value = expr.slice(prefix.length, expr.length - suffix.length); + } else { + mBindFact.value = ""; + } + } + function updateTitle() { if (newItem) return; @@ -157,21 +183,26 @@ Fact { var f = child(i); if (!f.name) continue; + if (f.transientFact) + continue; if (f.name === "title") continue; - if (f.text === "") + var text = f.text; + if ((!text || text === "") && f.descr) + text = f.descr; + if (!text || text === "") continue; if (f.name === "color") descrList.push(f.name.toUpperCase() + ": " + f.text.toUpperCase() + ""); else - descrList.push(f.name.toUpperCase() + ": " + f.text); + descrList.push(f.name.toUpperCase() + ": " + text); } descr = descrList.length > 0 ? descrList.join(", ") : ""; } function setColor() { var opt = menuItem.opts; - opt.color = mColor.value ? mColor.value : "#ffffff"; + opt.color = mColor.value ? mColor.value : Material.color(Material.Blue + menuItem.num * 2); opt.iconColor = opt.color; menuItem.opts = opt; mColor.changes = false; @@ -183,7 +214,12 @@ Fact { // Telemetry update — computes filtered value and evaluates warn/alarm function updateValue() { - if (!_bindFn) return; + if (!_bindFn) { + hasWarning = false; + hasAlarm = false; + alertText = ""; + return; + } var v; try { v = _bindFn(); @@ -194,8 +230,12 @@ Fact { if (_f) v = _f.value; } catch (e2) {} } - if (v === undefined || !isFinite(v)) + if (v === undefined || !isFinite(v)) { + hasWarning = false; + hasAlarm = false; + alertText = ""; return; + } // Seed filter state on first valid value if (currentValue === undefined) @@ -209,6 +249,12 @@ Fact { // Evaluate warning/alarm using pre-compiled functions try { hasWarning = _warnFn ? _warnFn(filtered) : false; } catch(e) { hasWarning = false; } try { hasAlarm = _alarmFn ? _alarmFn(filtered) : false; } catch(e) { hasAlarm = false; } + if (hasAlarm) + alertText = itemTitle + ": " + qsTr("Alarm"); + else if (hasWarning) + alertText = itemTitle + ": " + qsTr("Warning"); + else + alertText = ""; } function saveValue2Fact() { @@ -242,19 +288,35 @@ Fact { Fact { id: mTitle - name: "chartname" + name: "title" title: qsTr("Title") descr: qsTr("Chart name") flags: Fact.Text onTextChanged: changes = true } + Fact { + id: mBindFact + name: "_bind_fact" + property bool transientFact: true + title: qsTr("Binding") + descr: qsTr("Select a mandala fact and fill the expression automatically") + flags: Fact.Int + units: "mandala" + onTextChanged: { + if (text && text !== "") + mBind.value = "mandala." + text + ".value"; + } + } Fact { id: mBind name: "bind" title: qsTr("Expression") - descr: "Math.atan(est.att.pitch/est.att.roll)" + descr: qsTr("Example: Math.atan(mandala.est.att.pitch.value / mandala.est.att.roll.value)") flags: Fact.Text - onTextChanged: changes = true + onTextChanged: { + syncBindSelector(); + changes = true; + } } MenuColor { id: mColor @@ -264,12 +326,12 @@ Fact { } MenuFilters { id: mFilters - name: "filt" + name: "filters" title: qsTr("Filters") descr: qsTr("Filter settings") } Fact { - name: "warn" + name: "warning" id: mWarn title: qsTr("Warning") descr: qsTr("Expression for warning (receives 'value')") @@ -280,14 +342,14 @@ Fact { name: "alarm" id: mAlarm title: qsTr("Alarm") - descr: "value>1.8 || (value>0 && value<1)" + descr: qsTr("Example: value>1.8 || (value>0 && value<1)") flags: Fact.Text onValueChanged: changes = true } Fact { name: "act" title: qsTr("Action") - descr: "cmd.proc.action=proc_action_reset" + descr: qsTr("Example: cmd.proc.action=proc_action_reset") flags: Fact.Text onValueChanged: changes = true } diff --git a/src/Plugins/Tools/Signals/MenuPage.qml b/src/Plugins/Tools/Signals/MenuPage.qml index a084e1158..c900f5739 100644 --- a/src/Plugins/Tools/Signals/MenuPage.qml +++ b/src/Plugins/Tools/Signals/MenuPage.qml @@ -38,6 +38,7 @@ Fact { // Warning/alarm aggregated from items property bool hasWarning: false property bool hasAlarm: false + property string warningText: "" // Set to true when created directly from Signals.qml (page-button flow) // to show the per-page Save button. False when used inside SignalsMenu popup. @@ -66,15 +67,23 @@ Fact { function updateWarnings() { var warn = false; var alarm = false; + var message = ""; for (var i = 0; i < mItems.size; ++i) { var it = mItems.child(i); - if (it.hasWarning) + if (it.hasWarning) { warn = true; - if (it.hasAlarm) + if (message === "") + message = it.alertText || it.warningMsg; + } + if (it.hasAlarm) { alarm = true; + if (message === "") + message = it.alertText || it.warningMsg; + } } hasWarning = warn; hasAlarm = alarm; + warningText = message; } function updateChartsValues() { @@ -83,6 +92,12 @@ Fact { updateWarnings(); } + function setSpeed(value) { + if (mSpeed.value === value) + return; + mSpeed.value = value; + } + function save() { var items = []; for (var i = 0; i < mItems.size; ++i) { @@ -115,13 +130,25 @@ Fact { function createItem(itemData) { if (!itemData.bind || itemData.bind === "") return; + var wasEmpty = mItems.size === 0; var c = createFact(mItems, "MenuItem.qml", { "data": itemData }); c.parentFact = mItems; c.removeTriggered.connect(function () { updatePageValues(); }); c.titleChanged.connect(updatePageValues); + if (wasEmpty && (!pTitle.value || /^P\d+$/.test(pTitle.value))) + pTitle.value = defaultPageName(itemData.bind); return c; } + function defaultPageName(bindExpr) { + if (!bindExpr || bindExpr === "") + return "P"; + var expr = bindExpr.replace(/^mandala\./, "").replace(/\.value$/, ""); + var parts = expr.split("."); + var leaf = parts.length > 0 ? parts[parts.length - 1] : expr; + return leaf.length > 0 ? leaf.charAt(0).toUpperCase() : "P"; + } + function createFact(parent, url, opts) { var component = Qt.createComponent(url); if (component.status === Component.Ready) { @@ -196,6 +223,7 @@ Fact { if (typeof signalsWidget !== 'undefined' && signalsWidget) signalsWidget.updateLayout(); } + onItemMoved: menuPage.updatePageValues() } Fact { diff --git a/src/Plugins/Tools/Signals/MenuSet.qml b/src/Plugins/Tools/Signals/MenuSet.qml index 9876f86f4..8a16d41f2 100644 --- a/src/Plugins/Tools/Signals/MenuSet.qml +++ b/src/Plugins/Tools/Signals/MenuSet.qml @@ -31,6 +31,9 @@ Fact { flags: (Fact.Group | Fact.FlatModel) property var pages: [] // from config / JSON + property var titleFact: setTitle + property var addPageFact: addPage + property var pagesFact: mPages signal selected(var num) @@ -45,6 +48,7 @@ Fact { // Add a new blank page (up to 10) Fact { + id: addPage title: qsTr("Add page") icon: "plus-circle" flags: Fact.Action @@ -59,11 +63,15 @@ Fact { Fact { id: mPages title: qsTr("Pages") - flags: (Fact.Group | Fact.Section) + flags: (Fact.Group | Fact.Section | Fact.Count | Fact.DragChildren) onSizeChanged: updateDescr() + onItemMoved: updateDescr() } Component.onCompleted: { + var opt = opts; + opt.page = "qrc:/Signals/MenuSetPage.qml"; + opts = opt; updateSetItems(); } @@ -79,6 +87,13 @@ Fact { }; } + function loadSet(setData) { + title = setData.title ? setData.title : title; + setTitle.value = title; + pages = setData.pages ? setData.pages : []; + updateSetItems(); + } + function updateSetItems() { mPages.deleteChildren(); var plist = pages ? pages : []; @@ -98,9 +113,15 @@ Fact { var pg = component.createObject(mPages, { "title": pageData.name ? pageData.name : ("P" + (mPages.size + 1)) }); + if (!pg) { + console.warn("MenuSet: failed to create MenuPage instance"); + return null; + } pg.parentFact = mPages; pg.load(pageData); - pg.destroyed.connect(updateDescr); + if (pg.titleChanged) + pg.titleChanged.connect(updateDescr); + updateDescr(); return pg; } diff --git a/src/Plugins/Tools/Signals/MenuSetPage.qml b/src/Plugins/Tools/Signals/MenuSetPage.qml new file mode 100644 index 000000000..82bd61338 --- /dev/null +++ b/src/Plugins/Tools/Signals/MenuSetPage.qml @@ -0,0 +1,100 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import APX.Facts + +import Apx.Common +import Apx.Menu + +Item { + id: root + + property var pageFact: fact ? fact : null + property var listFact: pageFact ? pageFact.pagesFact : null + + function triggerFact(itemFact) { + if (!itemFact) + return; + if (itemFact.treeType === Fact.Action || itemFact.dataType === Fact.Apply || itemFact.dataType === Fact.Remove || itemFact.dataType === Fact.Stop) { + itemFact.trigger(); + return; + } + menuPage.factButtonTriggered(itemFact); + } + + clip: true + + ColumnLayout { + anchors.fill: parent + spacing: Style.spacing + + FactButton { + Layout.fillWidth: true + visible: root.pageFact !== null + fact: root.pageFact ? root.pageFact.titleFact : null + noFactTrigger: true + onTriggered: root.triggerFact(fact) + } + + FactButton { + Layout.fillWidth: true + visible: root.pageFact !== null + fact: root.pageFact ? root.pageFact.addPageFact : null + noFactTrigger: true + onTriggered: root.triggerFact(fact) + } + + ListView { + id: listView + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + spacing: 0 + model: root.listFact ? root.listFact.model : null + + delegate: Loader { + active: modelData ? modelData.visible : false + visible: active + width: listView.width + height: active ? MenuStyle.itemSize : 0 + sourceComponent: Component { + FactButton { + fact: modelData ? modelData : null + noFactTrigger: true + size: MenuStyle.itemSize + onTriggered: { + listView.currentIndex = index; + root.triggerFact(fact); + } + } + } + } + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } + } + } +} diff --git a/src/Plugins/Tools/Signals/PageButton.qml b/src/Plugins/Tools/Signals/PageButton.qml index bfca193e2..67d5294f0 100644 --- a/src/Plugins/Tools/Signals/PageButton.qml +++ b/src/Plugins/Tools/Signals/PageButton.qml @@ -26,25 +26,23 @@ import QtQuick.Controls.Material import Apx.Common -TextButton { +ValueButton { id: pageBtn Layout.fillHeight: true checkable: true ButtonGroup.group: pageButtonGroup + showValue: false + alerts: true // The MenuPage Fact this button represents property var page: null - textColor: { - if (page && page.hasAlarm) - return Material.color(Material.Red); - if (page && page.hasWarning) - return Material.color(Material.Orange); - if (checked) - return Material.color(Material.Yellow); - return Material.primaryTextColor; - } + warning: page && page.hasWarning && !page.hasAlarm + error: page && page.hasAlarm + active: checked + normalColor: "#222" + activeColor: Qt.darker(Material.color(Material.BlueGrey), 1.5) // Pinned indicator — a subtle border background: Rectangle { @@ -56,6 +54,8 @@ TextButton { radius: height / 6 } + descr: page && page.warningText ? page.warningText : "" + toolTip: buildToolTip() function buildToolTip() { @@ -63,6 +63,10 @@ TextButton { return text; var s = []; s.push("" + page.title + ""); + if (page.warningText && page.warningText !== "") { + var warnColor = page.hasAlarm ? Material.color(Material.Red) : Material.color(Material.Orange); + s.push("" + page.warningText + ""); + } var values = page.values; for (var i = 0; i < values.length; ++i) { var it = values[i]; diff --git a/src/Plugins/Tools/Signals/README.md b/src/Plugins/Tools/Signals/README.md index fe4294aa8..e97f8fb4e 100644 --- a/src/Plugins/Tools/Signals/README.md +++ b/src/Plugins/Tools/Signals/README.md @@ -4,4 +4,13 @@ page: plugins # Signals -QML widget to show live chart of defined UAV physical values for easy tuning. +QML widget to show live charts of UAV telemetry values for tuning and diagnostics. + +The refactored Signals plugin now supports: + +- Multiple saved sets in `signals.json` +- Named pages inside each set +- Pinned pages rendered as stacked charts +- Per-page speed persistence with values `0.2x`, `0.5x`, `1x`, `2x`, `4x` +- Per-item colors, warning/alarm expressions, actions, and save-to-`sns.scr.*` +- Ordered, draggable filter chains with `running_avg` and `kalman_smp` diff --git a/src/Plugins/Tools/Signals/REFACTOR_NOTES.md b/src/Plugins/Tools/Signals/REFACTOR_NOTES.md deleted file mode 100644 index cfcc7b50e..000000000 --- a/src/Plugins/Tools/Signals/REFACTOR_NOTES.md +++ /dev/null @@ -1,143 +0,0 @@ -# Signals Plugin Refactor Notes - -## Source Analysis - -### FilteredCharts components → new name in Signals/ - -| Fc* file | New name | Role | -|---|---|---| -| FcChartsView.qml | ChartsView.qml | QtCharts renderer (replaces SignalsView.qml) | -| FcButton.qml | PageButton.qml | Per-page tab button | -| FcMenuSet.qml | MenuSet.qml | Per-page Fact-tree editor (now per-set) | -| FcMenuChart.qml | MenuItem.qml | Per-item editor | -| FcMenuFilters.qml | MenuFilters.qml | Filter selector per item | -| FcFilterRunningAvg.qml | FilterRunningAvg.qml | Running-avg filter params | -| FcFilterKalmanSimple.qml | FilterKalmanSimple.qml | Kalman filter params | -| FcMenuColor.qml | MenuColor.qml | Color fact that points to ColorChooser page | -| FcColorChooser.qml | ColorChooser.qml | 12×4 palette grid | -| FilteredCharts.qml | → absorbed into Signals.qml | Top-level widget | -| FilteredChartsPlugin.qml | → deleted | Plugin registration not needed | - -### Signals components → fate - -| Signals file | Fate | -|---|---| -| SignalsPlugin.qml | Keep (is the plugin registration) | -| Signals.qml | Rewrite — new top-level widget | -| SignalsView.qml | Delete — ChartsView.qml is a superset | -| SignalButton.qml | Delete — PageButton.qml replaces it | - ---- - -## Fact Tree Design - -``` -Signals (plugin, Rectangle root) - SignalsModel (ObjectModel — loads signals.json) - ┌─ [active index = json.active.signals] - └─ sets: array - └─ SignalsMenu (Fact, like NumbersMenu) - ├─ MenuSet #0 (Fact.Group+FlatModel) ← active set - │ ├─ title - │ └─ pages: MenuPage #0..N - │ ├─ name (string) - │ ├─ pin (bool) - │ ├─ speed (float [0.2,0.5,1,2,4]) - │ └─ items: MenuItem #0..M - │ ├─ bind (expression) - │ ├─ title (optional) - │ ├─ color (hex) - │ ├─ MenuFilters - │ │ ├─ FilterRunningAvg - │ │ └─ FilterKalmanSimple - │ ├─ warn - │ ├─ alarm - │ ├─ act - │ └─ save (sns.scr.* target) - └─ MenuSet #1 … -``` - -Mapping to Numbers pattern: -- `NumbersModel` → `SignalsModel` (ObjectModel, loads signals.json, exposes `edit()`) -- `NumbersMenu` → `SignalsMenu` (Fact, set editor, save/load signals.json) -- `NumbersMenuSet` → `MenuSet` (per set; has pages instead of values) -- `NumbersMenuNumber` → `MenuItem` (per item; adds color + filters + save) - ---- - -## JSON Schema - -```json -{ - "active": { "signals": 0 }, - "sets": [ - { - "title": "default", - "pages": [ - { - "name": "R", - "pin": false, - "speed": 1.0, - "items": [ - { "bind": "est.att.roll", "title": "", "color": "", "filters": [], "warn": "", "alarm": "", "act": "", "save": "" } - ] - } - ] - } - ] -} -``` - -Legacy migration: if file has top-level `page` (string) + `signalas` (array), wrap into one set "default" with one page. - ---- - -## Default Set — Binding Map - -Matches today's hardcoded Signals.qml buttons: - -| Page name | Items (bind expressions) | -|---|---| -| R | cmd.att.roll, est.att.roll | -| P | cmd.att.pitch, est.att.pitch | -| Y | cmd.pos.bearing, cmd.att.yaw, est.att.yaw | -| Axy | est.acc.x, est.acc.y | -| Az | est.acc.z | -| G | est.gyro.x, est.gyro.y, est.gyro.z | -| Pt | est.pos.altitude, est.pos.vspeed, est.air.airspeed | -| Ctr | ctr.att.ail, ctr.att.elv, ctr.att.rud, ctr.eng.thr, ctr.eng.prop, ctr.str.rud | -| RC | cmd.rc.roll, cmd.rc.pitch, cmd.rc.thr, cmd.rc.yaw | -| Usr | est.usr.u1, est.usr.u2, est.usr.u3, est.usr.u4, est.usr.u5, est.usr.u6 | - ---- - -## Adding a New Filter Type (future) - -1. Create `FilterMyType.qml` in `Signals/` — a `Fact { flags: Fact.Group }` that exposes `coef`/`coefs` and implements `applyFilter(v)` returning filtered value. -2. Add `FilterMyType { id: fMyType; name: "my_type" … }` inside `MenuFilters.qml`. -3. Add `"my_type"` to the `enumStrings` of `fTypes` in `MenuFilters.qml`. -4. Add a `case "my_type":` branch in `MenuItem.qml`'s `updateValue()`. - ---- - -## Files Summary (post-refactor) - -### Added to Signals/ -- ChartsView.qml -- PageButton.qml -- MenuSet.qml (replaces FcMenuSet — now manages pages, not sets) -- MenuPage.qml (new — per-page Fact editor) -- MenuItem.qml -- MenuFilters.qml -- FilterRunningAvg.qml -- FilterKalmanSimple.qml -- MenuColor.qml -- ColorChooser.qml -- SignalsMenu.qml (like NumbersMenu — manages sets) - -### Deleted from Signals/ -- SignalsView.qml -- SignalButton.qml - -### Deleted entirely -- src/Plugins/Tools/FilteredCharts/ (whole directory) diff --git a/src/Plugins/Tools/Signals/REFACTOR_PROMPT.md b/src/Plugins/Tools/Signals/REFACTOR_PROMPT.md new file mode 100644 index 000000000..7df0862c6 --- /dev/null +++ b/src/Plugins/Tools/Signals/REFACTOR_PROMPT.md @@ -0,0 +1,251 @@ +## Context for the agent + +You are working in the **uavos/apx-gcs** repository (APX Ground Control Station, Qt 6 / QML / C++). The active branch is `FiltredCharts`. PR #111 ("Filtred charts") by SokolovskyYury adds a new plugin at `src/Plugins/Tools/FilteredCharts/`. The owner/architect (Aliaksei Stratsilatau, @uavinda) has decided, after discussion with the field operators, that this plugin as-submitted will not ship. Instead, you will **refactor the existing `Signals` plugin in-place** so that it natively absorbs the FilteredCharts functionality. The goal: one plugin, one set of buttons, Miller's-rule-friendly UI, backward-compatible with the current `Signals` layout and behavior. + +**Do not create a second plugin.** The `FilteredCharts` directory and PR will be superseded by enhancements to `Signals`. Once the refactor is complete, the `FilteredCharts` plugin directory must be removed from `src/Plugins/Tools/` and from the build. + +### Source of the requirements + +The spec below is the distillation of the architect's design decisions posted in Discord `#gcs` on 2026-04-23. Every point is a hard requirement unless marked optional. Do not reinterpret or drop any of them without asking. + +--- + +## Repository files you MUST read before writing any code + +Read these in full and understand the relationships. Open each one in the editor. + +### Current FilteredCharts plugin (to be absorbed, then deleted) +- `src/Plugins/Tools/FilteredCharts/FilteredChartsPlugin.qml` — AppPlugin registration +- `src/Plugins/Tools/FilteredCharts/FilteredCharts.qml` — top-level widget: page buttons 1..10, speed button, chart area, settings persistence to `charts.json` +- `src/Plugins/Tools/FilteredCharts/FcChartsView.qml` — QtCharts renderer; near-duplicate of `SignalsView.qml` with `speedFactor=[0.2,0.5,1,2,4]`, per-series `updateSeriesColor()`, `resetEnable` flag +- `src/Plugins/Tools/FilteredCharts/FcButton.qml` — per-page button; owns an `FcMenuSet`, has `getSet()/loadSet()/setSpeed()`, reacts to `mandala.onTelemetryDecoded` +- `src/Plugins/Tools/FilteredCharts/FcMenuSet.qml` — Fact-tree editor for a set: title, speed, `msValues` group with `FcMenuChart` children, Save action +- `src/Plugins/Tools/FilteredCharts/FcMenuChart.qml` — per-chart editor: `chartname`, `bind` (expression), color, filters, `save` target; implements `useRunningAvgFilter()`, `useKalmanSmpFilter()`, state/covariance +- `src/Plugins/Tools/FilteredCharts/FcMenuFilters.qml` — filter picker enum `["none", "running_avg", "kalman_smp"]` containing `FcFilterRunningAvg` + `FcFilterKalmanSimple` +- `src/Plugins/Tools/FilteredCharts/FcFilterRunningAvg.qml` — coefficient K ∈ [0,1] +- `src/Plugins/Tools/FilteredCharts/FcFilterKalmanSimple.qml` — measurement noise + environment noise +- `src/Plugins/Tools/FilteredCharts/FcMenuColor.qml` — color Fact routed to `FcColorChooser.qml` page +- `src/Plugins/Tools/FilteredCharts/FcColorChooser.qml` — 12×4 palette grid +- `src/Plugins/Tools/FilteredCharts/CMakeLists.txt` + +### Current Signals plugin (the base you will refactor) +- `src/Plugins/Tools/Signals/SignalsPlugin.qml` +- `src/Plugins/Tools/Signals/Signals.qml` — hardcoded buttons R / P / Y / Axy / Az / G / Pt / Ctr / RC / Usr / `+` (custom text input) + speed button +- `src/Plugins/Tools/Signals/SignalsView.qml` — QtCharts renderer +- `src/Plugins/Tools/Signals/SignalButton.qml` — minimal toggle button +- `src/Plugins/Tools/Signals/CMakeLists.txt` + +### Numbers plugin — the canonical set/editor pattern to mirror +- `src/main/qml/Apx/Controls/numbers/NumbersModel.qml` — loads `numbers.json`, builds `NumbersItem` objects, exposes `edit()` → `NumbersMenuPopup` +- `src/main/qml/Apx/Controls/numbers/NumbersMenu.qml` — Fact-tree set editor, `loadSettings()/saveSettings()`, "Add set" action, `select(num)` radio behavior, preserves `json.active[settingsName]` +- `src/main/qml/Apx/Controls/numbers/NumbersMenuSet.qml` — per-set editor with `setTitle`, `NumbersMenuNumber` template, drag-children values list +- `src/main/qml/Apx/Controls/numbers/NumbersMenuNumber.qml` — per-item editor: `bind`, `title`, `prec`, `warn`, `alarm`, `act` +- `src/main/qml/Apx/Controls/numbers/NumbersMenuPopup.qml` +- `src/main/qml/Apx/Controls/numbers/NumbersItem.qml` +- `src/main/qml/Apx/Controls/numbers/NumbersBar.qml` +- `src/main/qml/Apx/Controls/numbers/NumbersBox.qml` + +Also skim `src/main/qml/Apx/Common/` for `TextButton`, `IconButton`, `ValueButton`, `FactButton`, `Style` — reuse these; do not invent new primitives. + +Also skim how `datalink/ports` is modeled as a dynamic list (for the multi-filter list pattern). Search under `src/Plugins/Protocols` or `src/main/qml/Apx/Menu` for `ports` to find it. + +--- + +## Terminology (use these exact names in code and JSON) + +- **Set** — top-level saved group, same concept as a Numbers set. One set = one full chart configuration tied to a specific system/airframe. There can be many sets (e.g. "default", "HAPS", "R22", …). Only one set is active. +- **Page** — a named page inside a set, corresponds to one of the tabs at the bottom of the widget (today: R, P, Y, Axy, Az, G, Pt, Ctr, RC, Usr, +). The default set's pages must be identical to the hardcoded Signals pages. +- **Item** — a single plotted variable (previously a `SignalButton.values[i]` entry or an `FcMenuChart` entry). Each item has: expression (`bind`), optional `title`, `color`, per-item `filters` (ordered list, can be empty, can have multiple), optional `warning`/`alarm`/`act`, optional `save` target (`sns.scr.*`). +- **Filter** — a processing node inside an item's filter chain. Types at minimum: `running_avg`, `kalman_smp`. Model it as a list (like `datalink/ports`) so more filter types can be added later. + + +--- + +## Functional spec — what the refactored `Signals` plugin must do + +### 1. Unify with FilteredCharts +- The plugin is `Signals`, located at `src/Plugins/Tools/Signals/`. No new plugin directory. +- `FilteredCharts` plugin directory, its entry in `src/Plugins/Tools/CMakeLists.txt`, and the `FilteredChartsPlugin` registration must be removed at the end of the refactor. +- Any QML helper worth keeping from `FilteredCharts/` (chart view, color chooser, filter facts) must be **moved** into the `Signals` plugin directory with appropriate renaming (e.g. `ChartsView.qml`, `ColorChooser.qml`, `FilterRunningAvg.qml`, `FilterKalmanSimple.qml`, `MenuFilters.qml`, `MenuColor.qml`, `MenuChart.qml`, `MenuSet.qml`). The copyright header must be preserved on every copied file. Drop the `Fc` prefix. + +### 2. Sets editor (reuse Numbers pattern) +- Provide a Numbers-style sets editor. A user can create/rename/delete sets; one set is active at a time. Persist to a single JSON file in `application.prefs`, e.g. `signals.json`. Use the same `json.active`/`json.sets` structure as `numbers.json`. +- Default set is auto-generated on first run and reproduces today's hardcoded `Signals` configuration byte-for-byte: pages `R, P, Y, Axy, Az, G, Pt, Ctr, RC, Usr` with the exact same mandala bindings. The default set must always be regenerable (provide a "Reset to defaults" action). Do not delete the default if the user has no other sets. +- When the active set changes, pages and items are rebuilt from that set. + +### 3. Pages inside a set +- Each set contains an ordered list of pages (max 10 — keep current limit, Shpilevski's HAPS requirement). +- A page has: `name` (string, user-editable; default = first character of the first item's variable, uppercase — e.g. `est.att.roll` → `R`), `pin` (bool, optional — see #6), and an `items` array. +- The `+` button (top-right, where `IconButton { iconName: "plus" }` sits today in `FilteredCharts.qml`) opens the currently active page for editing. It is not a tab any more — replaced by the set name label (see next point). +- The bottom-right of the bar shows the **set name** as a plain label (or short dropdown to switch sets), not a `+` tab. Clicking it opens the sets editor. +- Tab buttons show the page `name` (not 1..10). On hover show a tooltip listing the page name and its items with their colors — the existing `updateToolTip()` logic in `FcButton.qml` is the right model. + +### 4. Items inside a page +- Each item has: `bind` (JS expression, required), `title` (optional), `color` (hex, optional — falls back to palette like Signals does today via `Material.color(Material.Blue+i*2)`), `filters` (ordered list, can be empty), `warning` (optional JS expression), `alarm` (optional JS expression), `act` (optional JS action), `save` (optional mandala var, must start with `sns.scr` — reuse check from `FcMenuChart.saveValue2Fact()`). +- Per-item editor reuses `NumbersMenuNumber` structure — same fields, same `load()/save()/settingName()/updateTitle()/updateDescr()` conventions — plus `color` and `filters`. +- When `warning` or `alarm` expressions evaluate true, highlight the **page button** (the tab) the same way `NumbersItem` highlights itself (use `ValueButton.alerts`), and show the warning message + tooltip on the page button. This is the "warning from `numbers` in item, highlight the `page` button" requirement. + +### 5. Filters as an ordered list +- Replace the current single-enum filter selector with a **list** of filter instances per item, modeled after `datalink/ports`. Each filter is a Fact with a `type` (enum), a position (draggable), an enable toggle, and its own parameter subtree. +- Supported filter types at launch: `running_avg` (reuse `FcFilterRunningAvg.qml` logic) and `kalman_smp` (reuse `FcFilterKalmanSimple.qml` logic). Architecture must make it trivial to add a new type — one new QML file + one entry in the enum registry. +- At runtime, `updateValue()` runs the filters sequentially on each telemetry tick: output of filter N is input to filter N+1. +- The Kalman filter must still seed its state/covariance from the first raw value (keep the `setKalmanState(v, 0.1)` behavior). + +### 6. Pinned pages +- Add a per-page `pin` option. When one or more pages are pinned, the widget shows them stacked (several chart views in the same plugin panel). Non-pinned pages remain a single-slot tabbed view. Implement this as multiple `ChartsView` instances vertically stacked in a `ColumnLayout` when `pages.filter(p => p.pin).length > 0`. Tab buttons for pinned pages visually indicate their pinned state (e.g. outlined border, or a pin icon overlay). + +### 7. Speed / scale button +- Fix the bug noted in review: the speed button in `SignalsView.qml` uses the index-based `speedFactor` array and does not persist; the one in `FcChartsView.qml` uses direct factor values but the per-set Speed slider in `FcMenuSet.qml` writes into a fact that isn't wired back on reload in all cases. Pick **one** model: per-page speed, direct factor value, options `[0.2, 0.5, 1, 2, 4]` (keep FilteredCharts' set, since 0.2 and 0.5 are needed for long-term recording). Persist speed per page. Clicking the speed button cycles to the next value. Label remains `{value}x`. + +### 8. Colors +- Default item colors follow the existing `Signals` algorithm: `Material.color(Material.Blue + i*2)` where `i` is the item index within the page. The user may override per item via the color chooser (reuse `FcColorChooser.qml`, rename to `ColorChooser.qml`). For legacy `cmd.*` items keep the desaturated/lightened rendering from `FcChartsView.addFactSeries` / `SignalsView.addFactSeries`. + +### 9. Save-to-mandala +- Keep the `save` field with the `sns.scr.*` guard (`chartWarning` when the user picks a non-`sns.scr` name). When an item has a `save` target, the filtered output of that item is pushed into the mandala variable via `apx.fleet.current.mandala.fact(fname, true).setRawValueLocal(value)` on every tick — identical to `FcMenuChart.saveValue2Fact()`. Also keep the `fcControl.checkScrMatches(text)` dedup check across all items in all pages of the active set. + +### 10. Chart rendering +- Use one `ChartsView.qml` (renamed from `FcChartsView.qml`) as the renderer. Delete `SignalsView.qml` — its functionality is a strict subset. Port over the FilteredCharts improvements: `resetEnable` on facts change, `updateSeriesColor()` for live color edits, instant rescale-grow, and the `speedFactor=[0.2,0.5,1,2,4]` array. + + +## Non-functional requirements + +1. **No C++ changes unless strictly required.** All work should be in QML under `src/Plugins/Tools/Signals/` and (where shared) `src/main/qml/Apx/Common/`. The plugin is QML-only today and should stay that way. +2. **Preserve the `FactButton.qml` `iconColor` change** introduced by PR #111 — that is a real improvement and is already merged conceptually; keep it in `src/main/qml/Apx/Common/FactButton.qml`. +3. **Revert `.vscode/settings.json`** if the FiltredCharts branch added private IDE settings there — keep the file clean. +4. **Backward-compatible migration.** Old `signals.json` files used a flat `{page: "string", signalas: [...]}` shape (note Yury's typo `signalas` — keep reading it, write as `signals`). On first load, if the old shape is detected, migrate to the new `{active, sets}` shape by wrapping the legacy list into a single set called "default" with a single page called (old `page` value or "page 1"). Never lose user data. +5. **Localize every user-visible string with `qsTr()`**. Include English source text. Match the style of existing Signals/Numbers QML. +6. **QML style.** 4-space indent, no tabs. Match the surrounding file style. Keep `import` order consistent with sibling files. Keep LGPL-2.1 headers on every new file (copy the header from `SignalsPlugin.qml`). +7. **CMakeLists.** Update `src/Plugins/Tools/Signals/CMakeLists.txt` to add any new QML files under `QRC_QML`. Remove `FilteredCharts` from `src/Plugins/Tools/CMakeLists.txt` (the `add_subdirectory(FilteredCharts)` line added by PR #111 — if it was added — must go). +8. **No new third-party dependencies.** Only Qt modules already used by the project (`QtQuick`, `QtQuick.Controls`, `QtQuick.Layouts`, `QtCharts`, `QtQml.Models`, `Apx.Common`, `Apx.Controls`, `APX.Facts`, `APX.Fleet`, `APX.Mandala`). +9. **Simulator smoke test** after the refactor: run GCS against the built-in simulator, create a set with two pages (one pinned, one not), add items with and without filters, toggle the speed button, edit a color, save to `sns.scr.*`, restart the app, confirm the state persisted. + +## JSON schema for `signals.json` + +```json +{ + "active": { + "signals": 0 + }, + "sets": [ + { + "title": "default", + "pages": [ + { + "name": "attitude", + "pin": false, + "speed": 1.0, + "items": [ + { + "bind": "est.att.roll", + "title": "roll", + "color": "#2196F3", + "filters": [ + { "type": "running_avg", "enabled": true, "coef": 0.2 }, + { "type": "kalman_smp", "enabled": false, "r": 0.1, "q": 0.001 } + ], + "warning": "", + "alarm": "", + "act": "", + "save": "" + } + ] + }, + { + "name": "power", + "pin": true, + "speed": 0.5, + "items": [ + { "bind": "est.pwr.vbat", "title": "Vbat", "color": "#FFC107", "filters": [], "save": "sns.scr.vbat_f" } + ] + } + ] + } + ] +} +``` + +- `active.signals` = index into `sets` of the currently visible set (mirrors `NumbersModel`'s `active.numbers`). +- Every field other than `bind` is optional. Readers must tolerate missing keys. +- Legacy (pre-refactor) files with keys `page` (string) and `signalas` (array) must be migrated silently — see non-functional requirement 4. + +## Step-by-step plan the agent should follow + +Work in this order. Commit after each numbered step with a clear message. Do not skip the inventory and design steps. + +1. **Inventory.** Read every file in `src/Plugins/Tools/FilteredCharts/` and `src/Plugins/Tools/Signals/` and `src/main/qml/Apx/Controls/numbers/`. Write a short `REFACTOR_NOTES.md` in the Signals plugin dir listing every Fc* component, what it does, and which Signals/Numbers component it maps to in the new world. This doc is your reference — keep it updated as you go and delete it in the final cleanup step. +2. **Design sketch.** Before writing code, in `REFACTOR_NOTES.md`, sketch the new Fact tree for Signals: `Signals (plugin) → Sets (list) → Set → Pages (list) → Page → Items (list) → Item → Filters (list) → Filter`. Map each level to a QML file. Confirm it matches the Numbers pattern (`NumbersModel` → `sets` → `pages` → `items`). +3. **Scaffold with renames.** Copy each `Fc*.qml` to its new name inside `Signals/` and strip the `Fc` prefix: `FcChartsView.qml` → `ChartsView.qml`, `FcButton.qml` → `PageButton.qml`, `FcMenuSet.qml` → `MenuSet.qml`, `FcMenuChart.qml` → `MenuItem.qml`, `FcMenuFilters.qml` → `MenuFilters.qml`, `FcFilterRunningAvg.qml` → `FilterRunningAvg.qml`, `FcFilterKalmanSimple.qml` → `FilterKalmanSimple.qml`, `FcMenuColor.qml` → `MenuColor.qml`, `FcColorChooser.qml` → `ColorChooser.qml`. Update every import and type reference. Delete the old `Signals.qml`, `SignalsView.qml`, `SignalButton.qml` — they are superseded. At this point the plugin should build and show the old FilteredCharts UI under the name "Signals". +4. **Data model.** Create `SignalsModel.qml` mirroring `NumbersModel.qml` (sets, active index, persistence through `Fact` + `defaults` + JSON). Wire `signals.json` load/save. Implement the legacy migration for the `page`/`signalas` shape. +5. **Default-set factory.** Build the default first-run set so a fresh user sees useful content: one set "default" with pages `attitude` (roll/pitch/yaw), `rates` (Ax/Ay/Az), `gyro`, `pitot`, `ctr`, `rc`, `user` — matching the hardcoded buttons in today's `Signals.qml`. These are seeded only when `signals.json` is missing or empty. +6. **Tabs, pin, speed.** In the main widget (rename `Signals.qml`'s role into `SignalsView.qml`-style container), render page tabs from the active set. Replace the old `+` tab with a set-name label at bottom-right (click → sets editor) and a `+` button at top-right (click → active page's item editor). Implement the pinned-pages stacked layout. Wire the per-page speed button. +7. **Item editor.** Build `MenuItem.qml` on the `NumbersMenuNumber` template: same field conventions, same `descr` wiring, same Apply/Cancel behavior. Add `color` (via `ColorChooser`), `filters` (list editor), `save` (with `sns.scr` guard + dedup). +8. **Filter list.** Implement filters as an ordered list of Facts under each item, modeled on `src/Plugins/System/DataLink/ports` (draggable, enable toggle, typed subtree). Register the two built-in filter types. Document in `REFACTOR_NOTES.md` exactly how to add a third filter type. +9. **Cleanup.** Delete `src/Plugins/Tools/FilteredCharts/` entirely. Remove its `add_subdirectory` from `src/Plugins/Tools/CMakeLists.txt`. Remove `REFACTOR_NOTES.md`. Update `src/Plugins/Tools/Signals/README.md` with a short summary of the new capabilities. +10. **Build + smoke.** `cmake --build` the project. Fix any QML warnings from `qmllint` on the new files. Run the simulator smoke test described in non-functional requirement 9. +11. **Pull request.** Create a new branch `signals-unified` off `main` (do **not** rebase on `FiltredCharts`). Cherry-pick the `FactButton.qml` `iconColor` change from PR #111 so that contribution is preserved. Open PR titled "Signals plugin: unified sets/pages/filters (replaces PR #111)". In the PR description, link PR #111 and say it should be closed in favor of this one. List the files deleted, added, and modified. Include a screenshot/gif of the simulator smoke test. + +## Open questions to flag + +If any of these becomes a real blocker while coding, stop and ask before guessing. Otherwise default as specified. + +1. **Speed scope.** The spec says speed is per page. If during implementation it becomes obvious users want one speed per set instead, flag it. Default: per page. +2. **Legacy `signalas` typo.** Keep reading the old key on load but always write `signals` on save. No user-visible migration message needed. +3. **The old `+` free-text item input** in `Signals.qml` (the row with `TextField` that let users type arbitrary `bind` strings inline) — is it worth keeping as a shortcut next to the item editor? Default: remove it; the full item editor replaces it. + +## Constraints on your behavior + +- **No new third-party dependencies.** Qt modules already referenced by the project only. +- **No unrelated file changes.** Exception: if you notice the `PligIns` typo in `src/main/AppRoot.cpp` comments or similar obvious typos *in files you are already editing*, fix them. Do not go typo-hunting. +- **No C++ signature changes** in `PluginInterface`, `Fact`, `AppRoot`, etc. QML-only refactor. +- **Ask only on real conflicts.** Do not ask for permission for obvious style or naming choices — pick the one that matches the surrounding code. +- **Terse progress updates.** After each of the 11 steps, report: files touched, lines added/removed, one-sentence summary. No essays. +- **Preserve git history where reasonable.** Use `git mv` for renames so blame is preserved. Don't rewrite existing commits. +- **Run `qmllint`** on every new and modified QML file before committing the step. + +## Reference — Discord context (2026-04-23) + +### Architect's decision (Aliaksei, @uavinda), `#gcs`, 12:59 MSK + +> If we add a pages editor to `Signals`, and give each chart the ability to enable filters, we end up with `FilteredCharts`. So let's not multiply entities — do it all inside `Signals` and drop `FilteredCharts`. +> +> What's needed: +> - In `Signals`, remove the hardcoded R/P/Y/Axy/Az/G/Pt/Ctr/RC/Usr buttons — replace them with a sets/pages editor like in `Numbers`. +> - Each chart item has: bind, color, filters (ordered list, multiple can run in sequence), warning/alarm/act, optional save to `sns.scr.*`. +> - Pages can be pinned — pinned pages are shown stacked. +> - Speed button is per page, values `[0.2, 0.5, 1, 2, 4]`, persisted. +> - A warning from an item highlights its page tab. +> - Everything persists in `signals.json` with the `{active, sets}` structure, same as `Numbers`. +> - Close PR #111, open a new PR off `main`. + +### UI review (Slava Vasiukovich), `#gcs`, 11:00 MSK + +1. Functional duplication with the existing `Signals` plugin — no reason for a second plugin with a similar widget. +2. The scale/speed button behaves differently from neighboring plugins — different values, different persistence logic. +3. Tab names "1..10" mean nothing to the operator — they need meaningful names. + +### Counter (Yury, author of PR #111) + +Yury argued the plugin is distinct because of the filter chain. Aliaksei's resolution: filter chain moves into Signals, plugin merges, PR #111 is closed. + +### PR + +- [PR #111 — "Filtred charts"](https://github.com/uavos/apx-gcs/pull/111) by SokolovskyYury — 18 files, +1638 / -88. To be closed when `signals-unified` merges. + +### FiltredCharts branch commits to cherry-pick ideas from (most recent first) + +- `c72cdab` Change speed with a button click +- `3c41e3f` Charts view refactoring +- `f66ca99` Button minor fix +- `737db79`, `3904baa`, `fa708d7` Chart/color menu fixes +- `681a715` Set running avg defaults coef +- `ef2f9ce`, `16bc4b3`, `28733ef`, `32e10d1`, `bf8b423` Various fixes +- `d90d99a` Similar names fix +- `8a414c6` Used scripts var check +- `32a3f06`, `8fc0746` newItem icon + pinned menu fixes +- `fda3b2e`, `d69f514`, `983341d`, `ee6c6c5`, `b31ed47` Chart reset/color/apply fixes +- `d1a3f5a` Coefs range and precision +- `7ecae37` Color menu added +- `4c5290d` Write values without sending implemented +- `d5a1c9e`, `b6e1ef5`, `a689078`, `0cf8c9f`, `2f7e8f9` Refactoring, saving, save-to-fact + +The `FactButton.qml` `iconColor` change from this branch is worth cherry-picking into the new PR. diff --git a/src/Plugins/Tools/Signals/Signals.qml b/src/Plugins/Tools/Signals/Signals.qml index c391af7ab..aabc1dfc5 100644 --- a/src/Plugins/Tools/Signals/Signals.qml +++ b/src/Plugins/Tools/Signals/Signals.qml @@ -31,6 +31,10 @@ import Apx.Common Rectangle { id: signalsWidget + SignalsModel { + id: signalsModel + } + implicitHeight: mainLayout.implicitHeight implicitWidth: mainLayout.implicitWidth border.width: 0 @@ -52,13 +56,13 @@ Rectangle { // ----------------------------------------------------------------- function saveSettings() { - var json = _loadJson(); + var json = signalsModel.loadJson(); json.sets = []; var activeSets = _buildSetsFromPages(); json.sets = activeSets.sets; if (!json.active) json.active = {}; json.active["signals"] = activeSetIndex; - application.prefs.saveFile("signals.json", JSON.stringify(json, ' ', 2)); + signalsModel.saveJson(json); } function checkScrMatches(val) { @@ -72,14 +76,26 @@ Rectangle { function updateLayout() { pinnedPages = activePages.filter(function(p) { return p.pinned; }); + var nonPinnedPages = activePages.filter(function(p) { return !p.pinned; }); + if (currentPage && currentPage.pinned) + currentPage = nonPinnedPages.length > 0 ? nonPinnedPages[0] : null; + if (!currentPage && nonPinnedPages.length > 0) + currentPage = nonPinnedPages[0]; } function updateSeriesColors() { singleChart.updateSeriesColor(); + for (var i = 0; i < pinnedChartsRepeater.count; ++i) { + var item = pinnedChartsRepeater.itemAt(i); + if (item) + item.updateSeriesColor(); + } } function activatePage(page) { if (!page) return; + if (page.pinned) + return; currentPage = page; singleChart.resetEnable = true; singleChart.facts = Qt.binding(function() { @@ -102,23 +118,13 @@ Rectangle { // Destroy old page facts _destroyActivePages(); - var json = _loadJson(); - - // Legacy migration: {page, signalas} -> {active, sets} - if (json && json.signalas && !json.sets) - json = _migrateLegacy(json); - - var sets = (json && json.sets) ? json.sets : []; - if (sets.length === 0) sets = [_buildDefaultSet()]; - - var idx = 0; - if (json && json.active) - idx = json.active["signals"] || 0; - if (idx < 0 || idx >= sets.length) idx = 0; + var json = signalsModel.loadJson(); + var sets = json.sets; + var idx = signalsModel.activeIndex(json); activeSetIndex = idx; var activeSet = sets[idx]; - activeSetTitle = activeSet.title || "default"; + activeSetTitle = activeSet.title || qsTr("default"); var pages = activeSet.pages || []; var newPages = []; @@ -128,7 +134,8 @@ Rectangle { } activePages = newPages; pinnedPages = newPages.filter(function(p) { return p.pinned; }); - currentPage = newPages.length > 0 ? newPages[0] : null; + var nonPinnedPages = newPages.filter(function(p) { return !p.pinned; }); + currentPage = nonPinnedPages.length > 0 ? nonPinnedPages[0] : null; if (currentPage) { singleChart.resetEnable = true; @@ -140,12 +147,12 @@ Rectangle { }); } else { singleChart.facts = []; + singleChart.speedFactorValue = 1.0; } } function _loadJson() { - var f = application.prefs.loadFile("signals.json"); - return f ? JSON.parse(f) : {}; + return signalsModel.loadJson(); } function _destroyActivePages() { @@ -163,7 +170,7 @@ Rectangle { return null; } var pg = component.createObject(signalsWidget, { - "title": pageData.name ? pageData.name : "P", + "title": pageData.name ? pageData.name : qsTr("P"), "isDirectEdit": true }); pg.parentFact = apx.fleet.local; @@ -178,7 +185,7 @@ Rectangle { savedPages.push(activePages[i].save()); // Load existing sets, replace active one - var json = _loadJson(); + var json = signalsModel.loadJson(); var sets = (json && json.sets) ? JSON.parse(JSON.stringify(json.sets)) : []; if (sets.length === 0) sets.push({ title: activeSetTitle, pages: [] }); if (activeSetIndex >= sets.length) activeSetIndex = 0; @@ -253,9 +260,13 @@ Rectangle { text: modelData.title Layout.fillHeight: true ButtonGroup.group: pageButtonGroup - checked: modelData === signalsWidget.currentPage + checked: !modelData.pinned && modelData === signalsWidget.currentPage onClicked: { - if (checked) { + var wasCurrent = modelData === signalsWidget.currentPage; + if (modelData.pinned) { + if (modelData) + modelData.trigger(); + } else if (wasCurrent) { // already active — open page editor if (modelData) modelData.trigger(); } else { @@ -288,25 +299,25 @@ Rectangle { var idx = factors.indexOf(currentPage.speed); var next = (idx >= 0 && idx < factors.length - 1) ? factors[idx + 1] : factors[0]; - currentPage.speed = next; + currentPage.setSpeed(next); saveSettings(); } } } } - // + button (top-right) — opens current page item editor + // + button (top-right) — opens current page editor IconButton { anchors.top: parent.top anchors.right: parent.right anchors.margins: Style.spacing size: Style.buttonSize * 0.7 iconName: "plus" - toolTip: qsTr("Edit current page items") + toolTip: qsTr("Edit current page") opacity: ui.effects ? (hovered ? 1 : 0.5) : 1 onTriggered: { if (currentPage) - currentPage.addNewItem(); + currentPage.trigger(); } } @@ -335,33 +346,7 @@ Rectangle { // ----------------------------------------------------------------- function _migrateLegacy(oldJson) { - var items = (oldJson.signalas || []) - .map(function(it) { - return { - bind: it.bind || it.name || "", - title: it.title || "", - color: it.color || "", - filters: [], - warn: it.warn || "", - alarm: it.alarm || "", - act: it.act || "", - save: it.save || "" - }; - }) - .filter(function(it) { return it.bind !== ""; }); - - return { - active: { signals: 0 }, - sets: [{ - title: "default", - pages: [{ - name: oldJson.page || "page 1", - pin: false, - speed: 1.0, - items: items - }] - }] - }; + return signalsModel.migrateLegacy(oldJson); } // ----------------------------------------------------------------- @@ -369,62 +354,6 @@ Rectangle { // ----------------------------------------------------------------- function _buildDefaultSet() { - return { - title: "default", - pages: [ - { name: "R", pin: false, speed: 1.0, items: [ - { bind: "mandala.cmd.att.roll.value", title: "roll cmd" }, - { bind: "mandala.est.att.roll.value", title: "roll" } - ]}, - { name: "P", pin: false, speed: 1.0, items: [ - { bind: "mandala.cmd.att.pitch.value", title: "pitch cmd" }, - { bind: "mandala.est.att.pitch.value", title: "pitch" } - ]}, - { name: "Y", pin: false, speed: 1.0, items: [ - { bind: "mandala.cmd.pos.bearing.value", title: "bearing cmd" }, - { bind: "mandala.cmd.att.yaw.value", title: "yaw cmd" }, - { bind: "mandala.est.att.yaw.value", title: "yaw" } - ]}, - { name: "Axy", pin: false, speed: 1.0, items: [ - { bind: "mandala.est.acc.x.value", title: "Ax" }, - { bind: "mandala.est.acc.y.value", title: "Ay" } - ]}, - { name: "Az", pin: false, speed: 1.0, items: [ - { bind: "mandala.est.acc.z.value", title: "Az" } - ]}, - { name: "G", pin: false, speed: 1.0, items: [ - { bind: "mandala.est.gyro.x.value", title: "Gx" }, - { bind: "mandala.est.gyro.y.value", title: "Gy" }, - { bind: "mandala.est.gyro.z.value", title: "Gz" } - ]}, - { name: "Pt", pin: false, speed: 1.0, items: [ - { bind: "mandala.est.pos.altitude.value", title: "alt" }, - { bind: "mandala.est.pos.vspeed.value", title: "vspd" }, - { bind: "mandala.est.air.airspeed.value", title: "airspeed" } - ]}, - { name: "Ctr", pin: false, speed: 1.0, items: [ - { bind: "mandala.ctr.att.ail.value", title: "ail" }, - { bind: "mandala.ctr.att.elv.value", title: "elv" }, - { bind: "mandala.ctr.att.rud.value", title: "rud" }, - { bind: "mandala.ctr.eng.thr.value", title: "thr" }, - { bind: "mandala.ctr.eng.prop.value", title: "prop" }, - { bind: "mandala.ctr.str.rud.value", title: "str.rud" } - ]}, - { name: "RC", pin: false, speed: 1.0, items: [ - { bind: "mandala.cmd.rc.roll.value", title: "RC roll" }, - { bind: "mandala.cmd.rc.pitch.value", title: "RC pitch" }, - { bind: "mandala.cmd.rc.thr.value", title: "RC thr" }, - { bind: "mandala.cmd.rc.yaw.value", title: "RC yaw" } - ]}, - { name: "Usr", pin: false, speed: 1.0, items: [ - { bind: "mandala.est.usr.u1.value", title: "u1" }, - { bind: "mandala.est.usr.u2.value", title: "u2" }, - { bind: "mandala.est.usr.u3.value", title: "u3" }, - { bind: "mandala.est.usr.u4.value", title: "u4" }, - { bind: "mandala.est.usr.u5.value", title: "u5" }, - { bind: "mandala.est.usr.u6.value", title: "u6" } - ]} - ] - }; + return signalsModel.buildDefaultSet(); } } diff --git a/src/Plugins/Tools/Signals/SignalsMenu.qml b/src/Plugins/Tools/Signals/SignalsMenu.qml index b97ac2a00..9d6c61e2e 100644 --- a/src/Plugins/Tools/Signals/SignalsMenu.qml +++ b/src/Plugins/Tools/Signals/SignalsMenu.qml @@ -29,6 +29,8 @@ import APX.Facts Fact { id: setsFact + readonly property QtObject signalsModel: SignalsModel {} + property var defaults property string settingsName: "signals" property bool destroyOnClose: true @@ -66,13 +68,7 @@ Fact { function loadSettings() { var sets = []; - var f = application.prefs.loadFile("signals.json"); - var json = f ? JSON.parse(f) : {}; - - // Legacy migration: {page, signalas} → {active, sets} - if (json && json.signalas && !json.sets) { - json = migrateLegacy(json); - } + var json = signalsModel.loadJson(); var currentSetIdx = -1; @@ -82,19 +78,8 @@ Fact { if (!set || !set.pages) continue; sets.push(set); } - var setIdx = json.active ? json.active[settingsName] : 0; - if (setIdx >= 0 && setIdx < sets.length) - currentSetIdx = setIdx; - else if (sets.length > 0) - currentSetIdx = 0; - } - - // First-run: generate default set - if (sets.length <= 0 || !json.active) { - var defSet = buildDefaultSet(); - sets.push(defSet); - currentSetIdx = 0; } + currentSetIdx = signalsModel.activeIndex(json); for (var i in sets) { var c = createSetFact(sets[i]); @@ -106,8 +91,7 @@ Fact { } function saveSettings() { - var fjson = application.prefs.loadFile("signals.json"); - var json = fjson ? JSON.parse(fjson) : {}; + var json = signalsModel.loadJson(); if (!json.active) json.active = {}; json.active[settingsName] = 0; @@ -120,7 +104,7 @@ Fact { if (setf.active) json.active[settingsName] = i; } - application.prefs.saveFile("signals.json", JSON.stringify(json, ' ', 2)); + signalsModel.saveJson(json); accepted(); close(); } @@ -132,7 +116,7 @@ Fact { return null; } var c = component.createObject(setsFact, { - "title": setData.title ? setData.title : "set", + "title": setData.title ? setData.title : qsTr("set"), "pages": setData.pages ? setData.pages : [] }); c.parentFact = setsFact; @@ -148,143 +132,12 @@ Fact { // Legacy migration: old flat {page, signalas} → {active, sets} function migrateLegacy(oldJson) { - var items = oldJson.signalas ? oldJson.signalas : []; - var mappedItems = items.map(function(it) { - return { - bind: it.bind || it.name || "", - title: it.title || "", - color: it.color || "", - filters: [], - warn: it.warn || "", - alarm: it.alarm || "", - act: it.act || "", - save: it.save || "" - }; - }).filter(function(it) { return it.bind !== ""; }); - - return { - active: { signals: 0 }, - sets: [{ - title: "default", - pages: [{ - name: oldJson.page || "page 1", - pin: false, - speed: 1.0, - items: mappedItems - }] - }] - }; + return signalsModel.migrateLegacy(oldJson); } // Default set matching the hardcoded Signals.qml pages function buildDefaultSet() { - return { - title: "default", - pages: [ - { - name: "R", - pin: false, - speed: 1.0, - items: [ - { bind: "mandala.cmd.att.roll.value", title: "roll cmd" }, - { bind: "mandala.est.att.roll.value", title: "roll" } - ] - }, - { - name: "P", - pin: false, - speed: 1.0, - items: [ - { bind: "mandala.cmd.att.pitch.value", title: "pitch cmd" }, - { bind: "mandala.est.att.pitch.value", title: "pitch" } - ] - }, - { - name: "Y", - pin: false, - speed: 1.0, - items: [ - { bind: "mandala.cmd.pos.bearing.value", title: "bearing cmd" }, - { bind: "mandala.cmd.att.yaw.value", title: "yaw cmd" }, - { bind: "mandala.est.att.yaw.value", title: "yaw" } - ] - }, - { - name: "Axy", - pin: false, - speed: 1.0, - items: [ - { bind: "mandala.est.acc.x.value", title: "Ax" }, - { bind: "mandala.est.acc.y.value", title: "Ay" } - ] - }, - { - name: "Az", - pin: false, - speed: 1.0, - items: [ - { bind: "mandala.est.acc.z.value", title: "Az" } - ] - }, - { - name: "G", - pin: false, - speed: 1.0, - items: [ - { bind: "mandala.est.gyro.x.value", title: "Gx" }, - { bind: "mandala.est.gyro.y.value", title: "Gy" }, - { bind: "mandala.est.gyro.z.value", title: "Gz" } - ] - }, - { - name: "Pt", - pin: false, - speed: 1.0, - items: [ - { bind: "mandala.est.pos.altitude.value", title: "alt" }, - { bind: "mandala.est.pos.vspeed.value", title: "vspd" }, - { bind: "mandala.est.air.airspeed.value", title: "airspeed" } - ] - }, - { - name: "Ctr", - pin: false, - speed: 1.0, - items: [ - { bind: "mandala.ctr.att.ail.value", title: "ail" }, - { bind: "mandala.ctr.att.elv.value", title: "elv" }, - { bind: "mandala.ctr.att.rud.value", title: "rud" }, - { bind: "mandala.ctr.eng.thr.value", title: "thr" }, - { bind: "mandala.ctr.eng.prop.value", title: "prop" }, - { bind: "mandala.ctr.str.rud.value", title: "str.rud" } - ] - }, - { - name: "RC", - pin: false, - speed: 1.0, - items: [ - { bind: "mandala.cmd.rc.roll.value", title: "RC roll" }, - { bind: "mandala.cmd.rc.pitch.value", title: "RC pitch" }, - { bind: "mandala.cmd.rc.thr.value", title: "RC thr" }, - { bind: "mandala.cmd.rc.yaw.value", title: "RC yaw" } - ] - }, - { - name: "Usr", - pin: false, - speed: 1.0, - items: [ - { bind: "mandala.est.usr.u1.value", title: "u1" }, - { bind: "mandala.est.usr.u2.value", title: "u2" }, - { bind: "mandala.est.usr.u3.value", title: "u3" }, - { bind: "mandala.est.usr.u4.value", title: "u4" }, - { bind: "mandala.est.usr.u5.value", title: "u5" }, - { bind: "mandala.est.usr.u6.value", title: "u6" } - ] - } - ] - }; + return signalsModel.buildDefaultSet(); } // Actions @@ -302,6 +155,26 @@ Fact { } } + Fact { + title: qsTr("Reset to defaults") + flags: Fact.Action + icon: "restore" + onTriggered: { + var defaultSet = buildDefaultSet(); + var defaultFact = setsFact.size > 0 ? setsFact.child(0) : null; + if (!defaultFact) { + defaultFact = createSetFact(defaultSet); + if (!defaultFact) + return; + defaultFact.selected.connect(select); + defaultFact.selected.connect(saveSettings); + } else { + defaultFact.loadSet(defaultSet); + } + select(0); + } + } + Fact { title: qsTr("Save") flags: (Fact.Action | Fact.Apply) diff --git a/src/Plugins/Tools/Signals/SignalsModel.qml b/src/Plugins/Tools/Signals/SignalsModel.qml new file mode 100644 index 000000000..6411b76f7 --- /dev/null +++ b/src/Plugins/Tools/Signals/SignalsModel.qml @@ -0,0 +1,170 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick + +QtObject { + id: signalsModel + + property string settingsName: "signals" + property string fileName: "signals.json" + + function loadRawJson() { + var fileData = application.prefs.loadFile(fileName); + return fileData ? JSON.parse(fileData) : {}; + } + + function saveJson(json) { + application.prefs.saveFile(fileName, JSON.stringify(json, " ", 2)); + } + + function clone(value) { + return JSON.parse(JSON.stringify(value)); + } + + function normalizeJson(json) { + var normalized = json && typeof json === "object" ? clone(json) : {}; + + if (normalized.signalas && !normalized.sets) + normalized = migrateLegacy(normalized); + + if (!normalized.active) + normalized.active = {}; + + var sets = Array.isArray(normalized.sets) ? normalized.sets.filter(function(setData) { + return !!setData; + }) : []; + if (sets.length === 0) + sets = [buildDefaultSet()]; + + normalized.sets = sets; + + var idx = normalized.active[settingsName]; + if (idx === undefined || idx < 0 || idx >= sets.length) + normalized.active[settingsName] = 0; + + return normalized; + } + + function loadJson() { + return normalizeJson(loadRawJson()); + } + + function activeIndex(json) { + var normalized = normalizeJson(json); + return normalized.active[settingsName]; + } + + function activeSet(json) { + var normalized = normalizeJson(json); + return normalized.sets[normalized.active[settingsName]]; + } + + function migrateLegacy(oldJson) { + var items = (oldJson.signalas || []) + .map(function(it) { + return { + bind: it.bind || it.name || "", + title: it.title || "", + color: it.color || "", + filters: [], + warning: it.warning || it.warn || "", + alarm: it.alarm || "", + act: it.act || "", + save: it.save || "" + }; + }) + .filter(function(it) { return it.bind !== ""; }); + + return { + active: { signals: 0 }, + sets: [{ + title: qsTr("default"), + pages: [{ + name: oldJson.page || qsTr("page 1"), + pin: false, + speed: 1.0, + items: items + }] + }] + }; + } + + function buildDefaultSet() { + return { + title: qsTr("default"), + pages: [ + { name: "R", pin: false, speed: 1.0, items: [ + { bind: "mandala.cmd.att.roll.value", title: qsTr("roll cmd") }, + { bind: "mandala.est.att.roll.value", title: qsTr("roll") } + ]}, + { name: "P", pin: false, speed: 1.0, items: [ + { bind: "mandala.cmd.att.pitch.value", title: qsTr("pitch cmd") }, + { bind: "mandala.est.att.pitch.value", title: qsTr("pitch") } + ]}, + { name: "Y", pin: false, speed: 1.0, items: [ + { bind: "mandala.cmd.pos.bearing.value", title: qsTr("bearing cmd") }, + { bind: "mandala.cmd.att.yaw.value", title: qsTr("yaw cmd") }, + { bind: "mandala.est.att.yaw.value", title: qsTr("yaw") } + ]}, + { name: "Axy", pin: false, speed: 1.0, items: [ + { bind: "mandala.est.acc.x.value", title: qsTr("Ax") }, + { bind: "mandala.est.acc.y.value", title: qsTr("Ay") } + ]}, + { name: "Az", pin: false, speed: 1.0, items: [ + { bind: "mandala.est.acc.z.value", title: qsTr("Az") } + ]}, + { name: "G", pin: false, speed: 1.0, items: [ + { bind: "mandala.est.gyro.x.value", title: qsTr("Gx") }, + { bind: "mandala.est.gyro.y.value", title: qsTr("Gy") }, + { bind: "mandala.est.gyro.z.value", title: qsTr("Gz") } + ]}, + { name: "Pt", pin: false, speed: 1.0, items: [ + { bind: "mandala.est.pos.altitude.value", title: qsTr("alt") }, + { bind: "mandala.est.pos.vspeed.value", title: qsTr("vspd") }, + { bind: "mandala.est.air.airspeed.value", title: qsTr("airspeed") } + ]}, + { name: "Ctr", pin: false, speed: 1.0, items: [ + { bind: "mandala.ctr.att.ail.value", title: qsTr("ail") }, + { bind: "mandala.ctr.att.elv.value", title: qsTr("elv") }, + { bind: "mandala.ctr.att.rud.value", title: qsTr("rud") }, + { bind: "mandala.ctr.eng.thr.value", title: qsTr("thr") }, + { bind: "mandala.ctr.eng.prop.value", title: qsTr("prop") }, + { bind: "mandala.ctr.str.rud.value", title: qsTr("str.rud") } + ]}, + { name: "RC", pin: false, speed: 1.0, items: [ + { bind: "mandala.cmd.rc.roll.value", title: qsTr("RC roll") }, + { bind: "mandala.cmd.rc.pitch.value", title: qsTr("RC pitch") }, + { bind: "mandala.cmd.rc.thr.value", title: qsTr("RC thr") }, + { bind: "mandala.cmd.rc.yaw.value", title: qsTr("RC yaw") } + ]}, + { name: "Usr", pin: false, speed: 1.0, items: [ + { bind: "mandala.est.usr.u1.value", title: qsTr("u1") }, + { bind: "mandala.est.usr.u2.value", title: qsTr("u2") }, + { bind: "mandala.est.usr.u3.value", title: qsTr("u3") }, + { bind: "mandala.est.usr.u4.value", title: qsTr("u4") }, + { bind: "mandala.est.usr.u5.value", title: qsTr("u5") }, + { bind: "mandala.est.usr.u6.value", title: qsTr("u6") } + ]} + ] + }; + } +} From 7640a479a51053d7ea66ef06f0568efc65cae9ea Mon Sep 17 00:00:00 2001 From: Aliaksei Stratsilatau Date: Thu, 23 Apr 2026 20:39:02 -0400 Subject: [PATCH 5/7] update docs and refactor --- src/Plugins/Tools/Signals/CMakeLists.txt | 23 +- src/Plugins/Tools/Signals/ChartsView.qml | 238 ----- ...gnalsMenuPopup.qml => ColorChoiceFact.qml} | 24 +- src/Plugins/Tools/Signals/ColorChooser.qml | 110 +-- src/Plugins/Tools/Signals/FilterFact.qml | 325 +++++++ src/Plugins/Tools/Signals/FilterItem.qml | 180 ---- .../Tools/Signals/FilterKalmanSimple.qml | 114 --- src/Plugins/Tools/Signals/FilterRegistry.qml | 194 ++++ .../Tools/Signals/FilterRunningAvg.qml | 88 -- src/Plugins/Tools/Signals/MenuColor.qml | 31 +- .../Tools/Signals/MenuFilterItemPage.qml | 100 --- src/Plugins/Tools/Signals/MenuFilters.qml | 192 ---- src/Plugins/Tools/Signals/MenuFiltersPage.qml | 100 --- src/Plugins/Tools/Signals/MenuItem.qml | 746 +++++++++------- src/Plugins/Tools/Signals/MenuPage.qml | 494 +++++++---- src/Plugins/Tools/Signals/MenuSet.qml | 265 ++++-- src/Plugins/Tools/Signals/MenuSetPage.qml | 100 --- src/Plugins/Tools/Signals/MenuSets.qml | 211 +++++ src/Plugins/Tools/Signals/PageButton.qml | 79 +- src/Plugins/Tools/Signals/README.md | 59 +- src/Plugins/Tools/Signals/REFACTOR_NOTES.md | 149 ++++ src/Plugins/Tools/Signals/REFACTOR_PROMPT.md | 371 ++++---- src/Plugins/Tools/Signals/SignalButton.qml | 61 ++ src/Plugins/Tools/Signals/Signals.qml | 830 ++++++++++++------ src/Plugins/Tools/Signals/SignalsMenu.qml | 184 ---- src/Plugins/Tools/Signals/SignalsModel.qml | 770 +++++++++++++--- src/Plugins/Tools/Signals/SignalsPlugin.qml | 4 +- src/Plugins/Tools/Signals/SignalsView.qml | 496 +++++++++++ src/main/qml/Apx/Menu/FactMenu.qml | 7 +- src/main/qml/Apx/Menu/FactMenuPage.qml | 29 +- src/main/qml/Apx/Menu/FactMenuPopup.qml | 3 +- 31 files changed, 4023 insertions(+), 2554 deletions(-) delete mode 100644 src/Plugins/Tools/Signals/ChartsView.qml rename src/Plugins/Tools/Signals/{SignalsMenuPopup.qml => ColorChoiceFact.qml} (68%) create mode 100644 src/Plugins/Tools/Signals/FilterFact.qml delete mode 100644 src/Plugins/Tools/Signals/FilterItem.qml delete mode 100644 src/Plugins/Tools/Signals/FilterKalmanSimple.qml create mode 100644 src/Plugins/Tools/Signals/FilterRegistry.qml delete mode 100644 src/Plugins/Tools/Signals/FilterRunningAvg.qml delete mode 100644 src/Plugins/Tools/Signals/MenuFilterItemPage.qml delete mode 100644 src/Plugins/Tools/Signals/MenuFilters.qml delete mode 100644 src/Plugins/Tools/Signals/MenuFiltersPage.qml delete mode 100644 src/Plugins/Tools/Signals/MenuSetPage.qml create mode 100644 src/Plugins/Tools/Signals/MenuSets.qml create mode 100644 src/Plugins/Tools/Signals/REFACTOR_NOTES.md create mode 100644 src/Plugins/Tools/Signals/SignalButton.qml delete mode 100644 src/Plugins/Tools/Signals/SignalsMenu.qml create mode 100644 src/Plugins/Tools/Signals/SignalsView.qml diff --git a/src/Plugins/Tools/Signals/CMakeLists.txt b/src/Plugins/Tools/Signals/CMakeLists.txt index 992c95555..93d92ac82 100644 --- a/src/Plugins/Tools/Signals/CMakeLists.txt +++ b/src/Plugins/Tools/Signals/CMakeLists.txt @@ -1,25 +1,20 @@ -set(SIGNALS_QML - ChartsView.qml +set(QRC_QML + ColorChoiceFact.qml ColorChooser.qml - FilterItem.qml - FilterKalmanSimple.qml - FilterRunningAvg.qml - MenuColor.qml - MenuFilterItemPage.qml - MenuFilters.qml - MenuFiltersPage.qml + FilterFact.qml + FilterRegistry.qml MenuItem.qml MenuPage.qml MenuSet.qml - MenuSetPage.qml + MenuSets.qml PageButton.qml + SignalButton.qml Signals.qml SignalsModel.qml - SignalsMenu.qml - SignalsMenuPopup.qml SignalsPlugin.qml + SignalsView.qml ) -apx_plugin(SRCS ${SIGNALS_QML}) +apx_plugin(SRCS ${QRC_QML}) -apx_qrc(${MODULE} PREFIX ${MODULE} SRCS ${SIGNALS_QML}) +apx_qrc(${MODULE} PREFIX ${MODULE} SRCS ${QRC_QML}) diff --git a/src/Plugins/Tools/Signals/ChartsView.qml b/src/Plugins/Tools/Signals/ChartsView.qml deleted file mode 100644 index 15bebb478..000000000 --- a/src/Plugins/Tools/Signals/ChartsView.qml +++ /dev/null @@ -1,238 +0,0 @@ -/* - * APX Autopilot project - * - * Copyright (c) 2003-2020, Aliaksei Stratsilatau - * All rights reserved - * - * This file is part of APX Ground Control. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program. If not, see . - */ -import QtQuick -import QtCharts -import QtQuick.Controls -import QtQml - -Item { - id: chartItem - - property var facts: [] - - property bool openGL: false - property bool smoothLines: ui.smooth - - property real lineWidth: ui.antialiasing ? 1.5 : 1 - property real lineWidthCmd: ui.antialiasing ? 2.1 : 2 - - property var speedFactor: [0.2, 0.5, 1, 2, 4] - property real speedFactorValue: 1 - - property bool resetEnable: false - - onFactsChanged: if (resetEnable) { - chartView.reset(); - resetEnable = false; - } - - Connections { - target: apx.fleet.current.mandala - function onTelemetryDecoded() { - // Defer so MenuItem.updateValue() (triggered by the same signal - // via Signals.qml) has a chance to write its filtered value first. - Qt.callLater(chartView.appendData); - } - } - - function updateSeriesColor() { - for (var i = 0; i < facts.length; ++i) { - if (!chartView.series(i)) - continue; - if (!facts || !facts[i] || !facts[i].opts) - continue; - if (chartView.series(i).color !== facts[i].opts.color) - chartView.series(i).color = facts[i].opts.color; - } - } - - function cycleSpeed() { - var idx = speedFactor.indexOf(speedFactorValue); - if (idx < 0 || idx >= speedFactor.length - 1) - speedFactorValue = speedFactor[0]; - else - speedFactorValue = speedFactor[idx + 1]; - } - - ChartView { - id: chartView - - antialiasing: ui.antialiasing - legend.visible: false - margins.top: 0 - margins.left: 0 - margins.bottom: 0 - margins.right: 0 - - anchors.fill: parent - property int margin: -8 - anchors.topMargin: margin - anchors.bottomMargin: margin - anchors.leftMargin: margin - anchors.rightMargin: margin - - plotAreaColor: "black" - backgroundColor: "black" - backgroundRoundness: 0 - dropShadowEnabled: false - - property int samples: Math.min(1000, Math.max(25, width / (3 * speedFactorValue))) - property int time: 0 - property bool dataExist: false - - ValueAxis { - id: axisX - property real t: chartView.time - Behavior on t { - enabled: ui.smooth && chartView.dataExist - NumberAnimation { - duration: 500 - } - } - min: t - chartView.samples + 20 - max: t - visible: false - gridVisible: false - labelsVisible: false - lineVisible: false - shadesVisible: false - titleVisible: false - } - ValueAxis { - id: axisY - min: -0 - max: 0 - tickCount: 4 - labelsColor: "white" - labelsFont.pixelSize: Qt.application.font.pixelSize * 0.7 - gridLineColor: "#555" - } - - property real dataPadding: 0.05 - property real dataPaddingZero: 0.05 - property var sdata: [] - property int timeRescale: 0 - - function reset() { - chartView.removeAllSeries(); - chartView.sdata = []; - chartView.time = 0; - axisY.min = -dataPaddingZero; - axisY.max = dataPaddingZero; - axisY.tickCount = 4; - axisY.applyNiceNumbers(); - } - - function appendData() { - var t = time + 1; - for (var i = 0; i < facts.length; ++i) { - appendDataValue(facts[i], t, i); - } - // Calc scale - reduce - if ((t - timeRescale) > 21) { - timeRescale = t; - var d = sdata.length - samples * facts.length; - if (d > 0) - sdata.splice(0, d); - var p = apx.seriesBounds(sdata); - var min = p.x - dataPadding; - var max = p.y + dataPadding; - if (min === max) { - min -= dataPaddingZero; - max += dataPaddingZero; - } - var bmod = false; - if (axisY.min < min) { - axisY.min = min; - bmod = true; - } - if (axisY.max > max) { - axisY.max = max; - bmod = true; - } - if (bmod) { - axisY.tickCount = 4; - axisY.applyNiceNumbers(); - } - } - time = t; - dataExist = true; - } - - function appendDataValue(fact, t, i) { - if (i >= chartView.count) - addFactSeries(fact); - var s = chartView.series(i); - - // MenuItem objects expose currentValue (filtered); plain mandala Fact - // objects expose value. Fall back to eval(name) for legacy paths. - var value; - if (fact.currentValue !== undefined) - value = fact.currentValue; - else if (fact.value !== undefined) - value = fact.value; - else - value = eval(fact.name); - - if (!isFinite(value)) - value = 0; - s.append(t, value); - sdata.push(value); - // Instant rescale - grow - if (axisY.max < value) { - axisY.max = value + dataPadding; - } - if (axisY.min > value) { - axisY.min = value - dataPadding; - } - // Remove old - var cnt = samples; - if (s.count > cnt) - s.removePoints(0, s.count - cnt); - } - - function addFactSeries(fact) { - var s = chartView.createSeries(ChartView.SeriesTypeLine, fact.title, axisX, axisY); - s.useOpenGL = Qt.binding(function () { - return openGL; - }); - s.capStyle = Qt.RoundCap; - - var color = fact.opts ? fact.opts.color : undefined; - if (!color) - color = Qt.rgba(1, 1, 1, 1); - - if (fact.name && fact.name.startsWith("cmd")) { - s.width = Qt.binding(function () { - return lineWidthCmd; - }); - s.color = Qt.hsla(color.hslHue, color.hslSaturation / 2, color.hslLightness * 1.2, 1); - } else { - s.width = Qt.binding(function () { - return lineWidth; - }); - s.color = color; - } - return s; - } - } -} diff --git a/src/Plugins/Tools/Signals/SignalsMenuPopup.qml b/src/Plugins/Tools/Signals/ColorChoiceFact.qml similarity index 68% rename from src/Plugins/Tools/Signals/SignalsMenuPopup.qml rename to src/Plugins/Tools/Signals/ColorChoiceFact.qml index 4fa13418a..615ed02ed 100644 --- a/src/Plugins/Tools/Signals/SignalsMenuPopup.qml +++ b/src/Plugins/Tools/Signals/ColorChoiceFact.qml @@ -21,18 +21,22 @@ */ import QtQuick -import Apx.Menu +import APX.Facts -FactMenuPopup { - id: popup - pinned: true +Fact { + id: colorFact - signal accepted() + property bool isColorChoice: true + property var itemOwner: null + property string colorValue: "" - fact: menuFact - SignalsMenu { - id: menuFact - onAccepted: popup.accepted() + flags: Fact.CloseOnTrigger + opts: ({ + "editor": Qt.resolvedUrl("ColorChooser.qml") + }) + + onTriggered: { + if (itemOwner && typeof itemOwner.setColorValue === "function") + itemOwner.setColorValue(colorValue) } - onClosed: menuFact.destroy() } diff --git a/src/Plugins/Tools/Signals/ColorChooser.qml b/src/Plugins/Tools/Signals/ColorChooser.qml index 302effade..47dca8139 100644 --- a/src/Plugins/Tools/Signals/ColorChooser.qml +++ b/src/Plugins/Tools/Signals/ColorChooser.qml @@ -20,86 +20,40 @@ * along with this program. If not, see . */ import QtQuick -import QtQuick.Layouts -import QtQuick.Controls - -import Apx.Common -import APX.Facts - -Item { - id: colorChooser - - property var color: fact.value !== undefined ? fact.value : "#ffffff" - property var space: Style.spacing * 1.2 - - implicitWidth: 280 * ui.scale - implicitHeight: 100 * ui.scale +import QtQuick.Controls.Material + +Rectangle { + id: editor + + readonly property string colorText: fact && fact.colorValue !== undefined + ? String(fact.colorValue).trim().toUpperCase() + : (fact && fact.itemOwner && fact.itemOwner.colorValue !== undefined + ? String(fact.itemOwner.colorValue).trim().toUpperCase() + : "") + readonly property bool selected: fact && fact.itemOwner + && fact.itemOwner.colorValue === colorText + + implicitHeight: factButton.height * 0.6 + implicitWidth: factButton.height * 1.8 + radius: height / 5 + border.width: selected ? 2 : 1 + border.color: selected ? "white" : "#66000000" + color: colorText !== "" ? colorText : "transparent" Rectangle { - id: colorBox - border.width: 0 - color: "#282828" - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: colorPreview.top - - GridLayout { - columns: 12 - anchors.fill: parent - anchors.margins: space - columnSpacing: space - rowSpacing: space - - Repeater { - model: [ - "#ff8a8a", "#ffc0cb", "#eee8aa", "#ffffe0", "#98fb98", "#acbf69", "#add8e6", "#4169e1", "#cf9fff", "#d8bfd8", "#ffebcd", "#ffffff", - "#ff7f50", "#ff69b4", "#ffd580", "#ffff8f", "#00ff00", "#6b8e23", "#87ceeb", "#0000ff", "#da70d6", "#dda0dd", "#deb887", "#d3d3d3", - "#ff4500", "#ff1493", "#ffa500", "#ffff00", "#32cd32", "#556b2f", "#00bfff", "#0000cd", "#bf40bf", "#ee82ee", "#d2691e", "#808080", - "#ff0000", "#dc143c", "#ff8c00", "#ffd700", "#008000", "#3c4d03", "#1e90ff", "#000080", "#800080", "#ba55d3", "#a52a2a", "#000000" - ] - - delegate: Rectangle { - property bool chosen: mouseArea.containsMouse - Layout.fillWidth: true - Layout.fillHeight: true - color: modelData - border.width: 1 - border.color: chosen ? "#b0c4de" : "transparent" - opacity: chosen ? 1 : 0.85 - scale: chosen ? 1.15 : 1.0 - - MouseArea { - id: mouseArea - anchors.fill: parent - hoverEnabled: true - onClicked: fact.value = modelData - } - } - } - } - } - RowLayout { - id: colorPreview - anchors.bottom: parent.bottom - spacing: space - - Rectangle { - color: colorChooser.color - width: 24 * ui.scale - height: 16 * ui.scale - border.width: ui.scale - border.color: "#383838" - Layout.topMargin: space - Layout.leftMargin: space - opacity: 0.85 - } - - Label { - text: colorChooser.color - font.pixelSize: Style.fontSize * 0.7 - Layout.topMargin: space - Layout.alignment: Qt.AlignVCenter + anchors.fill: parent + anchors.margins: 3 + visible: colorText === "" + radius: parent.radius - 1 + color: "transparent" + border.width: 1 + border.color: Material.hintTextColor + + Text { + anchors.centerIn: parent + text: qsTr("A") + font: apx.font_narrow(Math.max(10, parent.height * 0.55)) + color: Material.hintTextColor } } } diff --git a/src/Plugins/Tools/Signals/FilterFact.qml b/src/Plugins/Tools/Signals/FilterFact.qml new file mode 100644 index 000000000..35420410e --- /dev/null +++ b/src/Plugins/Tools/Signals/FilterFact.qml @@ -0,0 +1,325 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick + +import APX.Facts + +Fact { + id: filterFact + + property var data: ({}) + property var filterRegistry: FilterRegistry {} + property bool isFilterItem: true + + property bool initialized: false + property real outputValue: 0 + property real stateValue: 0 + property real covariance: 0.1 + property bool loading: false + + flags: (Fact.Group | Fact.Bool) + icon: "tune" + value: true + + signal removeTriggered() + + Component.onCompleted: { + load(data) + updateTitle() + updateDescr() + } + + function itemEditor() + { + for (var parent = filterFact.parentFact; parent; parent = parent.parentFact) { + if (typeof parent.saveAll === "function" && parent.newItem !== undefined) + return parent + } + + return null + } + + function canSaveAll() + { + var owner = itemEditor() + return owner && !owner.newItem + } + + function saveAll() + { + var owner = itemEditor() + if (owner) + owner.saveAll() + } + + function asNumber(value, fallback) + { + var number = Number(value) + return isFinite(number) ? number : fallback + } + + function clamp(value, minValue, maxValue) + { + return Math.max(minValue, Math.min(maxValue, value)) + } + + function enabledValue() + { + return value ? true : false + } + + function typeValue() + { + var text = String(typeFact.value === undefined || typeFact.value === null + ? typeFact.text + : typeFact.value).trim() + var info = filterRegistry.typeInfo(text) + return info ? info.value : filterRegistry.defaultType() + } + + function isRunningAverage() + { + return typeValue() === "running_avg" + } + + function isKalman() + { + return typeValue() === "kalman_smp" + } + + function runningAvgCoef() + { + return clamp(asNumber(coefFact.value, 0.2), 0.0, 1.0) + } + + function kalmanR() + { + return Math.max(0.0, asNumber(rFact.value, 0.1)) + } + + function kalmanQ() + { + return Math.max(0.0, asNumber(qFact.value, 0.001)) + } + + function load(filterData) + { + var filter = filterRegistry.normalizeFilter(filterData) + if (!filter) + filter = filterRegistry.defaultFilter(filterRegistry.defaultType()) + + loading = true + value = filter.enabled !== false + typeFact.value = filter.type + coefFact.value = filter.coef !== undefined ? filter.coef : 0.2 + rFact.value = filter.r !== undefined ? filter.r : 0.1 + qFact.value = filter.q !== undefined ? filter.q : 0.001 + reset() + loading = false + updateTitle() + updateDescr() + } + + function save() + { + var filter = { + "type": typeValue(), + "enabled": enabledValue() + } + + switch (filter.type) { + case "running_avg": + filter.coef = runningAvgCoef() + break + case "kalman_smp": + filter.r = kalmanR() + filter.q = kalmanQ() + break + } + + return filterRegistry.normalizeFilter(filter) + } + + function reset() + { + initialized = false + outputValue = 0 + stateValue = 0 + covariance = 0.1 + } + + function step(inputValue) + { + var input = asNumber(inputValue, 0) + var type = typeValue() + + if (!enabledValue()) + return input + + switch (type) { + case "running_avg": + if (!initialized) { + outputValue = input + initialized = true + return outputValue + } + + outputValue = outputValue * (1.0 - runningAvgCoef()) + input * runningAvgCoef() + return outputValue + case "kalman_smp": + if (!initialized) { + stateValue = input + covariance = 0.1 + initialized = true + return stateValue + } + + covariance = covariance + kalmanQ() + + var denominator = covariance + kalmanR() + var gain = denominator > 0 ? covariance / denominator : 0 + + stateValue = stateValue + gain * (input - stateValue) + covariance = (1.0 - gain) * covariance + return stateValue + default: + return input + } + } + + function updateTitle() + { + title = filterRegistry.titleForType(typeValue()) + } + + function updateDescr() + { + var parts = [] + + if (!enabledValue()) + parts.push(qsTr("Off")) + + switch (typeValue()) { + case "running_avg": + parts.push(qsTr("Coef") + ": " + Number(runningAvgCoef()).toFixed(2)) + break + case "kalman_smp": + parts.push(qsTr("R") + ": " + Number(kalmanR())) + parts.push(qsTr("Q") + ": " + Number(kalmanQ())) + break + } + + descr = parts.join(", ") + } + + onValueChanged: { + if (!loading) { + reset() + updateDescr() + } + } + + Fact { + id: typeFact + name: "type" + title: qsTr("Type") + descr: qsTr("Filter algorithm") + flags: Fact.Enum + enumStrings: filterRegistry.typeValues() + value: filterRegistry.defaultType() + onValueChanged: { + if (!filterFact.loading) { + filterFact.reset() + filterFact.updateTitle() + filterFact.updateDescr() + } + } + } + + Fact { + id: coefFact + name: "coef" + title: qsTr("Coefficient") + descr: qsTr("Running average blend coefficient") + flags: Fact.Float + visible: filterFact.isRunningAverage() + value: 0.2 + onValueChanged: { + if (!filterFact.loading) { + filterFact.reset() + filterFact.updateDescr() + } + } + } + + Fact { + id: rFact + name: "r" + title: qsTr("R") + descr: qsTr("Measurement noise") + flags: Fact.Float + visible: filterFact.isKalman() + value: 0.1 + onValueChanged: { + if (!filterFact.loading) { + filterFact.reset() + filterFact.updateDescr() + } + } + } + + Fact { + id: qFact + name: "q" + title: qsTr("Q") + descr: qsTr("Process noise") + flags: Fact.Float + visible: filterFact.isKalman() + value: 0.001 + onValueChanged: { + if (!filterFact.loading) { + filterFact.reset() + filterFact.updateDescr() + } + } + } + + Fact { + flags: (Fact.Action | Fact.Apply) + title: qsTr("Save") + descr: qsTr("Save chart changes") + visible: filterFact.canSaveAll() + icon: "check-circle" + onTriggered: filterFact.saveAll() + } + + Fact { + flags: (Fact.Action | Fact.Remove) + title: qsTr("Remove filter") + descr: qsTr("Delete this filter from the stack") + icon: "delete" + onTriggered: { + filterFact.removeTriggered() + filterFact.deleteFact() + } + } +} diff --git a/src/Plugins/Tools/Signals/FilterItem.qml b/src/Plugins/Tools/Signals/FilterItem.qml deleted file mode 100644 index 2b8c226db..000000000 --- a/src/Plugins/Tools/Signals/FilterItem.qml +++ /dev/null @@ -1,180 +0,0 @@ -/* - * APX Autopilot project - * - * Copyright (c) 2003-2020, Aliaksei Stratsilatau - * All rights reserved - * - * This file is part of APX Ground Control. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program. If not, see . - */ -import QtQuick - -import APX.Facts - -Fact { - id: filterItem - - flags: (Fact.Group | Fact.FlatModel) - - property var data: ({}) - property bool changes: false - property var paramsFact: null - - signal removeTriggered - - readonly property var registry: ({ - "running_avg": { - title: qsTr("Running average"), - url: "FilterRunningAvg.qml" - }, - "kalman_smp": { - title: qsTr("Kalman simple"), - url: "FilterKalmanSimple.qml" - } - }) - - Component.onCompleted: { - var opt = opts; - opt.page = "qrc:/Signals/MenuFilterItemPage.qml"; - opts = opt; - load(data); - } - - function filterTitle(typeName) { - var meta = registry[typeName]; - return meta ? meta.title : typeName; - } - - function createParamsFact(typeName) { - if (paramsFact) - paramsFact.deleteFact(); - - var meta = registry[typeName] || registry.running_avg; - var component = Qt.createComponent(meta.url); - if (component.status !== Component.Ready) { - console.warn("FilterItem: cannot load " + meta.url + ": " + component.errorString()); - paramsFact = null; - return null; - } - - paramsFact = component.createObject(filterParams, {}); - if (!paramsFact) { - console.warn("FilterItem: failed to create params fact for " + typeName); - return null; - } - paramsFact.parentFact = filterParams; - paramsFact.title = meta.title; - paramsFact.descr = meta.title; - updateDescr(); - return paramsFact; - } - - function updateDescr() { - var parts = []; - parts.push(filterEnabled.value > 0 ? qsTr("enabled") : qsTr("disabled")); - if (paramsFact && paramsFact.text) - parts.push(paramsFact.text); - descr = parts.join(", "); - title = filterTitle(filterType.text); - } - - function load(filterData) { - data = filterData || {}; - - var typeName = data.type ? data.type : "running_avg"; - var typeIndex = filterType.enumStrings.indexOf(typeName); - filterType.value = typeIndex >= 0 ? typeIndex : 0; - filterEnabled.value = data.enabled === undefined ? true : !!data.enabled; - - createParamsFact(filterType.text); - if (paramsFact && typeof paramsFact.loadFromObject === "function") - paramsFact.loadFromObject(data); - - changes = false; - updateDescr(); - } - - function save() { - var result = { - type: filterType.text, - enabled: filterEnabled.value > 0 - }; - - if (paramsFact && typeof paramsFact.save === "function") { - var params = paramsFact.save(); - for (var key in params) - result[key] = params[key]; - } - - data = result; - changes = false; - updateDescr(); - return result; - } - - function applyFilter(input) { - if (!(filterEnabled.value > 0) || !paramsFact || typeof paramsFact.filterValue !== "function") - return input; - return paramsFact.filterValue(input); - } - - function resetState() { - if (paramsFact && typeof paramsFact.resetState === "function") - paramsFact.resetState(); - } - - Fact { - id: filterType - name: "type" - title: qsTr("Type") - descr: qsTr("Filter type") - flags: Fact.Enum - enumStrings: ["running_avg", "kalman_smp"] - onValueChanged: { - createParamsFact(text); - changes = true; - updateDescr(); - } - } - - Fact { - id: filterEnabled - name: "enabled" - title: qsTr("Enabled") - descr: qsTr("Enable this filter in the chain") - flags: Fact.Bool - value: true - onValueChanged: { - changes = true; - updateDescr(); - } - } - - Fact { - id: filterParams - title: qsTr("Parameters") - flags: (Fact.Group | Fact.Section) - } - - Fact { - flags: (Fact.Action | Fact.Remove) - title: qsTr("Remove filter") - icon: "delete" - onTriggered: { - removeTriggered(); - filterItem.deleteFact(); - } - } -} diff --git a/src/Plugins/Tools/Signals/FilterKalmanSimple.qml b/src/Plugins/Tools/Signals/FilterKalmanSimple.qml deleted file mode 100644 index 1b211b81a..000000000 --- a/src/Plugins/Tools/Signals/FilterKalmanSimple.qml +++ /dev/null @@ -1,114 +0,0 @@ -/* - * APX Autopilot project - * - * Copyright (c) 2003-2020, Aliaksei Stratsilatau - * All rights reserved - * - * This file is part of APX Ground Control. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program. If not, see . - */ -import QtQuick - -import APX.Facts - -Fact { - id: ksFilter - - flags: Fact.Group - - property bool changes: false - property real q: 1 - property real r: 1 - property var data: ({}) - - // Kalman state - property real kState: 0 - property real kCovariance: 0.1 - property bool initialized: false - - function filterValue(input) { - if (!initialized) { - kState = input; - kCovariance = 0.1; - initialized = true; - } - // Time update - prediction - var x0 = kState; - var p0 = kCovariance + q; - - // Measurement update - correction - var k = p0 / (p0 + r); - kState = x0 + k * (input - x0); - kCovariance = (1 - k) * p0; - return kState; - } - - function resetState() { - initialized = false; - } - - function loadFromObject(obj) { - data = obj || {}; - ksMeasNoise.value = data.r !== undefined ? data.r : (data.measurement_noise !== undefined ? data.measurement_noise : 1); - ksEnvNoise.value = data.q !== undefined ? data.q : (data.environment_noise !== undefined ? data.environment_noise : 1); - updateCoefs(); - } - - function updateFilterValue() { - ksFilter.value = "Km=" + ksMeasNoise.value + ",Ke=" + ksEnvNoise.value; - changes = true; - } - - function updateCoefs() { - r = ksMeasNoise.value; - q = ksEnvNoise.value; - changes = false; - } - - function save() { - updateCoefs(); - data = { - r: ksMeasNoise.value, - q: ksEnvNoise.value - }; - return data; - } - - Fact { - id: ksMeasNoise - name: "r" - title: qsTr("Measurement noise") - descr: qsTr("Coefficient of measurement noise") - flags: Fact.Float - value: 1 - min: 0 - max: 10000 - precision: 3 - onValueChanged: updateFilterValue() - } - Fact { - id: ksEnvNoise - name: "q" - title: qsTr("Environment noise") - descr: qsTr("Coefficient of environment noise") - flags: Fact.Float - value: 1 - min: 0 - max: 10000 - precision: 3 - onValueChanged: updateFilterValue() - } -} - diff --git a/src/Plugins/Tools/Signals/FilterRegistry.qml b/src/Plugins/Tools/Signals/FilterRegistry.qml new file mode 100644 index 000000000..609ffb9ce --- /dev/null +++ b/src/Plugins/Tools/Signals/FilterRegistry.qml @@ -0,0 +1,194 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQml + +QtObject { + id: registry + + readonly property string genericFilterSource: Qt.resolvedUrl("FilterFact.qml") + + readonly property var typeOptions: [ + { + "title": qsTr("Running average"), + "value": "running_avg", + "source": genericFilterSource + }, + { + "title": qsTr("Simple Kalman"), + "value": "kalman_smp", + "source": genericFilterSource + } + ] + + function cloneValue(value) + { + if (value === undefined || value === null) + return value + + return JSON.parse(JSON.stringify(value)) + } + + function asObject(value) + { + if (!value || value instanceof Array || typeof value !== "object") + return {} + + return value + } + + function asString(value, fallback) + { + if (fallback === undefined) + fallback = "" + + if (value === undefined || value === null) + return fallback + + return String(value) + } + + function asBool(value, fallback) + { + if (value === undefined || value === null) + return fallback + + return !!value + } + + function asNumber(value, fallback) + { + var number = Number(value) + return isFinite(number) ? number : fallback + } + + function clamp(value, minValue, maxValue) + { + return Math.max(minValue, Math.min(maxValue, value)) + } + + function typeInfo(type) + { + for (var i = 0; i < typeOptions.length; ++i) { + if (typeOptions[i].value === type) + return typeOptions[i] + } + + return null + } + + function typeIndex(type) + { + for (var i = 0; i < typeOptions.length; ++i) { + if (typeOptions[i].value === type) + return i + } + + return 0 + } + + function titleForType(type) + { + var info = typeInfo(type) + return info ? info.title : asString(type, "") + } + + function componentSource(type) + { + var info = typeInfo(type) + return info ? info.source : "" + } + + function defaultType() + { + return typeOptions.length > 0 ? typeOptions[0].value : "" + } + + function typeValues() + { + var values = [] + + for (var i = 0; i < typeOptions.length; ++i) + values.push(typeOptions[i].value) + + return values + } + + function defaultFilter(type) + { + switch (type) { + case "kalman_smp": + return { + "type": "kalman_smp", + "enabled": true, + "r": 0.1, + "q": 0.001 + } + case "running_avg": + return { + "type": "running_avg", + "enabled": true, + "coef": 0.2 + } + default: + return null + } + } + + function normalizeFilter(filterData) + { + var source = asObject(filterData) + var type = asString(source.type, "") + var filter = defaultFilter(type) + + if (!filter) + return null + + filter = cloneValue(filter) + filter.enabled = asBool(source.enabled, true) + + switch (filter.type) { + case "running_avg": + filter.coef = clamp(asNumber(source.coef, filter.coef), 0.0, 1.0) + break + case "kalman_smp": + filter.r = Math.max(0.0, asNumber(source.r, filter.r)) + filter.q = Math.max(0.0, asNumber(source.q, filter.q)) + break + } + + return filter + } + + function normalizeFilters(filtersData) + { + var source = filtersData instanceof Array ? filtersData : [] + var list = [] + + for (var i = 0; i < source.length; ++i) { + var filter = normalizeFilter(source[i]) + if (filter) + list.push(filter) + } + + return list + } +} diff --git a/src/Plugins/Tools/Signals/FilterRunningAvg.qml b/src/Plugins/Tools/Signals/FilterRunningAvg.qml deleted file mode 100644 index db613d2f9..000000000 --- a/src/Plugins/Tools/Signals/FilterRunningAvg.qml +++ /dev/null @@ -1,88 +0,0 @@ -/* - * APX Autopilot project - * - * Copyright (c) 2003-2020, Aliaksei Stratsilatau - * All rights reserved - * - * This file is part of APX Ground Control. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program. If not, see . - */ -import QtQuick - -import APX.Facts - -Fact { - id: raFilter - - flags: Fact.Group - - property bool changes: false - property var data: ({}) - property var coef: 0.5 - - function applyFilter(v) { - return coef_value + (v - coef_value) * coef; - } - - property real coef_value: 0 - property bool initialized: false - - function filterValue(input) { - if (!initialized) { - coef_value = input; - initialized = true; - } - coef_value = coef_value + (input - coef_value) * coef; - return coef_value; - } - - function resetState() { - initialized = false; - } - - function loadFromObject(obj) { - data = obj || {}; - raCoef.value = data.coef !== undefined ? data.coef : (data.coefficient !== undefined ? data.coefficient : 0.5); - updateCoef(); - } - - function save() { - updateCoef(); - data = { coef: raCoef.value }; - return data; - } - - function updateCoef() { - coef = raCoef.value; - changes = false; - } - - Fact { - id: raCoef - name: "coef" - title: qsTr("Coefficient") - descr: qsTr("Coefficient for filtration") - flags: Fact.Float - value: 0.5 - min: 0 - max: 1 - precision: 3 - onValueChanged: { - raFilter.value = "K=" + value; - changes = true; - } - } -} - diff --git a/src/Plugins/Tools/Signals/MenuColor.qml b/src/Plugins/Tools/Signals/MenuColor.qml index f5ec38151..4985ed648 100644 --- a/src/Plugins/Tools/Signals/MenuColor.qml +++ b/src/Plugins/Tools/Signals/MenuColor.qml @@ -20,21 +20,28 @@ * along with this program. If not, see . */ import QtQuick +import QtQuick.Controls +import QtQuick.Layouts -import APX.Facts +import Apx.Common -Fact { - value: "#ffffff" +TextButton { + Layout.fillHeight: true + checkable: true + ButtonGroup.group: buttonGroup - property bool changes: false + property var values: [] + onActivated: signals.facts=Qt.binding(function(){return values}) - onValueChanged: changes = true - onChangesChanged: { if (changes) menuItem.changes = true; } - Component.onCompleted: { - var opt = opts; - opt.page = "qrc:/Signals/ColorChooser.qml"; - opts = opt; - } + toolTip: getToolTip(values) + function getToolTip(facts) + { + var s=[] + for(var i=0;i"+fact.descr+"") + } + return s.join("
") + } } - diff --git a/src/Plugins/Tools/Signals/MenuFilterItemPage.qml b/src/Plugins/Tools/Signals/MenuFilterItemPage.qml deleted file mode 100644 index b0da17efa..000000000 --- a/src/Plugins/Tools/Signals/MenuFilterItemPage.qml +++ /dev/null @@ -1,100 +0,0 @@ -/* - * APX Autopilot project - * - * Copyright (c) 2003-2020, Aliaksei Stratsilatau - * All rights reserved - * - * This file is part of APX Ground Control. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program. If not, see . - */ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts - -import APX.Facts - -import Apx.Common -import Apx.Menu - -Item { - id: root - - property var pageFact: fact ? fact : null - property var paramsFact: pageFact ? pageFact.child(2) : null - - function triggerFact(itemFact) { - if (!itemFact) - return; - if (itemFact.treeType === Fact.Action || itemFact.dataType === Fact.Apply || itemFact.dataType === Fact.Remove || itemFact.dataType === Fact.Stop) { - itemFact.trigger(); - return; - } - menuPage.factButtonTriggered(itemFact); - } - - clip: true - - ColumnLayout { - anchors.fill: parent - spacing: Style.spacing - - FactButton { - Layout.fillWidth: true - visible: root.pageFact !== null - fact: root.pageFact ? root.pageFact.child(0) : null - noFactTrigger: true - onTriggered: root.triggerFact(fact) - } - - FactButton { - Layout.fillWidth: true - visible: root.pageFact !== null - fact: root.pageFact ? root.pageFact.child(1) : null - noFactTrigger: true - onTriggered: root.triggerFact(fact) - } - - ListView { - id: listView - Layout.fillWidth: true - Layout.fillHeight: true - clip: true - spacing: 0 - model: root.paramsFact ? root.paramsFact.model : null - - delegate: Loader { - active: modelData ? modelData.visible : false - visible: active - width: listView.width - height: active ? MenuStyle.itemSize : 0 - sourceComponent: Component { - FactButton { - fact: modelData ? modelData : null - noFactTrigger: true - size: MenuStyle.itemSize - onTriggered: { - listView.currentIndex = index; - root.triggerFact(fact); - } - } - } - } - - ScrollBar.vertical: ScrollBar { - policy: ScrollBar.AsNeeded - } - } - } -} diff --git a/src/Plugins/Tools/Signals/MenuFilters.qml b/src/Plugins/Tools/Signals/MenuFilters.qml deleted file mode 100644 index 1b7cd96e0..000000000 --- a/src/Plugins/Tools/Signals/MenuFilters.qml +++ /dev/null @@ -1,192 +0,0 @@ -/* - * APX Autopilot project - * - * Copyright (c) 2003-2020, Aliaksei Stratsilatau - * All rights reserved - * - * This file is part of APX Ground Control. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program. If not, see . - */ -import QtQuick - -import APX.Facts - -// Ordered filter chain for a single chart item. -// To add a new filter type: -// 1. Create FilterMyType.qml with loadFromObject()/save()/filterValue()/resetState() -// 2. Register the type in FilterItem.qml registry and enumStrings -// 3. The chain will pick it up automatically -Fact { - id: menuFilters - - property bool changes: false - property var data: [] - - property var newFilterTypeFact: newFilterType - property var addFilterFact: addFilterAction - property var filtersFact: filterValues - - Component.onCompleted: { - var opt = opts; - opt.page = "qrc:/Signals/MenuFiltersPage.qml"; - opts = opt; - } - - onChangesChanged: { if (changes) menuItem.changes = true; } - - function createFilter(filterData) { - var component = Qt.createComponent("FilterItem.qml"); - if (component.status !== Component.Ready) { - console.warn("MenuFilters: cannot load FilterItem.qml: " + component.errorString()); - return null; - } - - var filterFact = component.createObject(filterValues, { - "data": filterData || { - type: "running_avg", - enabled: true - } - }); - if (!filterFact) { - console.warn("MenuFilters: failed to create FilterItem instance"); - return null; - } - filterFact.parentFact = filterValues; - filterFact.removeTriggered.connect(function() { - updateDescr(); - changes = true; - }); - filterFact.titleChanged.connect(updateDescr); - updateDescr(); - return filterFact; - } - - function resetFilterState() { - for (var i = 0; i < filterValues.size; ++i) - filterValues.child(i).resetState(); - } - - function applyFilters(v) { - var result = v; - for (var i = 0; i < filterValues.size; ++i) - result = filterValues.child(i).applyFilter(result); - return result; - } - - function normalizeData(rawData) { - if (Array.isArray(rawData)) - return rawData; - - if (rawData && typeof rawData === "object") { - if (rawData.type) - return [rawData]; - - if (rawData.filters && Array.isArray(rawData.filters)) - return rawData.filters; - - // Legacy single-selector shape - var legacyType = rawData.filters || rawData.filterType; - if (legacyType === "running_avg") - return [{ - type: "running_avg", - enabled: true, - coef: rawData.running_avg || rawData.coef || rawData.coefficient || 0.5 - }]; - if (legacyType === "kalman_smp") - return [{ - type: "kalman_smp", - enabled: true, - r: rawData.measurement_noise !== undefined ? rawData.measurement_noise : (rawData.r !== undefined ? rawData.r : 1), - q: rawData.environment_noise !== undefined ? rawData.environment_noise : (rawData.q !== undefined ? rawData.q : 1) - }]; - } - - return []; - } - - function load() { - filterValues.deleteChildren(); - var list = normalizeData(data); - for (var i = 0; i < list.length; ++i) - createFilter(list[i]); - menuFilters.value = list; - changes = false; - updateDescr(); - } - - function save() { - data = []; - for (var i = 0; i < filterValues.size; ++i) - data.push(filterValues.child(i).save()); - menuFilters.value = data; - changes = false; - updateDescr(); - return data; - } - - function updateDescr() { - var labels = []; - for (var i = 0; i < filterValues.size; ++i) - labels.push(filterValues.child(i).title); - descr = labels.length > 0 ? labels.join(", ") : qsTr("No filters"); - } - - function fillData() { - if (value !== undefined && value !== null) { - data = value; - load(); - } - } - - Fact { - id: newFilterType - name: "new_filter_type" - title: qsTr("New filter type") - descr: qsTr("Filter type to append to the chain") - flags: Fact.Enum - enumStrings: ["running_avg", "kalman_smp"] - } - - Fact { - id: addFilterAction - title: qsTr("Add filter") - descr: qsTr("Append a new filter to the chain") - flags: Fact.Action - icon: "plus-circle" - onTriggered: { - createFilter({ - type: newFilterType.text, - enabled: true - }); - changes = true; - } - } - - Fact { - id: filterValues - title: qsTr("Filters") - descr: qsTr("Ordered filter chain") - flags: (Fact.Group | Fact.Section | Fact.DragChildren) - onSizeChanged: { - updateDescr(); - changes = true; - } - onItemMoved: { - updateDescr(); - changes = true; - } - } -} - diff --git a/src/Plugins/Tools/Signals/MenuFiltersPage.qml b/src/Plugins/Tools/Signals/MenuFiltersPage.qml deleted file mode 100644 index cbffb7cf8..000000000 --- a/src/Plugins/Tools/Signals/MenuFiltersPage.qml +++ /dev/null @@ -1,100 +0,0 @@ -/* - * APX Autopilot project - * - * Copyright (c) 2003-2020, Aliaksei Stratsilatau - * All rights reserved - * - * This file is part of APX Ground Control. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program. If not, see . - */ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts - -import APX.Facts - -import Apx.Common -import Apx.Menu - -Item { - id: root - - property var pageFact: fact ? fact : null - property var listFact: pageFact ? pageFact.filtersFact : null - - function triggerFact(itemFact) { - if (!itemFact) - return; - if (itemFact.treeType === Fact.Action || itemFact.dataType === Fact.Apply || itemFact.dataType === Fact.Remove || itemFact.dataType === Fact.Stop) { - itemFact.trigger(); - return; - } - menuPage.factButtonTriggered(itemFact); - } - - clip: true - - ColumnLayout { - anchors.fill: parent - spacing: Style.spacing - - FactButton { - Layout.fillWidth: true - visible: root.pageFact !== null - fact: root.pageFact ? root.pageFact.newFilterTypeFact : null - noFactTrigger: true - onTriggered: root.triggerFact(fact) - } - - FactButton { - Layout.fillWidth: true - visible: root.pageFact !== null - fact: root.pageFact ? root.pageFact.addFilterFact : null - noFactTrigger: true - onTriggered: root.triggerFact(fact) - } - - ListView { - id: listView - Layout.fillWidth: true - Layout.fillHeight: true - clip: true - spacing: 0 - model: root.listFact ? root.listFact.model : null - - delegate: Loader { - active: modelData ? modelData.visible : false - visible: active - width: listView.width - height: active ? MenuStyle.itemSize : 0 - sourceComponent: Component { - FactButton { - fact: modelData ? modelData : null - noFactTrigger: true - size: MenuStyle.itemSize - onTriggered: { - listView.currentIndex = index; - root.triggerFact(fact); - } - } - } - } - - ScrollBar.vertical: ScrollBar { - policy: ScrollBar.AsNeeded - } - } - } -} diff --git a/src/Plugins/Tools/Signals/MenuItem.qml b/src/Plugins/Tools/Signals/MenuItem.qml index 73530980c..a75721018 100644 --- a/src/Plugins/Tools/Signals/MenuItem.qml +++ b/src/Plugins/Tools/Signals/MenuItem.qml @@ -23,368 +23,528 @@ import QtQuick import QtQuick.Controls.Material import APX.Facts -import Apx.Common Fact { - id: menuItem + id: itemFact - flags: Fact.Group - precision: 2 - icon: "rectangle" - - property bool changes: false property bool newItem: false property var data: ({}) + property var signalsModel: null + property var setFact: null + property var pageFact: null + + property string colorValue: "" + property var filtersData: [] + property string saveError: "" + property var filterRegistry: FilterRegistry {} + readonly property var colorBaseLabels: [ + qsTr("Red"), + qsTr("Pink"), + qsTr("Purple"), + qsTr("Deep Purple"), + qsTr("Indigo"), + qsTr("Blue"), + qsTr("Light Blue"), + qsTr("Cyan"), + qsTr("Teal"), + qsTr("Green"), + qsTr("Orange"), + qsTr("Blue Grey") + ] + readonly property var colorBaseValues: [ + Material.Red, + Material.Pink, + Material.Purple, + Material.DeepPurple, + Material.Indigo, + Material.Blue, + Material.LightBlue, + Material.Cyan, + Material.Teal, + Material.Green, + Material.Orange, + Material.BlueGrey + ] + readonly property var colorShadeLabels: [ + qsTr("300"), + qsTr("500"), + qsTr("700"), + qsTr("900") + ] + readonly property var colorShadeValues: [ + Material.Shade300, + Material.Shade500, + Material.Shade700, + Material.Shade900 + ] + + flags: Fact.Group - // Runtime computed value (filtered) - property var currentValue: undefined - property string warningMsg: "" - property string alertText: "" - property real _warnTimestamp: 0 - - // Cached compiled functions – rebuilt only when expressions change - property var _bindFn: null - property var _warnFn: null - property var _alarmFn: null - - function _recompileBindFn() { - var expr = mBind.text; - if (!expr || expr === "") { _bindFn = null; return; } - try { - // eval creates a closure in QML scope, giving the compiled function - // access to context properties (mandala, apx, Math, etc.). - // Expressions are user-authored in signals.json — not external input. - _bindFn = eval("(function() { return (" + expr + "); })"); - } catch(e) { _bindFn = null; } - } - function _recompileWarnFn() { - var expr = mWarn.text; - if (!expr || expr === "") { _warnFn = null; return; } - try { - _warnFn = new Function("value", "return !!(" + expr + ")"); - } catch(e) { _warnFn = null; } - } - function _recompileAlarmFn() { - var expr = mAlarm.text; - if (!expr || expr === "") { _alarmFn = null; return; } - try { - _alarmFn = new Function("value", "return !!(" + expr + ")"); - } catch(e) { _alarmFn = null; } - } - - signal addTriggered - signal removeTriggered - - // Warning/alarm state — propagated to PageButton - property bool hasWarning: false - property bool hasAlarm: false - - // Expose for PageButton tooltip - property string itemTitle: mTitle.text ? mTitle.text : mBind.text - property var itemColor: menuItem.opts && menuItem.opts.color ? menuItem.opts.color : Material.color(Material.Blue + menuItem.num * 2) + signal addTriggered() + signal removeTriggered() Component.onCompleted: { - // Wire expression changes to recompile cached functions before loading - // so the initial load triggers them automatically. - mBind.textChanged.connect(_recompileBindFn); - mWarn.textChanged.connect(_recompileWarnFn); - mAlarm.textChanged.connect(_recompileAlarmFn); - - load(data); - - // Explicit recompile after load() in case textChanged did not fire - // (e.g. the Fact text was already equal to the loaded value). - _recompileBindFn(); - _recompileWarnFn(); - _recompileAlarmFn(); - - updateTitle(); - updateDescr(); - mTitle.valueChanged.connect(updateTitle); - mBind.valueChanged.connect(updateDescr); - mColor.valueChanged.connect(function() { updateDescr(); setColor(); }); - mFilters.valueChanged.connect(updateDescr); - mFact2Save.valueChanged.connect(updateDescr); - } - - onCurrentValueChanged: saveValue2Fact() - - function load() { - if (data.filters === undefined && data.filt !== undefined) - data.filters = data.filt; - if (data.warning === undefined && data.warn !== undefined) - data.warning = data.warn; - if (data.title === undefined && data.chartname !== undefined) - data.title = data.chartname; - for (var i = 0; i < menuItem.size; ++i) { - var f = child(i); - if (f.transientFact) - continue; - var v = data[settingName(f)]; - if (v !== undefined) - f.value = v; - } - syncBindSelector(); - mFilters.fillData(); - mColor.value = data.color ? data.color : ""; - changes = false; - setColor(); - } - - function save() { - data = {}; - for (var i = 0; i < menuItem.size; ++i) { - var f = child(i); - if (f.transientFact) - continue; - var s = f.text.trim(); - if (f.size !== 0) - s = f.save(); - if (s === "") - continue; - data[settingName(f)] = s; - } - changes = false; - setColor(); - return data; - } - - function settingName(f) { - var n = f.name; - if (!n || n.startsWith("_")) - return ""; - if (n.includes("_")) - return n.slice(0, n.indexOf("_")); - return n; - } - - function syncBindSelector() { - var expr = mBind.text; - var prefix = "mandala."; - var suffix = ".value"; - if (expr && expr.startsWith(prefix) && expr.endsWith(suffix) && expr.indexOf("(") < 0 && expr.indexOf(" ") < 0) { - mBindFact.value = expr.slice(prefix.length, expr.length - suffix.length); - } else { - mBindFact.value = ""; + load() + rebuildColorChoices() + updateTitle() + updateDescr() + } + + function rootEditor() + { + for (var parent = itemFact.parentFact; parent; parent = parent.parentFact) { + if (typeof parent.saveSettings === "function") + return parent } + + return null } - function updateTitle() { - if (newItem) - return; - title = mTitle.text ? mTitle.text : mBind.text; + function saveAll() + { + var root = rootEditor() + if (root) + root.saveSettings() } - function updateDescr() { - if (newItem) - return; - var descrList = []; - for (var i = 0; i < menuItem.size; ++i) { - var f = child(i); - if (!f.name) - continue; - if (f.transientFact) - continue; - if (f.name === "title") - continue; - var text = f.text; - if ((!text || text === "") && f.descr) - text = f.descr; - if (!text || text === "") - continue; - if (f.name === "color") - descrList.push(f.name.toUpperCase() + ": " + f.text.toUpperCase() + ""); - else - descrList.push(f.name.toUpperCase() + ": " + text); + function createFact(parent, url, opts) + { + var component = Qt.createComponent(Qt.resolvedUrl(url)) + if (component.status === Component.Ready) { + var properties = opts || {} + properties.parentFact = parent + var child = component.createObject(parent, properties) + return child } - descr = descrList.length > 0 ? descrList.join(", ") : ""; - } - - function setColor() { - var opt = menuItem.opts; - opt.color = mColor.value ? mColor.value : Material.color(Material.Blue + menuItem.num * 2); - opt.iconColor = opt.color; - menuItem.opts = opt; - mColor.changes = false; - if (menuPage) - menuPage.updatePageValues(); - if (signalsWidget) - signalsWidget.updateSeriesColors(); - } - - // Telemetry update — computes filtered value and evaluates warn/alarm - function updateValue() { - if (!_bindFn) { - hasWarning = false; - hasAlarm = false; - alertText = ""; - return; + + console.log(component.errorString()) + return null + } + + function cloneValue(value) + { + if (value === undefined || value === null) + return value + return JSON.parse(JSON.stringify(value)) + } + + function normalizeColorValue(value) + { + var text = String(value === undefined || value === null ? "" : value).trim() + if (text === "") + return "" + return text.toUpperCase() + } + + function normalizeBindValue(value) + { + var text = String(value === undefined || value === null ? "" : value).trim() + var simplePath = /^(?:mandala\.)?[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+(?:\.value)?$/ + + if (text === "" || !simplePath.test(text)) + return text + + if (text.startsWith("mandala.")) + text = text.slice(8) + if (text.endsWith(".value")) + text = text.slice(0, -6) + + return text + } + + function factTextValue(factObject) + { + if (!factObject) + return "" + + if (factObject.text !== undefined && factObject.text !== null) + return String(factObject.text).trim() + + if (factObject.value !== undefined && factObject.value !== null) + return String(factObject.value).trim() + + return "" + } + + function bindText() + { + return normalizeBindValue(factTextValue(mBind)) + } + + function saveTarget() + { + return normalizeBindValue(factTextValue(mSaveFact)) + } + + function colorSummary() + { + return colorValue !== "" ? colorValue : qsTr("Auto") + } + + function filterSummary(filterData) + { + var filter = filterData || {} + var label = filterRegistry.titleForType(filter.type) + if (filter.enabled === false) + label += " (" + qsTr("off") + ")" + return label + } + + function clearFilterFacts() + { + if (!mFiltersGroup) + return + + for (var i = mFiltersGroup.size - 1; i >= 0; --i) { + var child = mFiltersGroup.child(i) + if (child && child.isFilterItem) + child.deleteFact() } - var v; - try { - v = _bindFn(); - } catch (e) { - // Fallback: treat as a plain mandala fact path - try { - var _f = apx.fleet.current.mandala.fact(mBind.text, false); - if (_f) v = _f.value; - } catch (e2) {} + } + + function createFilterFact(filterData) + { + var child = createFact(mFiltersGroup, "FilterFact.qml", { + "data": filterData, + "filterRegistry": itemFact.filterRegistry + }) + if (!child) + return null + + child.titleChanged.connect(updateDescr) + child.descrChanged.connect(updateDescr) + child.removeTriggered.connect(updateDescr) + return child + } + + function exportFilters() + { + var list = [] + + if (!mFiltersGroup) + return list + + for (var i = 0; i < mFiltersGroup.size; ++i) { + var child = mFiltersGroup.child(i) + if (!child || !child.isFilterItem || typeof child.save !== "function") + continue + + var filterData = child.save() + if (filterData) + list.push(filterRegistry.normalizeFilter(filterData)) } - if (v === undefined || !isFinite(v)) { - hasWarning = false; - hasAlarm = false; - alertText = ""; - return; + + return list + } + + function currentFilters() + { + var live = exportFilters() + if (live.length > 0) + return live + return filterRegistry.normalizeFilters(cloneValue(filtersData)) + } + + function rebuildFilters() + { + clearFilterFacts() + + var source = filterRegistry.normalizeFilters(cloneValue(filtersData)) + for (var i = 0; i < source.length; ++i) + createFilterFact(source[i]) + } + + function filtersSummary() + { + var list = currentFilters() + if (!(list instanceof Array) || list.length <= 0) + return qsTr("None") + + var parts = [] + for (var i = 0; i < list.length; ++i) + parts.push(filterSummary(list[i])) + return parts.join(", ") + } + + function setColorValue(value) + { + colorValue = normalizeColorValue(value) + updateDescr() + } + + function clearColorChoices() + { + if (!mColorPage) + return + + for (var i = mColorPage.size - 1; i >= 0; --i) { + var child = mColorPage.child(i) + if (child && child.isColorChoice) + child.deleteFact() } + } - // Seed filter state on first valid value - if (currentValue === undefined) - mFilters.resetFilterState(); - - // Run filter chain - var filtered = mFilters.applyFilters(v); - currentValue = filtered; - menuItem.value = filtered; // ChartsView reads fact.value - - // Evaluate warning/alarm using pre-compiled functions - try { hasWarning = _warnFn ? _warnFn(filtered) : false; } catch(e) { hasWarning = false; } - try { hasAlarm = _alarmFn ? _alarmFn(filtered) : false; } catch(e) { hasAlarm = false; } - if (hasAlarm) - alertText = itemTitle + ": " + qsTr("Alarm"); - else if (hasWarning) - alertText = itemTitle + ": " + qsTr("Warning"); - else - alertText = ""; - } - - function saveValue2Fact() { - var fname = mFact2Save.text; - if (!fname || fname === "") - return; - if (!apx.fleet.current.mandala.fact(fname, true)) - return; - if (!fname.includes("sns.scr")) { - emitWarning(qsTr("Unacceptable variable name. Use 'sns.scr' vars for saving!")); - return; + function rebuildColorChoices() + { + clearColorChoices() + + createFact(mColorPage, "ColorChoiceFact.qml", { + "itemOwner": itemFact, + "title": qsTr("Auto"), + "descr": qsTr("Use automatic series color"), + "colorValue": "", + "section": "", + "value": qsTr("Auto") + }) + + for (var shadeIndex = 0; shadeIndex < colorShadeValues.length; ++shadeIndex) { + var shadeLabel = colorShadeLabels[shadeIndex] + for (var colorIndex = 0; colorIndex < colorBaseValues.length; ++colorIndex) { + var colorCode = Material.color(colorBaseValues[colorIndex], + colorShadeValues[shadeIndex]).toString().toUpperCase() + createFact(mColorPage, "ColorChoiceFact.qml", { + "itemOwner": itemFact, + "title": colorBaseLabels[colorIndex], + "descr": colorCode, + "colorValue": colorCode, + "section": shadeLabel, + "value": colorCode + }) + } } - apx.fleet.current.mandala.fact(fname, true).setRawValueLocal(currentValue); } - function emitWarning(msg) { - var now = Date.now(); - if (warningMsg === msg && (now - _warnTimestamp) < 10000) - return; - warningMsg = msg; - _warnTimestamp = now; - console.warn(qsTr("Chart") + " " + title + ": " + msg); + function setFiltersData(value) + { + filtersData = filterRegistry.normalizeFilters(cloneValue(value)) + rebuildFilters() + updateDescr() } - function hasScr(val) { - if (!val || val !== mFact2Save.text) - return false; - emitWarning(val + " " + qsTr("variable already used")); - return true; + function addFilter() + { + var child = createFilterFact(filterRegistry.defaultFilter(filterRegistry.defaultType())) + if (child) + updateDescr() + return child } - Fact { - id: mTitle - name: "title" - title: qsTr("Title") - descr: qsTr("Chart name") - flags: Fact.Text - onTextChanged: changes = true + function validationError() + { + var target = saveTarget() + if (target === "") + return "" + + if (!target.startsWith("sns.scr.")) + return qsTr("Save target must start with sns.scr.") + + if (itemFact.pageFact && typeof itemFact.pageFact.isSaveTargetUsedLocal === "function" + && itemFact.pageFact.isSaveTargetUsedLocal(target, itemFact)) + return qsTr("Save target is already used in this page.") + + if (itemFact.setFact && typeof itemFact.setFact.isSaveTargetUsed === "function" + && itemFact.setFact.isSaveTargetUsed(target, itemFact)) + return qsTr("Save target is already used in this set.") + + return "" } + + function refreshValidation() + { + saveError = validationError() + updateDescr() + } + + function canSave() + { + return bindText() !== "" && validationError() === "" + } + + function load() + { + mBind.value = normalizeBindValue(data && data.bind !== undefined ? data.bind : "") + colorValue = normalizeColorValue(data ? data.color : "") + filtersData = filterRegistry.normalizeFilters(cloneValue(data && data.filters instanceof Array + ? data.filters + : [])) + rebuildFilters() + mWarning.value = data && data.warning !== undefined ? data.warning : "" + mSaveFact.value = normalizeBindValue(data && data.save !== undefined ? data.save : null) + refreshValidation() + } + + function save() + { + if (!canSave()) + return null + + var item = { + "bind": bindText(), + "filters": exportFilters() + } + + if (colorValue !== "") + item.color = colorValue + if (factTextValue(mWarning) !== "") + item.warning = factTextValue(mWarning) + if (saveTarget() !== "") + item.save = saveTarget() + + return item + } + + function updateTitle() + { + if (newItem) + return + + title = bindText() + } + + function updateDescr() + { + var details = [] + if (colorValue !== "") + details.push(qsTr("Color") + ": " + colorValue) + if (currentFilters().length > 0) + details.push(qsTr("Filters") + ": " + filtersSummary()) + if (factTextValue(mWarning) !== "") + details.push(qsTr("Warning") + ": " + factTextValue(mWarning)) + if (saveTarget() !== "") + details.push(qsTr("Save") + ": " + saveTarget()) + if (saveError !== "") + details.push(saveError) + + descr = details.join(", ") + if (mFiltersGroup) + mFiltersGroup.descr = filtersSummary() + } + Fact { - id: mBindFact - name: "_bind_fact" - property bool transientFact: true + id: mFact title: qsTr("Binding") - descr: qsTr("Select a mandala fact and fill the expression automatically") + descr: qsTr("Pick a mandala fact") flags: Fact.Int units: "mandala" onTextChanged: { - if (text && text !== "") - mBind.value = "mandala." + text + ".value"; + if (value) + mBind.setValue(itemFact.normalizeBindValue(text)) } } + Fact { id: mBind name: "bind" title: qsTr("Expression") - descr: qsTr("Example: Math.atan(mandala.est.att.pitch.value / mandala.est.att.roll.value)") + descr: qsTr("Mandala path or JavaScript expression") flags: Fact.Text - onTextChanged: { - syncBindSelector(); - changes = true; + onValueChanged: { + itemFact.updateTitle() + itemFact.updateDescr() } } - MenuColor { - id: mColor - name: "color" + + Fact { + id: mColorPage title: qsTr("Color") - descr: qsTr("Chart color") + descr: qsTr("Series color override") + flags: Fact.Group + value: itemFact.colorSummary() + opts: ({ + "editor": Qt.resolvedUrl("ColorChooser.qml") + }) + property var itemOwner: itemFact } - MenuFilters { - id: mFilters - name: "filters" + + Fact { + id: mFiltersGroup title: qsTr("Filters") - descr: qsTr("Filter settings") + descr: qsTr("Ordered filter stack") + // icon: "tune" + flags: (Fact.Group | Fact.DragChildren) + + Fact { + flags: Fact.Action + title: qsTr("Add filter") + descr: qsTr("Append a new filter to the stack") + icon: "plus-circle" + onTriggered: itemFact.addFilter() + } + + Fact { + flags: (Fact.Action | Fact.Apply) + title: qsTr("Save") + descr: qsTr("Save chart changes") + visible: !itemFact.newItem + icon: "check-circle" + onTriggered: itemFact.saveAll() + } } + Fact { + id: mWarning name: "warning" - id: mWarn title: qsTr("Warning") - descr: qsTr("Expression for warning (receives 'value')") + descr: qsTr("Expression that raises a page warning") flags: Fact.Text - onValueChanged: changes = true - } - Fact { - name: "alarm" - id: mAlarm - title: qsTr("Alarm") - descr: qsTr("Example: value>1.8 || (value>0 && value<1)") - flags: Fact.Text - onValueChanged: changes = true - } - Fact { - name: "act" - title: qsTr("Action") - descr: qsTr("Example: cmd.proc.action=proc_action_reset") - flags: Fact.Text - onValueChanged: changes = true + onValueChanged: itemFact.updateDescr() } + Fact { - id: mFact2Save + id: mSaveFact name: "save" - title: qsTr("Save to") - descr: qsTr("Variable for saving chart value (sns.scr.*)") + title: qsTr("Save target") + descr: qsTr("Pick a mandala fact under sns.scr") flags: Fact.Int units: "mandala" onTextChanged: { - signalsWidget.checkScrMatches(text); - changes = true; + itemFact.refreshValidation() + if (itemFact.setFact && typeof itemFact.setFact.refreshSaveWarnings === "function") + itemFact.setFact.refreshSaveWarnings() } } - // Actions + Fact { + title: qsTr("Save target status") + descr: qsTr("Fix this before saving") + visible: itemFact.saveError !== "" + icon: "alert-circle" + value: itemFact.saveError + } + Fact { flags: (Fact.Action | Fact.Apply) + title: qsTr("Save") + descr: qsTr("Save chart changes") + visible: !itemFact.newItem + icon: "check-circle" + onTriggered: itemFact.saveAll() + } + + Fact { + flags: (Fact.Action | Fact.Apply | Fact.ShowDisabled) title: qsTr("Add") - enabled: newItem && mBind && mBind.value + descr: qsTr("Add this item") + enabled: itemFact.newItem && itemFact.canSave() icon: "plus-circle" onTriggered: { - menuItem.menuBack(); - addTriggered(); + itemFact.menuBack() + itemFact.addTriggered() } } + Fact { flags: (Fact.Action | Fact.Remove) title: qsTr("Remove") - visible: !newItem + descr: qsTr("Remove this item") + visible: !itemFact.newItem icon: "delete" onTriggered: { - removeTriggered(); - menuItem.deleteFact(); + var ownerSet = itemFact.setFact + itemFact.removeTriggered() + itemFact.deleteFact() + if (ownerSet && typeof ownerSet.refreshSaveWarnings === "function") + ownerSet.refreshSaveWarnings() } } } diff --git a/src/Plugins/Tools/Signals/MenuPage.qml b/src/Plugins/Tools/Signals/MenuPage.qml index c900f5739..b4104ab9d 100644 --- a/src/Plugins/Tools/Signals/MenuPage.qml +++ b/src/Plugins/Tools/Signals/MenuPage.qml @@ -22,224 +22,418 @@ import QtQuick import APX.Facts +import "." Fact { - id: menuPage + id: pageFact + + property bool newItem: false + property bool standaloneEditor: false + property var data: ({}) + property var signalsModel: null + property var setFact: null + property real speedValue: 1.0 + property bool loading: false + + property alias itemsFact: pageItems flags: (Fact.Group | Fact.FlatModel) - // Page properties - property bool pinned: false - property real speed: 1.0 + signal addTriggered() + signal accepted(var pageData) + signal removeTriggered() + signal removedStandalone() - // Values list for the chart renderer — array of MenuItem objects - property var values: [] + Component.onCompleted: { + load() + updateTitle() + updateDescr() + } - // Warning/alarm aggregated from items - property bool hasWarning: false - property bool hasAlarm: false - property string warningText: "" + function rootEditor() + { + for (var parent = pageFact.parentFact; parent; parent = parent.parentFact) { + if (typeof parent.saveSettings === "function") + return parent + } - // Set to true when created directly from Signals.qml (page-button flow) - // to show the per-page Save button. False when used inside SignalsMenu popup. - property bool isDirectEdit: false + return null + } - Component.onCompleted: { - pTitle.value = title; + function saveAll() + { + var root = rootEditor() + if (root) + root.saveSettings() + } + + function createFact(parent, url, opts) + { + var component = Qt.createComponent(Qt.resolvedUrl(url)) + if (component.status === Component.Ready) { + var properties = opts || {} + properties.parentFact = parent + var child = component.createObject(parent, properties) + return child + } + + console.log(component.errorString()) + return null + } + + function asNumber(value, fallback) + { + var number = Number(value) + return isFinite(number) ? number : fallback } - function addNewItem() { - mMenuNewItem.trigger(); + function defaultTitle() + { + if (signalsModel && typeof signalsModel.defaultPageTitle === "function") + return pageFact.signalsModel.defaultPageTitle(Math.max(pageFact.num, 0)) + return qsTr("Page") + " " + (Math.max(pageFact.num, 0) + 1) } - // Rebuild the values array from current items - function updatePageValues() { - var list = []; - for (var i = 0; i < mItems.size; ++i) { - var it = mItems.child(i); - list.push(it); + function pageNameFromBind(bind) + { + var text = String(bind).trim() + var simplePath = /^(?:mandala\.)?[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+(?:\.value)?$/ + + if (simplePath.test(text)) { + if (text.startsWith("mandala.")) + text = text.slice(8) + if (text.endsWith(".value")) + text = text.slice(0, -6) } - values = list; - // Rebuild opts colors for chart - updateWarnings(); - } - - function updateWarnings() { - var warn = false; - var alarm = false; - var message = ""; - for (var i = 0; i < mItems.size; ++i) { - var it = mItems.child(i); - if (it.hasWarning) { - warn = true; - if (message === "") - message = it.alertText || it.warningMsg; + + if (text === "") + return "" + + var parts = text.split(".") + var token = parts.length > 0 ? parts[parts.length - 1] : text + if (token.length <= 0) + return "" + + return token.charAt(0).toUpperCase() + } + + function pageName() + { + var text = mName.text.trim() + if (text !== "") + return text + + if (pageItems.size > 0) { + var firstItem = pageItems.child(0) + if (firstItem && typeof firstItem.bindText === "function") { + var guessed = pageNameFromBind(firstItem.bindText()) + if (guessed !== "") + return guessed } - if (it.hasAlarm) { - alarm = true; - if (message === "") - message = it.alertText || it.warningMsg; + } + + return pageFact.defaultTitle() + } + + function speedText() + { + if (signalsModel && typeof signalsModel.formatSpeed === "function") + return signalsModel.formatSpeed(speedValue) + + var text = String(speedValue) + if (text.endsWith(".0")) + text = text.slice(0, -2) + return text + "x" + } + + function speedOptions() + { + var factors = signalsModel && signalsModel.speedFactors instanceof Array + ? signalsModel.speedFactors + : [0.2, 0.5, 1.0, 2.0, 4.0] + var values = [] + + for (var i = 0; i < factors.length; ++i) { + if (signalsModel && typeof signalsModel.formatSpeed === "function") + values.push(signalsModel.formatSpeed(factors[i])) + else { + var text = String(factors[i]) + if (text.endsWith(".0")) + text = text.slice(0, -2) + values.push(text + "x") } } - hasWarning = warn; - hasAlarm = alarm; - warningText = message; + + return values } - function updateChartsValues() { - for (var i = 0; i < mItems.size; ++i) - mItems.child(i).updateValue(); - updateWarnings(); + function speedValueFromText(value) + { + var text = String(value === undefined || value === null ? "" : value).trim() + var factors = signalsModel && signalsModel.speedFactors instanceof Array + ? signalsModel.speedFactors + : [0.2, 0.5, 1.0, 2.0, 4.0] + + for (var i = 0; i < factors.length; ++i) { + var optionText = signalsModel && typeof signalsModel.formatSpeed === "function" + ? signalsModel.formatSpeed(factors[i]) + : String(factors[i]).replace(/\.0$/, "") + "x" + if (optionText === text) + return Number(factors[i]) + } + + if (signalsModel && typeof signalsModel.normalizeSpeed === "function") + return signalsModel.normalizeSpeed(text) + + return 1.0 + } + + function syncSpeedFact() + { + if (!mSpeed) + return + + loading = true + mSpeed.value = speedText() + loading = false + } + + function load() + { + loading = true + mName.value = data && data.name !== undefined ? data.name : "" + mPin.value = data && data.pin !== undefined ? data.pin : false + speedValue = signalsModel && typeof signalsModel.normalizeSpeed === "function" + ? signalsModel.normalizeSpeed(data ? data.speed : undefined) + : asNumber(data ? data.speed : undefined, 1.0) + syncSpeedFact() + loading = false + updateItems() } - function setSpeed(value) { - if (mSpeed.value === value) - return; - mSpeed.value = value; + function updateItems() + { + pageItems.deleteChildren() + + var items = data && data.items instanceof Array ? data.items : [] + for (var i = 0; i < items.length; ++i) + createItem(items[i]) + } + + function maybeAdoptNameFromBind(bind) + { + if (mName.text.trim() !== "") + return + + var guessed = pageNameFromBind(bind) + if (guessed !== "") + mName.setValue(guessed) } - function save() { - var items = []; - for (var i = 0; i < mItems.size; ++i) { - var item = mItems.child(i).save(); - if (!item.bind) - continue; - items.push(item); + function createItem(itemData) + { + var child = createFact(pageItems, "MenuItem.qml", { + "data": itemData, + "signalsModel": signalsModel, + "setFact": setFact, + "pageFact": pageFact + }) + if (!child) + return null + + child.titleChanged.connect(updateDescr) + child.removeTriggered.connect(updateDescr) + + if (itemData && itemData.bind !== undefined) + maybeAdoptNameFromBind(itemData.bind) + + if (setFact && typeof setFact.refreshSaveWarnings === "function") + setFact.refreshSaveWarnings() + + return child + } + + function isSaveTargetUsedLocal(saveTarget, skipItem) + { + var target = String(saveTarget).trim() + if (target === "") + return false + + for (var i = 0; i < pageItems.size; ++i) { + var itemEditor = pageItems.child(i) + if (!itemEditor || itemEditor === skipItem) + continue + if (typeof itemEditor.saveTarget === "function" + && itemEditor.saveTarget() === target) + return true + } + + return false + } + + function save() + { + var items = [] + for (var i = 0; i < pageItems.size; ++i) { + var itemEditor = pageItems.child(i) + var itemData = itemEditor.save() + if (itemData === null) + return null + items.push(itemData) } + return { - name: pTitle.value, - pin: mPin.value ? true : false, - speed: mSpeed.value, - items: items - }; - } - - function load(pageData) { - pTitle.value = pageData.name ? pageData.name : title; - pinned = pageData.pin ? true : false; - mPin.value = pinned; - speed = pageData.speed !== undefined ? pageData.speed : 1.0; - mSpeed.value = speed; - mItems.deleteChildren(); - var items = pageData.items ? pageData.items : []; - for (var i in items) - createItem(items[i]); - updatePageValues(); - } - - function createItem(itemData) { - if (!itemData.bind || itemData.bind === "") - return; - var wasEmpty = mItems.size === 0; - var c = createFact(mItems, "MenuItem.qml", { "data": itemData }); - c.parentFact = mItems; - c.removeTriggered.connect(function () { updatePageValues(); }); - c.titleChanged.connect(updatePageValues); - if (wasEmpty && (!pTitle.value || /^P\d+$/.test(pTitle.value))) - pTitle.value = defaultPageName(itemData.bind); - return c; - } - - function defaultPageName(bindExpr) { - if (!bindExpr || bindExpr === "") - return "P"; - var expr = bindExpr.replace(/^mandala\./, "").replace(/\.value$/, ""); - var parts = expr.split("."); - var leaf = parts.length > 0 ? parts[parts.length - 1] : expr; - return leaf.length > 0 ? leaf.charAt(0).toUpperCase() : "P"; - } - - function createFact(parent, url, opts) { - var component = Qt.createComponent(url); - if (component.status === Component.Ready) { - var c = component.createObject(parent, opts); - c.parentFact = parent; - return c; + "name": pageName(), + "pin": mPin.value, + "speed": speedValue, + "items": items } - console.warn("MenuPage.createFact: failed to load " + url + ": " + component.errorString()); } - function checkScrs(val) { - var matches = false; - for (var i = 0; i < mItems.size; ++i) - if (mItems.child(i).hasScr(val)) - matches = true; - return matches; + function updateTitle() + { + if (newItem) + return + + title = pageName() + } + + function updateDescr() + { + var parts = [] + if (mPin.value) + parts.push(qsTr("Pinned")) + + parts.push(qsTr("Speed") + ": " + speedText()) + + var items = [] + for (var i = 0; i < pageItems.size; ++i) + items.push(pageItems.child(i).title) + if (items.length > 0) + parts.push(items.join(", ")) + + descr = parts.join(", ") } - // Page title fact Fact { - id: pTitle + id: mName title: qsTr("Page name") - descr: qsTr("Short name shown on tab") + descr: qsTr("Tab label for this page") flags: Fact.Text icon: "rename-box" onValueChanged: { - menuPage.title = value; + pageFact.updateTitle() + pageFact.updateDescr() } } + Fact { id: mPin - name: "pin" title: qsTr("Pinned") - descr: qsTr("Show this page stacked with other pinned pages") + descr: qsTr("Show this page in the stacked pinned layout") flags: Fact.Bool - onValueChanged: { - menuPage.pinned = value > 0; - if (typeof signalsWidget !== 'undefined' && signalsWidget) - signalsWidget.updateLayout(); - } + icon: "pin" + onValueChanged: pageFact.updateDescr() } + Fact { id: mSpeed - name: "speed" title: qsTr("Speed") - descr: qsTr("Chart scroll speed factor") - flags: Fact.Float - enumStrings: ["0.2", "0.5", "1", "2", "4"] - value: 1.0 - precision: 1 + descr: qsTr("Default chart speed for this page") + flags: Fact.Enum + icon: "play-speed" + enumStrings: pageFact.speedOptions() + value: pageFact.speedText() onValueChanged: { - menuPage.speed = value; + if (pageFact.loading) + return + + var selectedText = value === undefined || value === null ? text : value + var selectedSpeed = pageFact.speedValueFromText(selectedText) + if (selectedSpeed === pageFact.speedValue) + return + + pageFact.speedValue = selectedSpeed + pageFact.updateDescr() + pageFact.syncSpeedFact() } } - // Add new item action MenuItem { - id: mMenuNewItem - title: qsTr("Add new chart") - descr: qsTr("Create and configure a new chart item") + title: qsTr("Add new item") icon: "plus-circle" newItem: true - onAddTriggered: createItem(save()) + visible: !pageFact.newItem + signalsModel: pageFact.signalsModel + setFact: pageFact.setFact + pageFact: pageFact + onAddTriggered: { + var itemData = save() + if (itemData) + pageFact.createItem(itemData) + } } Fact { - id: mItems + id: pageItems title: qsTr("Items") + visible: !pageFact.newItem flags: (Fact.Group | Fact.Section | Fact.DragChildren) - onSizeChanged: { - menuPage.updatePageValues(); - if (typeof signalsWidget !== 'undefined' && signalsWidget) - signalsWidget.updateLayout(); - } - onItemMoved: menuPage.updatePageValues() } Fact { flags: (Fact.Action | Fact.Apply) title: qsTr("Save") - visible: menuPage.isDirectEdit + descr: pageFact.standaloneEditor ? qsTr("Apply page changes") + : qsTr("Save chart changes") + visible: !pageFact.newItem icon: "check-circle" onTriggered: { - if (typeof signalsWidget !== 'undefined' && signalsWidget) - signalsWidget.saveSettings(); + if (!pageFact.standaloneEditor) { + pageFact.saveAll() + return + } + + var pageData = pageFact.save() + if (pageData === null) + return + + pageFact.accepted(pageData) + pageFact.menuBack() } } + + Fact { + flags: (Fact.Action | Fact.Apply) + title: qsTr("Add") + descr: qsTr("Add this page") + enabled: newItem + icon: "plus-circle" + onTriggered: { + pageFact.menuBack() + addTriggered() + } + } + Fact { flags: (Fact.Action | Fact.Remove) - title: qsTr("Remove page") + title: qsTr("Remove") + descr: qsTr("Remove this page") + visible: !newItem icon: "delete" - onTriggered: menuPage.deleteFact() + onTriggered: { + if (pageFact.standaloneEditor) { + removeTriggered() + removedStandalone() + pageFact.menuBack() + return + } + + var ownerSet = setFact + removeTriggered() + pageFact.deleteFact() + if (ownerSet && typeof ownerSet.refreshSaveWarnings === "function") + ownerSet.refreshSaveWarnings() + } } } diff --git a/src/Plugins/Tools/Signals/MenuSet.qml b/src/Plugins/Tools/Signals/MenuSet.qml index 8a16d41f2..f96e98a6a 100644 --- a/src/Plugins/Tools/Signals/MenuSet.qml +++ b/src/Plugins/Tools/Signals/MenuSet.qml @@ -22,151 +22,224 @@ import QtQuick import APX.Facts +import "." -// One set — a named collection of pages. -// Mirrors NumbersMenuSet but contains pages (MenuPage) instead of values. Fact { id: setFact - flags: (Fact.Group | Fact.FlatModel) + property var signalsModel: null + property var data: ({}) - property var pages: [] // from config / JSON - property var titleFact: setTitle - property var addPageFact: addPage - property var pagesFact: mPages + flags: (Fact.Group | Fact.FlatModel) signal selected(var num) - Fact { - id: setTitle - title: qsTr("Description") - flags: Fact.Text - icon: "rename-box" - value: setFact.title - onValueChanged: setFact.title = value + Component.onCompleted: { + load() + updateTitle() + updateDescr() + refreshSaveWarnings() } - // Add a new blank page (up to 10) - Fact { - id: addPage - title: qsTr("Add page") - icon: "plus-circle" - flags: Fact.Action - enabled: mPages.size < 10 - onTriggered: { - var n = mPages.size + 1; - var pageData = { name: "P" + n, pin: false, speed: 1.0, items: [] }; - createPage(pageData); + function rootEditor() + { + for (var parent = setFact.parentFact; parent; parent = parent.parentFact) { + if (typeof parent.saveSettings === "function") + return parent } + + return null } - Fact { - id: mPages - title: qsTr("Pages") - flags: (Fact.Group | Fact.Section | Fact.Count | Fact.DragChildren) - onSizeChanged: updateDescr() - onItemMoved: updateDescr() + function saveAll() + { + var root = rootEditor() + if (root) + root.saveSettings() } - Component.onCompleted: { - var opt = opts; - opt.page = "qrc:/Signals/MenuSetPage.qml"; - opts = opt; - updateSetItems(); + function createFact(parent, url, opts) + { + var component = Qt.createComponent(Qt.resolvedUrl(url)) + if (component.status === Component.Ready) { + var properties = opts || {} + properties.parentFact = parent + var child = component.createObject(parent, properties) + return child + } + + console.log(component.errorString()) + return null + } + + function defaultTitle() + { + if (signalsModel && typeof signalsModel.defaultSetTitle === "function") + return setFact.signalsModel.defaultSetTitle(Math.max(setFact.num, 0)) + return qsTr("Set") + " " + (Math.max(setFact.num, 0) + 1) } - function save() { - var savedPages = []; - for (var i = 0; i < mPages.size; ++i) { - var pg = mPages.child(i); - savedPages.push(pg.save()); + function load() + { + setTitle.value = data && data.title !== undefined ? data.title : defaultTitle() + updatePages() + } + + function save() + { + refreshSaveWarnings() + + var pages = [] + for (var i = 0; i < setPages.size; ++i) { + var pageEditor = setPages.child(i) + var pageData = pageEditor.save() + if (pageData === null) + return null + pages.push(pageData) } + return { - title: title, - pages: savedPages - }; + "title": setTitle.text.trim() !== "" ? setTitle.text.trim() : setFact.defaultTitle(), + "pages": pages + } } - function loadSet(setData) { - title = setData.title ? setData.title : title; - setTitle.value = title; - pages = setData.pages ? setData.pages : []; - updateSetItems(); + function updatePages() + { + setPages.deleteChildren() + + var pages = data && data.pages instanceof Array ? data.pages : [] + for (var i = 0; i < pages.length; ++i) + createPage(pages[i]) } - function updateSetItems() { - mPages.deleteChildren(); - var plist = pages ? pages : []; - for (var i in plist) - createPage(plist[i]); - updateDescr(); + function createPage(pageData) + { + var child = createFact(setPages, "MenuPage.qml", { + "data": pageData, + "signalsModel": setFact.signalsModel, + "setFact": setFact + }) + if (!child) + return null + + child.titleChanged.connect(updateDescr) + child.removeTriggered.connect(updateDescr) + return child } - function createPage(pageData) { - if (mPages.size >= 10) - return null; - var component = Qt.createComponent("MenuPage.qml"); - if (component.status !== Component.Ready) { - console.warn("MenuSet: cannot load MenuPage.qml: " + component.errorString()); - return null; + function refreshSaveWarnings() + { + for (var i = 0; i < setPages.size; ++i) { + var pageEditor = setPages.child(i) + if (!pageEditor || !pageEditor.itemsFact) + continue + + for (var j = 0; j < pageEditor.itemsFact.size; ++j) { + var itemEditor = pageEditor.itemsFact.child(j) + if (itemEditor && typeof itemEditor.refreshValidation === "function") + itemEditor.refreshValidation() + } } - var pg = component.createObject(mPages, { - "title": pageData.name ? pageData.name : ("P" + (mPages.size + 1)) - }); - if (!pg) { - console.warn("MenuSet: failed to create MenuPage instance"); - return null; + } + + function isSaveTargetUsed(saveTarget, skipItem) + { + var target = String(saveTarget).trim() + if (target === "") + return false + + for (var i = 0; i < setPages.size; ++i) { + var pageEditor = setPages.child(i) + if (!pageEditor || !pageEditor.itemsFact) + continue + + for (var j = 0; j < pageEditor.itemsFact.size; ++j) { + var itemEditor = pageEditor.itemsFact.child(j) + if (!itemEditor || itemEditor === skipItem) + continue + if (typeof itemEditor.saveTarget === "function" + && itemEditor.saveTarget() === target) + return true + } } - pg.parentFact = mPages; - pg.load(pageData); - if (pg.titleChanged) - pg.titleChanged.connect(updateDescr); - updateDescr(); - return pg; + + return false } - function getPages() { - var list = []; - for (var i = 0; i < mPages.size; ++i) - list.push(mPages.child(i)); - return list; + function updateTitle() + { + title = setTitle.text.trim() !== "" ? setTitle.text.trim() : setFact.defaultTitle() + } + + function updateDescr() + { + var pages = [] + for (var i = 0; i < setPages.size; ++i) + pages.push(setPages.child(i).title) + descr = pages.join(", ") + } + + Fact { + id: setTitle + title: qsTr("Set name") + descr: qsTr("Saved chart configuration name") + flags: Fact.Text + icon: "rename-box" + onValueChanged: { + setFact.updateTitle() + setFact.updateDescr() + } } - function updateDescr() { - if (!setFact) - return; - var s = []; - for (var i = 0; i < mPages.size; ++i) - s.push(mPages.child(i).title); - descr = s.join(", "); + MenuPage { + title: qsTr("Add new page") + icon: "plus-circle" + newItem: true + signalsModel: setFact.signalsModel + setFact: setFact + onAddTriggered: { + var pageData = save() + if (pageData) + setFact.createPage(pageData) + } } - function checkScrs(val) { - var matches = false; - for (var i = 0; i < mPages.size; ++i) - if (mPages.child(i).checkScrs(val)) - matches = true; - return matches; + Fact { + id: setPages + title: qsTr("Pages") + flags: (Fact.Group | Fact.Section | Fact.DragChildren) } Fact { flags: (Fact.Action | Fact.Remove) title: qsTr("Remove set") + descr: qsTr("Delete this chart set") icon: "delete" onTriggered: { if (setFact.active) - selected(0); - setFact.destroy(); + selected(0) + setFact.deleteFact() } } + + Fact { + flags: (Fact.Action | Fact.Apply) + title: qsTr("Save") + descr: qsTr("Save chart changes") + icon: "check-circle" + onTriggered: setFact.saveAll() + } + Fact { flags: (Fact.Action | Fact.Apply) title: qsTr("Select and save") + descr: qsTr("Make this set active and save it") visible: !setFact.active icon: "check-circle" onTriggered: { - setFact.menuBack(); - setFact.selected(setFact.num); + setFact.menuBack() + setFact.selected(setFact.num) } } } diff --git a/src/Plugins/Tools/Signals/MenuSetPage.qml b/src/Plugins/Tools/Signals/MenuSetPage.qml deleted file mode 100644 index 82bd61338..000000000 --- a/src/Plugins/Tools/Signals/MenuSetPage.qml +++ /dev/null @@ -1,100 +0,0 @@ -/* - * APX Autopilot project - * - * Copyright (c) 2003-2020, Aliaksei Stratsilatau - * All rights reserved - * - * This file is part of APX Ground Control. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program. If not, see . - */ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts - -import APX.Facts - -import Apx.Common -import Apx.Menu - -Item { - id: root - - property var pageFact: fact ? fact : null - property var listFact: pageFact ? pageFact.pagesFact : null - - function triggerFact(itemFact) { - if (!itemFact) - return; - if (itemFact.treeType === Fact.Action || itemFact.dataType === Fact.Apply || itemFact.dataType === Fact.Remove || itemFact.dataType === Fact.Stop) { - itemFact.trigger(); - return; - } - menuPage.factButtonTriggered(itemFact); - } - - clip: true - - ColumnLayout { - anchors.fill: parent - spacing: Style.spacing - - FactButton { - Layout.fillWidth: true - visible: root.pageFact !== null - fact: root.pageFact ? root.pageFact.titleFact : null - noFactTrigger: true - onTriggered: root.triggerFact(fact) - } - - FactButton { - Layout.fillWidth: true - visible: root.pageFact !== null - fact: root.pageFact ? root.pageFact.addPageFact : null - noFactTrigger: true - onTriggered: root.triggerFact(fact) - } - - ListView { - id: listView - Layout.fillWidth: true - Layout.fillHeight: true - clip: true - spacing: 0 - model: root.listFact ? root.listFact.model : null - - delegate: Loader { - active: modelData ? modelData.visible : false - visible: active - width: listView.width - height: active ? MenuStyle.itemSize : 0 - sourceComponent: Component { - FactButton { - fact: modelData ? modelData : null - noFactTrigger: true - size: MenuStyle.itemSize - onTriggered: { - listView.currentIndex = index; - root.triggerFact(fact); - } - } - } - } - - ScrollBar.vertical: ScrollBar { - policy: ScrollBar.AsNeeded - } - } - } -} diff --git a/src/Plugins/Tools/Signals/MenuSets.qml b/src/Plugins/Tools/Signals/MenuSets.qml new file mode 100644 index 000000000..22481f37e --- /dev/null +++ b/src/Plugins/Tools/Signals/MenuSets.qml @@ -0,0 +1,211 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick + +import APX.Facts + +Fact { + id: setsFact + + property var signalsModel: null + property bool destroyOnClose: true + property string defaultDescr: qsTr("Chart configuration editor") + + name: setsFact.signalsModel && setsFact.signalsModel.settingsName ? setsFact.signalsModel.settingsName : "signals" + flags: (Fact.Group | Fact.FlatModel) + title: qsTr("Signals") + descr: setsFact.defaultDescr + icon: "poll" + + signal accepted() + + Component.onCompleted: { + loadSettings() + } + + function close() + { + if (!setsFact.destroyOnClose) { + setsList.deleteChildren() + setsFact.loadSettings() + setsFact.menuBack() + return + } + + setsList.deleteChildren() + setsFact.menuBack() + } + + function createFact(parent, url, opts) + { + var component = Qt.createComponent(Qt.resolvedUrl(url)) + if (component.status === Component.Ready) { + var properties = opts || {} + properties.parentFact = parent + var child = component.createObject(parent, properties) + return child + } + + console.log(component.errorString()) + return null + } + + function createSet(setData) + { + var child = createFact(setsList, "MenuSet.qml", { + "data": setData, + "signalsModel": setsFact.signalsModel + }) + if (!child) + return null + + child.selected.connect(select) + child.selected.connect(saveSettings) + return child + } + + function defaultSettings() + { + if (setsFact.signalsModel && typeof setsFact.signalsModel.createDefaultSettings === "function") + return setsFact.signalsModel.createDefaultSettings() + + return { + "active": { + "signals": 0 + }, + "sets": [] + } + } + + function loadSettings() + { + setsFact.descr = setsFact.defaultDescr + setsList.deleteChildren() + + var settings = setsFact.defaultSettings() + if (setsFact.signalsModel) { + if (!setsFact.signalsModel.loaded && typeof setsFact.signalsModel.loadSettings === "function") + setsFact.signalsModel.loadSettings() + if (typeof setsFact.signalsModel.exportSettings === "function") + settings = setsFact.signalsModel.exportSettings() + } + + var sets = settings && settings.sets instanceof Array ? settings.sets : [] + var currentSetIdx = settings && settings.active ? settings.active.signals : 0 + + for (var i = 0; i < sets.length; ++i) + setsFact.createSet(sets[i]) + + setsFact.select(currentSetIdx) + } + + function saveSettings() + { + setsFact.descr = setsFact.defaultDescr + + var settings = { + "active": { + "signals": 0 + }, + "sets": [] + } + + for (var i = 0; i < setsList.size; ++i) { + var setEditor = setsList.child(i) + var setData = setEditor.save() + if (setData === null) { + setsFact.descr = qsTr("Fix editor errors before saving") + return + } + + settings.sets.push(setData) + if (setEditor.active) + settings.active.signals = i + } + + if (setsFact.signalsModel && typeof setsFact.signalsModel.saveSettings === "function") + setsFact.signalsModel.saveSettings(settings) + + setsFact.accepted() + setsFact.close() + } + + function resetToDefaults() + { + setsFact.descr = setsFact.defaultDescr + setsList.deleteChildren() + + var settings = setsFact.defaultSettings() + for (var i = 0; i < settings.sets.length; ++i) + setsFact.createSet(settings.sets[i]) + + setsFact.select(settings.active.signals) + } + + function select(num) + { + for (var i = 0; i < setsList.size; ++i) { + var setEditor = setsList.child(i) + setEditor.active = setEditor.num === num + } + } + + Fact { + id: setsList + title: qsTr("Sets") + flags: (Fact.Group | Fact.Section | Fact.DragChildren) + } + + Fact { + title: qsTr("Add set") + descr: qsTr("Create a new chart set") + flags: Fact.Action + icon: "plus-circle" + onTriggered: { + var setTitle = setsFact.signalsModel && typeof setsFact.signalsModel.defaultSetTitle === "function" + ? setsFact.signalsModel.defaultSetTitle(setsList.size) + : qsTr("Set") + " " + (setsList.size + 1) + var child = setsFact.createSet({ + "title": setTitle, + "pages": [] + }) + if (child) + child.trigger() + } + } + + Fact { + title: qsTr("Reset to defaults") + descr: qsTr("Replace the editor contents with the built-in default set") + flags: Fact.Action + icon: "restore" + onTriggered: setsFact.resetToDefaults() + } + + Fact { + title: qsTr("Save") + descr: qsTr("Save chart sets") + flags: (Fact.Action | Fact.Apply) + icon: "check-circle" + onTriggered: setsFact.saveSettings() + } +} diff --git a/src/Plugins/Tools/Signals/PageButton.qml b/src/Plugins/Tools/Signals/PageButton.qml index 67d5294f0..871e29b1c 100644 --- a/src/Plugins/Tools/Signals/PageButton.qml +++ b/src/Plugins/Tools/Signals/PageButton.qml @@ -1,3 +1,5 @@ +pragma ComponentBehavior: Bound + /* * APX Autopilot project * @@ -20,60 +22,51 @@ * along with this program. If not, see . */ import QtQuick -import QtQuick.Controls -import QtQuick.Layouts import QtQuick.Controls.Material import Apx.Common ValueButton { - id: pageBtn + id: control + + property bool selected: false + property bool pinned: false + property bool pageWarning: false - Layout.fillHeight: true + hoverEnabled: true checkable: true - ButtonGroup.group: pageButtonGroup - showValue: false - alerts: true + checked: selected + active: selected - // The MenuPage Fact this button represents - property var page: null + showValue: false + alerts: false + warning: pageWarning + normalColor: pinned ? "#4a4a22" : "#202020" + textBold: selected + textScale: 0.62 + minimumWidth: Math.max(28, height * 1.15) + maximumWidth: Math.max(minimumWidth, height * 3.0) + toolTipItem.delay: 500 - warning: page && page.hasWarning && !page.hasAlarm - error: page && page.hasAlarm - active: checked - normalColor: "#222" - activeColor: Qt.darker(Material.color(Material.BlueGrey), 1.5) + onTriggered: checked = true - // Pinned indicator — a subtle border - background: Rectangle { - color: checked - ? Qt.darker(Material.color(Material.BlueGrey), 1.5) - : "transparent" - border.width: (page && page.pinned) ? 1 : 0 - border.color: Material.color(Material.Cyan) - radius: height / 6 + Rectangle { + anchors.fill: parent + radius: 4 + color: "transparent" + border.width: control.pinned ? 1 : 0 + border.color: control.pageWarning + ? Material.color(Material.Orange) + : Material.color(Material.BlueGrey) } - descr: page && page.warningText ? page.warningText : "" - - toolTip: buildToolTip() - - function buildToolTip() { - if (!page) - return text; - var s = []; - s.push("" + page.title + ""); - if (page.warningText && page.warningText !== "") { - var warnColor = page.hasAlarm ? Material.color(Material.Red) : Material.color(Material.Orange); - s.push("" + page.warningText + ""); - } - var values = page.values; - for (var i = 0; i < values.length; ++i) { - var it = values[i]; - s.push("" + it.itemTitle + ""); - } - if (page.pinned) - s.push(qsTr("(pinned)")); - return s.join("
"); + MaterialIcon { + anchors.left: parent.left + anchors.top: parent.top + anchors.margins: 2 + visible: control.pinned + name: "pin" + size: Math.max(8, control.height * 0.34) + color: Material.color(Material.LightBlue) } } diff --git a/src/Plugins/Tools/Signals/README.md b/src/Plugins/Tools/Signals/README.md index e97f8fb4e..e2ca217aa 100644 --- a/src/Plugins/Tools/Signals/README.md +++ b/src/Plugins/Tools/Signals/README.md @@ -4,13 +4,56 @@ page: plugins # Signals -QML widget to show live charts of UAV telemetry values for tuning and diagnostics. +Configurable telemetry chart plugin for APX GCS. -The refactored Signals plugin now supports: +## Current behavior -- Multiple saved sets in `signals.json` -- Named pages inside each set -- Pinned pages rendered as stacked charts -- Per-page speed persistence with values `0.2x`, `0.5x`, `1x`, `2x`, `4x` -- Per-item colors, warning/alarm expressions, actions, and save-to-`sns.scr.*` -- Ordered, draggable filter chains with `running_avg` and `kalman_smp` +- Saved configurations live in `signals.json` as sets of pages and items. +- The bottom tab row keeps the original `Signals` workflow: `SignalButton` tabs only switch pages. +- The `+` button opens the full editor for sets, pages, items, colors, filters, and save targets. +- Pinned pages are rendered as stacked charts above the main chart inside the same widget. +- Chart overlays use `apx.font_narrow`: + - top-right page name on every chart + - speed label directly below the page name when the page speed is not `1x` + - bottom-right active set name on the main chart +- Page speed is persisted per page with the allowed values `0.2x`, `0.5x`, `1x`, `2x`, `4x`. +- Speed can be changed in two places: + - in the page editor as an enum-like selector + - by clicking the chart itself, which cycles the page speed + +## Item model + +Each chart item stores: + +- `bind` +- `color` +- `filters` +- `warning` +- `save` + +Notes: + +- Item labels are always derived from `bind`. +- Simple mandala picks are normalized to bare paths such as `est.att.roll` instead of `mandala.est.att.roll.value`. +- `save` targets use the same normalization and must stay under `sns.scr.*`. + +## Filters + +Filters are plain `Fact` children, not a custom editor page. + +Current filter types: + +- `running_avg` +- `kalman_smp` + +The filter stack is ordered, draggable, and can be enabled or disabled per filter. The rendered series value and the optional `save` output both use the filtered result. + +## Persistence + +`SignalsModel.qml` owns persistence and legacy migration. + +- New format: `{ active: { signals }, sets: [...] }` +- Legacy format accepted on load: `{ page, signalas }` +- Missing or empty settings regenerate the built-in default set + +There is no hard page limit in the current editor. diff --git a/src/Plugins/Tools/Signals/REFACTOR_NOTES.md b/src/Plugins/Tools/Signals/REFACTOR_NOTES.md new file mode 100644 index 000000000..f285cec07 --- /dev/null +++ b/src/Plugins/Tools/Signals/REFACTOR_NOTES.md @@ -0,0 +1,149 @@ +# Signals Refactor Notes + +Current implementation snapshot for `src/Plugins/Tools/Signals/`. + +## Runtime architecture + +- `SignalsPlugin.qml` + - Registers the plugin and mounts `Signals {}`. +- `Signals.qml` + - Hosts `SignalsModel`. + - Renders original-style `SignalButton` page tabs. + - Opens the full editor from the `+` button. + - Renders pinned charts above the main chart. + - Cycles page speed when the user clicks a chart. + - Shows chart overlays with `apx.font_narrow`: + - top-right page title on all charts + - speed label below the page title only when speed is not `1x` + - bottom-right active set label on the main chart +- `SignalsView.qml` + - Owns the chart renderer. + - Builds series from page items. + - Runs the filter chain per item. + - Writes filtered values to `sns.scr.*` save targets. + - Preserves `cmd.*` desaturated styling and grow-only Y-axis behavior. +- `SignalButton.qml` + - Remains the active tab-button implementation and behavioral baseline. + +## Persistence and model + +- `SignalsModel.qml` + - Owns `signals.json` load/save. + - Persists `{ active: { signals }, sets: [...] }`. + - Migrates legacy `{ page, signalas }` on load. + - Regenerates the built-in default set when settings are missing or empty. + - Exposes page-speed helpers and page mutation helpers. + +Current page/item schema: + +```json +{ + "title": "default", + "pages": [ + { + "name": "R", + "pin": false, + "speed": 1.0, + "items": [ + { + "bind": "est.att.roll", + "color": "", + "filters": [], + "warning": "", + "save": "" + } + ] + } + ] +} +``` + +Notes: + +- There is no hard page limit in the current editor. +- Simple mandala paths are normalized to bare paths such as `est.att.roll`. +- The same normalization is applied to `save` targets. + +## Editor tree + +- `MenuSets.qml` + - Root sets editor. + - Add/remove/reorder/select sets. + - Reset to defaults. + - Save the full chart configuration. +- `MenuSet.qml` + - One set editor. + - Set name, page list, add/remove pages, save action. + - No page-limit enforcement. +- `MenuPage.qml` + - One page editor. + - Page name, pin toggle, editable enum-like speed selector, item list. +- `MenuItem.qml` + - One item editor. + - No `title` field. + - No `act` field. + - Fields: binding, color, filters, warning, save target. + - Nested save actions stay visible while editing. +- `ColorChooser.qml` + - 12x4 Material palette plus hex input. +- `FilterFact.qml` + - Plain Fact-based filter row. + - `Fact.Group | Fact.Bool` with type-specific parameter visibility. + - Save/remove actions on the filter page. +- `FilterRegistry.qml` + - Filter defaults, titles, type normalization, runtime component lookup. + +## Filter implementation + +Current filter types: + +- `running_avg` +- `kalman_smp` + +Current pattern: + +- Filters are plain Fact children inside the item’s `Filters` group. +- Each filter is ordered and draggable. +- Each filter has an enable toggle on the row itself. +- Parameter facts are shown/hidden from the selected `type`. +- Runtime execution is still registry-driven in `SignalsView.qml`. + +## Current UX decisions + +- `+` opens the full Signals editor, not a quick-bind field. +- Tabs only switch pages. No edit affordances or pin actions are attached to the tabs. +- Pinned pages are controlled from the page editor. +- Speed is per page. +- The dedicated bottom speed button was removed. +- Clicking a chart cycles that page’s speed. +- The speed label is only shown when the page speed is not `1x`. +- Save targets must stay under `sns.scr.*` and are deduplicated across the active set. +- Only the warning expression is kept. There is no separate alarm field. + +## File status + +| File | Current role | Status | +| --- | --- | --- | +| `Signals.qml` | Runtime shell and overlays | active | +| `SignalsModel.qml` | persistence and runtime helpers | active | +| `SignalsView.qml` | chart renderer and filter execution | active | +| `SignalButton.qml` | page tabs | active | +| `MenuSets.qml` | sets editor root | active | +| `MenuSet.qml` | set editor | active | +| `MenuPage.qml` | page editor | active | +| `MenuItem.qml` | item editor | active | +| `ColorChooser.qml` | color editor page | active | +| `FilterFact.qml` | filter editor/runtime row | active | +| `FilterRegistry.qml` | filter metadata and normalization | active | +| `PageButton.qml` | old experiment from an earlier tab rewrite | currently unused | + +## Documentation sync + +The markdown files should reflect these current decisions: + +- no page limit +- no bottom speed button +- chart click cycles speed +- speed label sits below the page title and is hidden at `1x` +- `SignalButton.qml` remains the active tab path +- item schema is `bind/color/filters/warning/save` diff --git a/src/Plugins/Tools/Signals/REFACTOR_PROMPT.md b/src/Plugins/Tools/Signals/REFACTOR_PROMPT.md index 7df0862c6..85c2eaf31 100644 --- a/src/Plugins/Tools/Signals/REFACTOR_PROMPT.md +++ b/src/Plugins/Tools/Signals/REFACTOR_PROMPT.md @@ -1,123 +1,186 @@ +# VSCode Copilot Agent Prompt — Signals Plugin Upgrade + +Paste this entire prompt into your Copilot chat (Agent mode) inside the `apx-gcs` repo working tree. It describes the full feature set you will add to the existing `Signals` plugin, the reference code you should study, and concrete implementation rules. + +**Starting point:** the `main` branch of `uavos/apx-gcs`. You are extending the existing `Signals` plugin in-place. There is no other branch, plugin, or prior contribution you need to consult — everything needed is described below. + +**Important: do not run any git operations.** No `git add`, `git commit`, `git checkout`, `git branch`, `git mv`, `git rm`, `git rebase`, `git push`, `git stash`, `git restore`. No creating branches. No opening pull requests. Only modify the working tree. The human will handle all version control afterwards. + +--- + ## Context for the agent -You are working in the **uavos/apx-gcs** repository (APX Ground Control Station, Qt 6 / QML / C++). The active branch is `FiltredCharts`. PR #111 ("Filtred charts") by SokolovskyYury adds a new plugin at `src/Plugins/Tools/FilteredCharts/`. The owner/architect (Aliaksei Stratsilatau, @uavinda) has decided, after discussion with the field operators, that this plugin as-submitted will not ship. Instead, you will **refactor the existing `Signals` plugin in-place** so that it natively absorbs the FilteredCharts functionality. The goal: one plugin, one set of buttons, Miller's-rule-friendly UI, backward-compatible with the current `Signals` layout and behavior. +You are working in the **uavos/apx-gcs** repository (APX Ground Control Station, Qt 6 / QML / C++). The `Signals` plugin at `src/Plugins/Tools/Signals/` is a telemetry chart widget. Today it is hardcoded — fixed tab buttons `R / P / Y / Axy / Az / G / Pt / Ctr / RC / Usr / +` with fixed mandala bindings, a single global speed setting, no per-series filtering, no multiple configurations. It works, but every vehicle / airframe / mission needs its own chart setup, and operators currently have no way to save or switch those configurations. -**Do not create a second plugin.** The `FilteredCharts` directory and PR will be superseded by enhancements to `Signals`. Once the refactor is complete, the `FilteredCharts` plugin directory must be removed from `src/Plugins/Tools/` and from the build. +Your job is to turn `Signals` into a configurable plugin with the same ergonomic model the `Numbers` plugin already uses elsewhere in the codebase: **sets** of **pages** of **items**, fully user-editable, persisted to JSON. On top of that, each item gets an ordered **filter stack** (e.g. running-average then Kalman) so operators can smooth noisy telemetry before plotting and, optionally, write the filtered signal back into the mandala for use by other plugins. -### Source of the requirements +The end result: one plugin, one widget, no hardcoded buttons, no new dependencies, fully backward-compatible with any existing `signals.json` on disk. -The spec below is the distillation of the architect's design decisions posted in Discord `#gcs` on 2026-04-23. Every point is a hard requirement unless marked optional. Do not reinterpret or drop any of them without asking. +### Design philosophy + +- **Mirror Numbers.** The sets/pages/items editor pattern, the JSON shape, the Fact-tree wiring — all of it should look and feel identical to `Numbers`. Operators already know how Numbers works; Signals should feel like the same tool applied to charts instead of readouts. +- **Filters are plain Facts, not a custom editor page.** Model the filter stack like `datalink/ports` — an ordered, reorderable list of child Fact groups with add/remove actions, a bool on/off switch on each filter group row, and child parameter Facts shown/hidden from the selected filter `type`. +- **QML only.** No C++ changes. The plugin is QML today and will stay QML. + +### Clarifications from implementation review + +- **Keep the page tabs visually and behaviorally the same as the original `Signals` plugin.** Tabs only switch pages. Do not overload them with edit affordances, pin actions, long-press behavior, or extra icons. +- **The `+` button opens the full Signals editor** (sets/pages/items), same entry-point concept as `Numbers`. There is no separate sets-editor button. +- **Items do not have `title` or `act` fields.** Series labels are always derived from `bind`. +- **Normalize simple mandala paths.** If the user picks `mandala.est.att.roll.value`, store and display `est.att.roll`. Apply the same rule to `save` targets. +- **The save target behaves like the binding picker.** Use a mandala-typed Fact selector, not a duplicate free-text “save path” row. +- **Pinned charts are stacked above the main chart inside the same widget.** Pinned charts show only a top-right page-name label. The main chart shows a top-right page-name label and a bottom-right set-name label. All these overlay labels use `apx.font_narrow`. +- **There is no hard page limit.** Do not cap the number of pages per set in the editor or model. +- **There is no bottom speed button.** Clicking a chart cycles that page's speed. The speed label is shown directly below the page title and only when the page speed is not `1x`. +- **Save must be reachable from nested editor pages.** A user drilling into set/page/item/color/filter pages should still have a visible `Save` action without backing all the way out. --- ## Repository files you MUST read before writing any code -Read these in full and understand the relationships. Open each one in the editor. - -### Current FilteredCharts plugin (to be absorbed, then deleted) -- `src/Plugins/Tools/FilteredCharts/FilteredChartsPlugin.qml` — AppPlugin registration -- `src/Plugins/Tools/FilteredCharts/FilteredCharts.qml` — top-level widget: page buttons 1..10, speed button, chart area, settings persistence to `charts.json` -- `src/Plugins/Tools/FilteredCharts/FcChartsView.qml` — QtCharts renderer; near-duplicate of `SignalsView.qml` with `speedFactor=[0.2,0.5,1,2,4]`, per-series `updateSeriesColor()`, `resetEnable` flag -- `src/Plugins/Tools/FilteredCharts/FcButton.qml` — per-page button; owns an `FcMenuSet`, has `getSet()/loadSet()/setSpeed()`, reacts to `mandala.onTelemetryDecoded` -- `src/Plugins/Tools/FilteredCharts/FcMenuSet.qml` — Fact-tree editor for a set: title, speed, `msValues` group with `FcMenuChart` children, Save action -- `src/Plugins/Tools/FilteredCharts/FcMenuChart.qml` — per-chart editor: `chartname`, `bind` (expression), color, filters, `save` target; implements `useRunningAvgFilter()`, `useKalmanSmpFilter()`, state/covariance -- `src/Plugins/Tools/FilteredCharts/FcMenuFilters.qml` — filter picker enum `["none", "running_avg", "kalman_smp"]` containing `FcFilterRunningAvg` + `FcFilterKalmanSimple` -- `src/Plugins/Tools/FilteredCharts/FcFilterRunningAvg.qml` — coefficient K ∈ [0,1] -- `src/Plugins/Tools/FilteredCharts/FcFilterKalmanSimple.qml` — measurement noise + environment noise -- `src/Plugins/Tools/FilteredCharts/FcMenuColor.qml` — color Fact routed to `FcColorChooser.qml` page -- `src/Plugins/Tools/FilteredCharts/FcColorChooser.qml` — 12×4 palette grid -- `src/Plugins/Tools/FilteredCharts/CMakeLists.txt` - -### Current Signals plugin (the base you will refactor) -- `src/Plugins/Tools/Signals/SignalsPlugin.qml` -- `src/Plugins/Tools/Signals/Signals.qml` — hardcoded buttons R / P / Y / Axy / Az / G / Pt / Ctr / RC / Usr / `+` (custom text input) + speed button -- `src/Plugins/Tools/Signals/SignalsView.qml` — QtCharts renderer -- `src/Plugins/Tools/Signals/SignalButton.qml` — minimal toggle button -- `src/Plugins/Tools/Signals/CMakeLists.txt` - -### Numbers plugin — the canonical set/editor pattern to mirror -- `src/main/qml/Apx/Controls/numbers/NumbersModel.qml` — loads `numbers.json`, builds `NumbersItem` objects, exposes `edit()` → `NumbersMenuPopup` -- `src/main/qml/Apx/Controls/numbers/NumbersMenu.qml` — Fact-tree set editor, `loadSettings()/saveSettings()`, "Add set" action, `select(num)` radio behavior, preserves `json.active[settingsName]` -- `src/main/qml/Apx/Controls/numbers/NumbersMenuSet.qml` — per-set editor with `setTitle`, `NumbersMenuNumber` template, drag-children values list -- `src/main/qml/Apx/Controls/numbers/NumbersMenuNumber.qml` — per-item editor: `bind`, `title`, `prec`, `warn`, `alarm`, `act` -- `src/main/qml/Apx/Controls/numbers/NumbersMenuPopup.qml` -- `src/main/qml/Apx/Controls/numbers/NumbersItem.qml` -- `src/main/qml/Apx/Controls/numbers/NumbersBar.qml` -- `src/main/qml/Apx/Controls/numbers/NumbersBox.qml` - -Also skim `src/main/qml/Apx/Common/` for `TextButton`, `IconButton`, `ValueButton`, `FactButton`, `Style` — reuse these; do not invent new primitives. - -Also skim how `datalink/ports` is modeled as a dynamic list (for the multi-filter list pattern). Search under `src/Plugins/Protocols` or `src/main/qml/Apx/Menu` for `ports` to find it. +Open each of these in the editor and understand them. The two blocks below are your entire study set. ---- +### The Signals plugin (what you will modify) + +- `src/Plugins/Tools/Signals/SignalsPlugin.qml` — AppPlugin registration. +- `src/Plugins/Tools/Signals/Signals.qml` — top-level widget: page tabs, `+` editor button, chart-click speed control, pinned-chart stack, main-chart page/set labels, and the chart area. This is the file most affected by the refactor. +- `src/Plugins/Tools/Signals/SignalsView.qml` — QtCharts renderer. Handles series creation, series colors (including the desaturated rendering for `cmd.*` facts), axis auto-rescale, speed-factor timing. +- `src/Plugins/Tools/Signals/SignalButton.qml` — the authoritative tab/toggle button. Keep the page-tab interaction and visual behavior aligned with this original component. +- `src/Plugins/Tools/Signals/FilterRegistry.qml` — filter-type registry, defaults, normalization helpers. +- `src/Plugins/Tools/Signals/FilterFact.qml` — the plain Fact-based filter row used in the filter stack. +- `src/Plugins/Tools/Signals/CMakeLists.txt` — resource list; every new QML file you add must be listed here under `QRC_QML`. -## Terminology (use these exact names in code and JSON) +### The Numbers plugin (the canonical pattern to mirror) -- **Set** — top-level saved group, same concept as a Numbers set. One set = one full chart configuration tied to a specific system/airframe. There can be many sets (e.g. "default", "HAPS", "R22", …). Only one set is active. -- **Page** — a named page inside a set, corresponds to one of the tabs at the bottom of the widget (today: R, P, Y, Axy, Az, G, Pt, Ctr, RC, Usr, +). The default set's pages must be identical to the hardcoded Signals pages. -- **Item** — a single plotted variable (previously a `SignalButton.values[i]` entry or an `FcMenuChart` entry). Each item has: expression (`bind`), optional `title`, `color`, per-item `filters` (ordered list, can be empty, can have multiple), optional `warning`/`alarm`/`act`, optional `save` target (`sns.scr.*`). -- **Filter** — a processing node inside an item's filter chain. Types at minimum: `running_avg`, `kalman_smp`. Model it as a list (like `datalink/ports`) so more filter types can be added later. +- `src/main/qml/Apx/Controls/numbers/NumbersModel.qml` — loads `numbers.json`, builds `NumbersItem` objects, exposes `edit()` → `NumbersMenuPopup`. This is the template for the new `SignalsModel.qml`. +- `src/main/qml/Apx/Controls/numbers/NumbersMenu.qml` — Fact-tree sets editor with `loadSettings()`/`saveSettings()`, "Add set" action, `select(num)` radio behavior, and the `json.active[settingsName]` active-index preservation logic. Template for the new `MenuSets.qml`. +- `src/main/qml/Apx/Controls/numbers/NumbersMenuSet.qml` — per-set editor with `setTitle`, a `NumbersMenuNumber` child template, and a draggable children list. Template for the new `MenuSet.qml`. +- `src/main/qml/Apx/Controls/numbers/NumbersMenuNumber.qml` — per-item editor exposing `bind`, `title`, `prec`, `warn`, `alarm`, `act`. Template for the new `MenuItem.qml`, but Signals drops `title`, `alarm`, and `act`; you will add `color`, `filters`, and `save` instead. +- `src/main/qml/Apx/Controls/numbers/NumbersMenuPopup.qml`, `NumbersItem.qml`, `NumbersBar.qml`, `NumbersBox.qml` — supporting components. Skim them; reuse their patterns rather than inventing new ones. +### Shared components + +- `src/main/qml/Apx/Common/` — skim `TextButton`, `IconButton`, `ValueButton`, `FactButton`, `Style`. Reuse these; do not invent new primitives. +- Find the `datalink/ports` implementation (search under `src/Plugins/Protocols` or `src/main/qml/Apx/Menu` for `ports`). Read it carefully — it's the model for the per-item filter list: ordered, draggable, typed sub-Facts, each with an enable toggle. Note how it handles add/remove/reorder and how the typed subtree is rendered. --- -## Functional spec — what the refactored `Signals` plugin must do +## Terminology (use these exact names in code, JSON, and UI) -### 1. Unify with FilteredCharts -- The plugin is `Signals`, located at `src/Plugins/Tools/Signals/`. No new plugin directory. -- `FilteredCharts` plugin directory, its entry in `src/Plugins/Tools/CMakeLists.txt`, and the `FilteredChartsPlugin` registration must be removed at the end of the refactor. -- Any QML helper worth keeping from `FilteredCharts/` (chart view, color chooser, filter facts) must be **moved** into the `Signals` plugin directory with appropriate renaming (e.g. `ChartsView.qml`, `ColorChooser.qml`, `FilterRunningAvg.qml`, `FilterKalmanSimple.qml`, `MenuFilters.qml`, `MenuColor.qml`, `MenuChart.qml`, `MenuSet.qml`). The copyright header must be preserved on every copied file. Drop the `Fc` prefix. +- **Set** — top-level saved group, same concept as a Numbers set. One set = one complete chart configuration, typically tied to a specific system or airframe. Multiple sets can exist (e.g. `default`, `HAPS`, `R22`, …); exactly one is active at a time. +- **Page** — a named page inside a set. Each page is one tab in the widget's tab bar. Replaces today's hardcoded `R / P / Y / …` buttons. +- **Item** — one plotted variable on a page. An item has: expression (`bind`), `color`, an ordered `filters` list, an optional `warning` expression, and an optional `save` target (a mandala variable under `sns.scr.*`). The displayed series name is always derived from `bind`. +- **Filter** — one processing node inside an item's filter stack. A filter has a `type` (from an enum), an `enabled` toggle, a position in the stack (user-reorderable), and its own typed parameter subtree. Supported types at launch: `running_avg`, `kalman_smp`. -### 2. Sets editor (reuse Numbers pattern) -- Provide a Numbers-style sets editor. A user can create/rename/delete sets; one set is active at a time. Persist to a single JSON file in `application.prefs`, e.g. `signals.json`. Use the same `json.active`/`json.sets` structure as `numbers.json`. -- Default set is auto-generated on first run and reproduces today's hardcoded `Signals` configuration byte-for-byte: pages `R, P, Y, Axy, Az, G, Pt, Ctr, RC, Usr` with the exact same mandala bindings. The default set must always be regenerable (provide a "Reset to defaults" action). Do not delete the default if the user has no other sets. -- When the active set changes, pages and items are rebuilt from that set. +--- -### 3. Pages inside a set -- Each set contains an ordered list of pages (max 10 — keep current limit, Shpilevski's HAPS requirement). -- A page has: `name` (string, user-editable; default = first character of the first item's variable, uppercase — e.g. `est.att.roll` → `R`), `pin` (bool, optional — see #6), and an `items` array. -- The `+` button (top-right, where `IconButton { iconName: "plus" }` sits today in `FilteredCharts.qml`) opens the currently active page for editing. It is not a tab any more — replaced by the set name label (see next point). -- The bottom-right of the bar shows the **set name** as a plain label (or short dropdown to switch sets), not a `+` tab. Clicking it opens the sets editor. -- Tab buttons show the page `name` (not 1..10). On hover show a tooltip listing the page name and its items with their colors — the existing `updateToolTip()` logic in `FcButton.qml` is the right model. +## Functional spec — what the upgraded `Signals` plugin must do -### 4. Items inside a page -- Each item has: `bind` (JS expression, required), `title` (optional), `color` (hex, optional — falls back to palette like Signals does today via `Material.color(Material.Blue+i*2)`), `filters` (ordered list, can be empty), `warning` (optional JS expression), `alarm` (optional JS expression), `act` (optional JS action), `save` (optional mandala var, must start with `sns.scr` — reuse check from `FcMenuChart.saveValue2Fact()`). -- Per-item editor reuses `NumbersMenuNumber` structure — same fields, same `load()/save()/settingName()/updateTitle()/updateDescr()` conventions — plus `color` and `filters`. -- When `warning` or `alarm` expressions evaluate true, highlight the **page button** (the tab) the same way `NumbersItem` highlights itself (use `ValueButton.alerts`), and show the warning message + tooltip on the page button. This is the "warning from `numbers` in item, highlight the `page` button" requirement. +### 1. Sets editor (mirror Numbers exactly) + +- Provide a Numbers-style sets editor reachable from the widget's `+` button (see §3 for the exact UI). Users can add / rename / delete / reorder sets; exactly one set is active at any time. +- Persist to a single JSON file in `application.prefs` named `signals.json`, using the same `{ active, sets }` structure as `numbers.json`. `active.signals` is the index of the active set. See the JSON schema section further down for the full shape. +- On first run (no `signals.json` on disk), auto-generate a **default set** that reproduces today's hardcoded Signals configuration verbatim: pages `R`, `P`, `Y`, `Axy`, `Az`, `G`, `Pt`, `Ctr`, `RC`, `Usr` with the same mandala bindings each of those hardcoded buttons currently wires up. Read the existing `Signals.qml` to extract those bindings exactly — this is how you preserve behavior for existing users. +- Provide a "Reset to defaults" action in the sets editor that regenerates the default set even if other sets already exist. The default set must never be silently deleted when the user has zero sets — in that case, auto-recreate it. +- When the active set changes, pages and items must be rebuilt from that set's contents. + +### 2. Pages inside a set + +- Each set holds an ordered list of pages with no hard editor cap. +- A page has: `name` (string, user-editable; suggested default when creating a new page = uppercase first character of the first item's bind, e.g. `est.att.roll` → `R`), `pin` (bool, optional — see §6), `speed` (float, per-page — see §7), and an `items` array. +- Tab buttons at the bottom of the widget show the page `name`. On hover, each tab shows a tooltip listing the page name and each item's derived label (`bind`) + color swatch, so operators can see at a glance what's on a page without switching to it. +- A warning raised by any item on a page (see §4) must visually highlight that page's tab button using the same `ValueButton.alerts` style used elsewhere, and the tooltip should include the warning message. -### 5. Filters as an ordered list -- Replace the current single-enum filter selector with a **list** of filter instances per item, modeled after `datalink/ports`. Each filter is a Fact with a `type` (enum), a position (draggable), an enable toggle, and its own parameter subtree. -- Supported filter types at launch: `running_avg` (reuse `FcFilterRunningAvg.qml` logic) and `kalman_smp` (reuse `FcFilterKalmanSimple.qml` logic). Architecture must make it trivial to add a new type — one new QML file + one entry in the enum registry. -- At runtime, `updateValue()` runs the filters sequentially on each telemetry tick: output of filter N is input to filter N+1. -- The Kalman filter must still seed its state/covariance from the first raw value (keep the `setKalmanState(v, 0.1)` behavior). +### 3. Page bar layout (replaces today's hardcoded button row) -### 6. Pinned pages -- Add a per-page `pin` option. When one or more pages are pinned, the widget shows them stacked (several chart views in the same plugin panel). Non-pinned pages remain a single-slot tabbed view. Implement this as multiple `ChartsView` instances vertically stacked in a `ColumnLayout` when `pages.filter(p => p.pin).length > 0`. Tab buttons for pinned pages visually indicate their pinned state (e.g. outlined border, or a pin icon overlay). +The bottom bar and chart overlays should behave like this: -### 7. Speed / scale button -- Fix the bug noted in review: the speed button in `SignalsView.qml` uses the index-based `speedFactor` array and does not persist; the one in `FcChartsView.qml` uses direct factor values but the per-set Speed slider in `FcMenuSet.qml` writes into a fact that isn't wired back on reload in all cases. Pick **one** model: per-page speed, direct factor value, options `[0.2, 0.5, 1, 2, 4]` (keep FilteredCharts' set, since 0.2 and 0.5 are needed for long-term recording). Persist speed per page. Clicking the speed button cycles to the next value. Label remains `{value}x`. +- **Bottom bar, left / center:** the page tabs, one per page of the active set. Keep them behaving like the original Signals plugin: clicking a tab only switches the chart page. +- **Bottom bar, right:** a `+` icon button (`IconButton { iconName: "plus" }`) that opens the full Signals editor (sets/pages/items). +- **Main chart, top-right:** the active page name, with the speed label directly below it when the page speed is not `1x`. +- **Main chart, bottom-right:** the active set name. +- **Pinned charts, top-right:** the pinned page name, with the speed label directly below it when the page speed is not `1x`. +- **Chart click:** clicking a chart cycles that page's speed. + +There is no separate sets-editor button and no clickable set-name badge. The old `+` tab with its inline text-input `bind` shortcut is removed entirely. + +### 4. Items inside a page + +- Each item has these fields: + - `bind` — JS expression over the mandala, required (e.g. `est.att.roll`, `cmd.rc.roll`, or a computed expression). If the user chooses a simple mandala fact from the selector, store/display it as `xxx.yyy.zzz`, not `mandala.xxx.yyy.zzz.value`. + - `color` — hex string, optional. If empty, fall back to the palette algorithm today's `Signals.qml` uses: `Material.color(Material.Blue + i*2)` where `i` is the item index within the page. For `cmd.*` bindings keep the desaturated / lightened series rendering already in `SignalsView.addFactSeries`. + - `filters` — ordered list of filter instances, can be empty. See §5. + - `warning` — optional JS expression. When it evaluates truthy, the owning page tab is highlighted and the message is surfaced in the tab tooltip. + - `save` — optional mandala variable name. Must begin with `sns.scr.` — reject anything else with a user-visible warning in the editor. This field behaves like the binding picker: it is a mandala-typed Fact selector and stores the normalized bare path. +- The per-item editor is built on the `NumbersMenuNumber` structure, but Signals does **not** expose `title` or `act`. Add `color` (via the color chooser — see §8), `filters` (inline fact children — see §5), and `save` (mandala-picker field with the `sns.scr.` guard and the dedup check described in §9). Do not add a duplicate free-text “save path” row. + +### 5. Filter stack (ordered list of filters per item) + +- Each item owns an ordered list of filter instances. The list can be empty (no filtering). Multiple filters run in sequence: telemetry tick → filter 0 → filter 1 → … → rendered value (and, if `save` is set, written back to the mandala). +- Model the list on `datalink/ports`: a `Filters` group Fact whose children are the filters, each draggable to reorder, each with an `enabled` toggle on the filter group row itself, and each with a typed parameter subtree decided by its `type` enum. +- Filter types at launch: + - **`running_avg`** — single-pole running average with coefficient `coef ∈ [0, 1]`. Update rule: `out = out * (1 - coef) + in * coef`. On the first sample, initialize `out = in`. Reasonable default: `coef = 0.2`; precision / slider granularity should match `NumbersMenuNumber.prec` conventions. + - **`kalman_smp`** — simple 1-D Kalman smoother. Parameters: `r` (measurement noise, default `0.1`) and `q` (process noise, default `0.001`). State and covariance are internal; on the first sample, seed state from the raw value and covariance to `0.1`. Standard scalar Kalman update on each subsequent sample. +- Do **not** build a separate custom filter editor page. Use plain Facts only. A filter row is a `Fact.Group | Fact.Bool` subtree with normal add/remove actions. The `type` Fact is enum-like, and the parameter Facts (`coef`, `r`, `q`, …) are ordinary child Facts shown/hidden from the selected `type`. +- The architecture must still make adding a third filter type trivial: update the filter-type registry and add the parameter subtree / runtime logic for the new type in the same plain-Fact pattern. + +### 6. Pinned pages (stacked view) + +- Each page has an optional `pin` boolean. When **no** pages are pinned, the widget behaves as a classic single-chart tabbed view: clicking a tab swaps the chart. +- When **one or more** pages are pinned, the widget switches to a stacked layout: every pinned page renders its own chart view, vertically stacked in a `ColumnLayout`, all visible simultaneously. Non-pinned pages remain accessible via their tabs and appear in the single remaining tabbed slot below (or above) the pinned stack — pick one placement and be consistent. +- Pinned charts must not grow extra controls. Each pinned chart shows only the chart plus a small top-right page title label. +- The main chart also shows a top-right page title label, and additionally a bottom-right active-set label. +- Use `apx.font_narrow` for all these chart overlay labels. +- Pinning / unpinning is a page-level toggle in the page editor. Do not add pin actions to the page tabs. + +### 7. Speed control (per page, persisted) + +- Each page has its own `speed` value, a direct float factor from the set `[0.2, 0.5, 1, 2, 4]`. This factor controls the chart timing (x-axis tick rate) — same meaning as today's speed control, just with a wider, explicit value set so long-term recording at `0.2` / `0.5` is possible. +- The page editor exposes speed as an enabled enum-like selector for the page's default speed. +- Clicking a chart cycles that page to the next speed value in the list (wrapping from `4` back to `0.2`). +- The speed label is rendered as `{value}x` (e.g. `0.5x`, `1x`, `2x`) and is shown directly below the page title only when the page speed is not `1x`. +- The current speed is persisted per page inside `signals.json` and restored on reload. Changing speed on one page does not affect any other page. +- In the pinned-stack layout, each pinned page's chart uses its own stored speed, and clicking a pinned chart cycles only that pinned page. ### 8. Colors -- Default item colors follow the existing `Signals` algorithm: `Material.color(Material.Blue + i*2)` where `i` is the item index within the page. The user may override per item via the color chooser (reuse `FcColorChooser.qml`, rename to `ColorChooser.qml`). For legacy `cmd.*` items keep the desaturated/lightened rendering from `FcChartsView.addFactSeries` / `SignalsView.addFactSeries`. + +- Default item colors follow the palette algorithm already in `Signals.qml`: `Material.color(Material.Blue + i*2)` with `i` = item index on its page. +- Users may override per item via a color chooser. Implement the chooser as a compact 12×4 palette grid (48 Material colors) plus a hex text input for custom values. The color chooser opens from the item editor's color field. +- For `cmd.*` bindings, keep the existing desaturated / lightened rendering from `SignalsView.addFactSeries` — that rendering distinguishes commanded values from estimated values at a glance. ### 9. Save-to-mandala -- Keep the `save` field with the `sns.scr.*` guard (`chartWarning` when the user picks a non-`sns.scr` name). When an item has a `save` target, the filtered output of that item is pushed into the mandala variable via `apx.fleet.current.mandala.fact(fname, true).setRawValueLocal(value)` on every tick — identical to `FcMenuChart.saveValue2Fact()`. Also keep the `fcControl.checkScrMatches(text)` dedup check across all items in all pages of the active set. + +- When an item has a non-empty `save`, the item's **filtered output** (post-filter-stack) is written to the named mandala variable on every tick, using the existing pattern: `apx.fleet.current.mandala.fact(fname, true).setRawValueLocal(value)`. +- The editor must enforce that `save` begins with `sns.scr.` (the `scr` = "scripting" namespace reserved for plugin-produced values). Anything else is rejected with a user-visible warning message. +- The editor must also dedup: within the active set (across all pages, all items), no two items may share the same `save` target. If the user tries to save to a name already used elsewhere, show a warning and block the save. +- The save selector must normalize simple mandala picks the same way `bind` does: `mandala.sns.scr.foo.value` is stored/displayed as `sns.scr.foo`. ### 10. Chart rendering -- Use one `ChartsView.qml` (renamed from `FcChartsView.qml`) as the renderer. Delete `SignalsView.qml` — its functionality is a strict subset. Port over the FilteredCharts improvements: `resetEnable` on facts change, `updateSeriesColor()` for live color edits, instant rescale-grow, and the `speedFactor=[0.2,0.5,1,2,4]` array. +- Keep one chart renderer: the current `SignalsView.qml`. Adapt it to accept its data from the active set's active page (or, in pinned-stack mode, to be instanced per pinned page) rather than from hardcoded `SignalButton` children. +- Required adaptations: + - Rebuild series when the active page / set changes, when items are added / removed / reordered, or when an item's `bind` or `color` changes. Use a `resetEnable` / dirty-flag pattern so rebuilds happen exactly once per user action. + - Support live color edits: editing an item's color updates that series' pen without a full rebuild (an `updateSeriesColor(index, color)` method on the chart view). + - Support the `[0.2, 0.5, 1, 2, 4]` speed factor array per-page (see §7). + - Keep the existing auto-rescale-grow behavior (axis grows, never shrinks during a recording session). + - For items with a non-empty filter stack, render the **filtered** value on the chart, not the raw one. The raw stream is only visible if the user explicitly adds a second item with the same `bind` and an empty filter list. + - Preserve the original page-switch workflow driven by `SignalButton.qml`. + - Overlay labels are owned by `Signals.qml`: top-right page label on every chart, bottom-right set label on the main chart, all using `apx.font_narrow`. + +--- ## Non-functional requirements -1. **No C++ changes unless strictly required.** All work should be in QML under `src/Plugins/Tools/Signals/` and (where shared) `src/main/qml/Apx/Common/`. The plugin is QML-only today and should stay that way. -2. **Preserve the `FactButton.qml` `iconColor` change** introduced by PR #111 — that is a real improvement and is already merged conceptually; keep it in `src/main/qml/Apx/Common/FactButton.qml`. -3. **Revert `.vscode/settings.json`** if the FiltredCharts branch added private IDE settings there — keep the file clean. -4. **Backward-compatible migration.** Old `signals.json` files used a flat `{page: "string", signalas: [...]}` shape (note Yury's typo `signalas` — keep reading it, write as `signals`). On first load, if the old shape is detected, migrate to the new `{active, sets}` shape by wrapping the legacy list into a single set called "default" with a single page called (old `page` value or "page 1"). Never lose user data. -5. **Localize every user-visible string with `qsTr()`**. Include English source text. Match the style of existing Signals/Numbers QML. -6. **QML style.** 4-space indent, no tabs. Match the surrounding file style. Keep `import` order consistent with sibling files. Keep LGPL-2.1 headers on every new file (copy the header from `SignalsPlugin.qml`). -7. **CMakeLists.** Update `src/Plugins/Tools/Signals/CMakeLists.txt` to add any new QML files under `QRC_QML`. Remove `FilteredCharts` from `src/Plugins/Tools/CMakeLists.txt` (the `add_subdirectory(FilteredCharts)` line added by PR #111 — if it was added — must go). -8. **No new third-party dependencies.** Only Qt modules already used by the project (`QtQuick`, `QtQuick.Controls`, `QtQuick.Layouts`, `QtCharts`, `QtQml.Models`, `Apx.Common`, `Apx.Controls`, `APX.Facts`, `APX.Fleet`, `APX.Mandala`). -9. **Simulator smoke test** after the refactor: run GCS against the built-in simulator, create a set with two pages (one pinned, one not), add items with and without filters, toggle the speed button, edit a color, save to `sns.scr.*`, restart the app, confirm the state persisted. +1. **No C++ changes.** Everything is QML, under `src/Plugins/Tools/Signals/` and (where you need a truly shared primitive that doesn't already exist) `src/main/qml/Apx/Common/`. If you feel a C++ change is unavoidable, stop and ask first. +2. **Backward-compatible migration.** Existing `signals.json` files may use older shapes. Specifically, any legacy file with top-level keys `page` (string) and `signalas` (array — note the typo on the old key, read it as-is) must be migrated silently on first load: wrap the legacy list into a single set named `default` with a single page whose `name` is the old `page` value (or `page 1` if missing), and whose `items` are the legacy list entries mapped field-for-field. Always write the new key `signals` (correctly spelled) on save. Never lose user data. Never show a migration dialog — the migration is invisible to the user. +3. **Localize every user-visible string with `qsTr()`.** Include the English source text inline. Match the style of the existing Signals and Numbers QML for consistency with existing translations. +4. **QML style.** 4-space indent, no tabs. Match surrounding file style. Keep `import` order consistent with sibling files. Copy the LGPL-2.1 header from `SignalsPlugin.qml` onto every new file you create. +5. **CMakeLists.** Every new QML file must be listed under `QRC_QML` in `src/Plugins/Tools/Signals/CMakeLists.txt`. Every deleted file must be removed from that list. +6. **No new third-party dependencies.** Only Qt modules already used by the project: `QtQuick`, `QtQuick.Controls`, `QtQuick.Layouts`, `QtCharts`, `QtQml.Models`, `Apx.Common`, `Apx.Controls`, `APX.Facts`, `APX.Fleet`, `APX.Mandala`. +7. **`qmllint` clean.** Every new and modified QML file must pass `qmllint` with no warnings. Report any warnings you cannot silence in your progress summary. +8. **Simulator smoke test after the upgrade.** Build the project, run GCS against the built-in simulator, and verify: (a) a fresh profile auto-generates the default set with all old Signals pages populated; (b) create a second set with two pages (one pinned, one not) and a handful of items, including at least one item with a two-filter stack `running_avg → kalman_smp` and one item with `save = sns.scr.`; (c) click each chart to set a different page speed; (d) restart the app; (e) confirm all of it persisted exactly; (f) confirm the `sns.scr.*` variable is receiving filtered values visible to another plugin (e.g. Numbers). + +--- ## JSON schema for `signals.json` @@ -137,15 +200,12 @@ Also skim how `datalink/ports` is modeled as a dynamic list (for the multi-filte "items": [ { "bind": "est.att.roll", - "title": "roll", "color": "#2196F3", "filters": [ { "type": "running_avg", "enabled": true, "coef": 0.2 }, - { "type": "kalman_smp", "enabled": false, "r": 0.1, "q": 0.001 } + { "type": "kalman_smp", "enabled": false, "r": 0.1, "q": 0.001 } ], "warning": "", - "alarm": "", - "act": "", "save": "" } ] @@ -155,7 +215,7 @@ Also skim how `datalink/ports` is modeled as a dynamic list (for the multi-filte "pin": true, "speed": 0.5, "items": [ - { "bind": "est.pwr.vbat", "title": "Vbat", "color": "#FFC107", "filters": [], "save": "sns.scr.vbat_f" } + { "bind": "est.pwr.vbat", "color": "#FFC107", "filters": [], "save": "sns.scr.vbat_f" } ] } ] @@ -164,88 +224,71 @@ Also skim how `datalink/ports` is modeled as a dynamic list (for the multi-filte } ``` -- `active.signals` = index into `sets` of the currently visible set (mirrors `NumbersModel`'s `active.numbers`). -- Every field other than `bind` is optional. Readers must tolerate missing keys. -- Legacy (pre-refactor) files with keys `page` (string) and `signalas` (array) must be migrated silently — see non-functional requirement 4. +- `active.signals` is the index into `sets` of the currently active set (mirrors the `active.numbers` convention in `numbers.json`). +- Every field other than `bind` is optional; readers must tolerate missing keys and default them sensibly. +- Legacy files with top-level `page` (string) and `signalas` (array) are migrated silently on load — see non-functional requirement 2. + +--- ## Step-by-step plan the agent should follow -Work in this order. Commit after each numbered step with a clear message. Do not skip the inventory and design steps. +Work through these in order. Do not skip the inventory and design steps. **Do not commit anything.** Just modify files in the working tree. After each numbered step, produce a short progress summary (files added / modified / deleted + a one-sentence description) and stop for review — the human will decide when to commit. + +1. **Inventory.** Read every file in `src/Plugins/Tools/Signals/` and `src/main/qml/Apx/Controls/numbers/` in full. Locate and read the `datalink/ports` implementation (search under `src/Plugins/Protocols` and `src/main/qml/Apx/Menu`). Create a short `REFACTOR_NOTES.md` inside `src/Plugins/Tools/Signals/` listing, for each existing Signals file: what it does today, what will happen to it (keep / modify / delete), and which Numbers file you will model the replacement on. This doc is working scratch — keep it updated as you go, delete it in the final cleanup step. + +2. **Design sketch.** Before writing any QML, in `REFACTOR_NOTES.md`, sketch the new Fact tree for Signals: `Signals (plugin) → Sets (list) → Set → Pages (list) → Page → Items (list) → Item → Filters (list) → Filter`. Write down exactly which new QML file implements each level, which existing Numbers file it is modeled on, and which existing Signals file it replaces. Confirm the structure matches the Numbers pattern (`NumbersModel` → sets → items, with the same `active.` persistence convention). + +3. **Data model first.** Create `src/Plugins/Tools/Signals/SignalsModel.qml`, modeled on `NumbersModel.qml`. It owns the Fact tree (`active`, `sets`), the persistence (`loadSettings()` / `saveSettings()` reading and writing `signals.json` under `application.prefs`), and the legacy migration logic for old `{ page, signalas }` files. At this point you have no UI yet — only the model. Write a small comment block documenting the JSON schema and the migration. Verify load / save against a synthetic `signals.json` in the simulator before moving on. + +4. **Default-set factory.** Inside `SignalsModel.qml` (or a sibling helper file), implement the default-set generator that is invoked when `signals.json` is missing or has zero sets. The default set must exactly reproduce today's hardcoded Signals configuration: one page per hardcoded button (`R`, `P`, `Y`, `Axy`, `Az`, `G`, `Pt`, `Ctr`, `RC`, `Usr`), each page populated with the same mandala bindings today's `Signals.qml` wires into those buttons. Extract those bindings from the current `Signals.qml` source — don't guess. Each default page starts with `pin = false`, `speed = 1.0`, and an empty `filters` list on every item. -1. **Inventory.** Read every file in `src/Plugins/Tools/FilteredCharts/` and `src/Plugins/Tools/Signals/` and `src/main/qml/Apx/Controls/numbers/`. Write a short `REFACTOR_NOTES.md` in the Signals plugin dir listing every Fc* component, what it does, and which Signals/Numbers component it maps to in the new world. This doc is your reference — keep it updated as you go and delete it in the final cleanup step. -2. **Design sketch.** Before writing code, in `REFACTOR_NOTES.md`, sketch the new Fact tree for Signals: `Signals (plugin) → Sets (list) → Set → Pages (list) → Page → Items (list) → Item → Filters (list) → Filter`. Map each level to a QML file. Confirm it matches the Numbers pattern (`NumbersModel` → `sets` → `pages` → `items`). -3. **Scaffold with renames.** Copy each `Fc*.qml` to its new name inside `Signals/` and strip the `Fc` prefix: `FcChartsView.qml` → `ChartsView.qml`, `FcButton.qml` → `PageButton.qml`, `FcMenuSet.qml` → `MenuSet.qml`, `FcMenuChart.qml` → `MenuItem.qml`, `FcMenuFilters.qml` → `MenuFilters.qml`, `FcFilterRunningAvg.qml` → `FilterRunningAvg.qml`, `FcFilterKalmanSimple.qml` → `FilterKalmanSimple.qml`, `FcMenuColor.qml` → `MenuColor.qml`, `FcColorChooser.qml` → `ColorChooser.qml`. Update every import and type reference. Delete the old `Signals.qml`, `SignalsView.qml`, `SignalButton.qml` — they are superseded. At this point the plugin should build and show the old FilteredCharts UI under the name "Signals". -4. **Data model.** Create `SignalsModel.qml` mirroring `NumbersModel.qml` (sets, active index, persistence through `Fact` + `defaults` + JSON). Wire `signals.json` load/save. Implement the legacy migration for the `page`/`signalas` shape. -5. **Default-set factory.** Build the default first-run set so a fresh user sees useful content: one set "default" with pages `attitude` (roll/pitch/yaw), `rates` (Ax/Ay/Az), `gyro`, `pitot`, `ctr`, `rc`, `user` — matching the hardcoded buttons in today's `Signals.qml`. These are seeded only when `signals.json` is missing or empty. -6. **Tabs, pin, speed.** In the main widget (rename `Signals.qml`'s role into `SignalsView.qml`-style container), render page tabs from the active set. Replace the old `+` tab with a set-name label at bottom-right (click → sets editor) and a `+` button at top-right (click → active page's item editor). Implement the pinned-pages stacked layout. Wire the per-page speed button. -7. **Item editor.** Build `MenuItem.qml` on the `NumbersMenuNumber` template: same field conventions, same `descr` wiring, same Apply/Cancel behavior. Add `color` (via `ColorChooser`), `filters` (list editor), `save` (with `sns.scr` guard + dedup). -8. **Filter list.** Implement filters as an ordered list of Facts under each item, modeled on `src/Plugins/System/DataLink/ports` (draggable, enable toggle, typed subtree). Register the two built-in filter types. Document in `REFACTOR_NOTES.md` exactly how to add a third filter type. -9. **Cleanup.** Delete `src/Plugins/Tools/FilteredCharts/` entirely. Remove its `add_subdirectory` from `src/Plugins/Tools/CMakeLists.txt`. Remove `REFACTOR_NOTES.md`. Update `src/Plugins/Tools/Signals/README.md` with a short summary of the new capabilities. -10. **Build + smoke.** `cmake --build` the project. Fix any QML warnings from `qmllint` on the new files. Run the simulator smoke test described in non-functional requirement 9. -11. **Pull request.** Create a new branch `signals-unified` off `main` (do **not** rebase on `FiltredCharts`). Cherry-pick the `FactButton.qml` `iconColor` change from PR #111 so that contribution is preserved. Open PR titled "Signals plugin: unified sets/pages/filters (replaces PR #111)". In the PR description, link PR #111 and say it should be closed in favor of this one. List the files deleted, added, and modified. Include a screenshot/gif of the simulator smoke test. +5. **Sets / page / item editors.** Create the editor QML files: + - `MenuSets.qml` — modeled on `NumbersMenu.qml`. Lists all sets, supports add / rename / delete / reorder, "Reset to defaults" action, persists `active.signals`. + - `MenuSet.qml` — modeled on `NumbersMenuSet.qml`. Per-set editor: `title`, draggable pages list, "Add page" action, no page cap. + - `MenuPage.qml` — per-page editor: `name`, `pin` toggle, `speed` (enabled enum-like selector), draggable items list, "Add item" action. + - `MenuItem.qml` — modeled on `NumbersMenuNumber.qml`, but without `title` / `act`; extend it with `color` (opens color chooser), inline `filters`, and `save` (mandala picker with `sns.scr.` guard and dedup check). + - `ColorChooser.qml` — 12×4 palette grid plus hex text input. + - Ensure nested editor pages expose a visible `Save` action so the user can save while editing a set, page, item, color, or filter. + +6. **Filter stack.** Implement the filter list: + - `FilterRegistry.qml` — filter types, titles, defaults, normalization, and registry helpers. + - `FilterFact.qml` — one plain Fact-based filter row: `Fact.Group | Fact.Bool`, enum-like `type`, child parameter Facts shown/hidden from `type`, `reset()`, `step(in)`, and a remove action. + - The item editor owns a `Filters` group Fact that holds `FilterFact` children, supports drag reorder, and exposes an `Add filter` action. + - Wire the filter chain into the chart pipeline: the chart series for an item with filters renders post-filter values. The filters are reset whenever the item is edited or the series is rebuilt. + +7. **Widget shell.** Rewrite `Signals.qml` (and / or split it into a container + tab bar + chart area) so it renders the active set: + - Bottom bar: original-style `SignalButton` page tabs from `sets[active].pages` and the `+` icon button on the right (opens the full editor). + - Chart area: single `SignalsView` in the no-pins case; `ColumnLayout` of `SignalsView` instances in the pinned-pages-exist case. + - Chart click: reads / writes the clicked page's `speed`; cycles through `[0.2, 0.5, 1, 2, 4]`. + - Page-tab warning state: per-item `warning` expressions are evaluated and the owning tab is highlighted when truthy. + - Overlay labels: top-right page label plus conditional speed label on the main chart, top-right page label plus conditional speed label on each pinned chart, bottom-right active-set label on the main chart. Use `apx.font_narrow`. + +8. **Chart renderer adaptation.** Modify `SignalsView.qml` to take its series definitions from a page model (list of items with `bind` / `color` / filter chain) rather than from hardcoded `SignalButton` children. Add `updateSeriesColor(index, color)` for live color edits. Wire per-page `speed`. Preserve the existing `cmd.*` desaturated rendering and the auto-rescale-grow behavior. Support the save-to-mandala write on every tick for items with a non-empty `save`. + +9. **Cleanup.** Keep `SignalButton.qml` as the tab interaction/style baseline. If `PageButton.qml` or other experimental tab components were added during refactor attempts and end up unused, remove them. Remove `REFACTOR_NOTES.md`. Update `src/Plugins/Tools/Signals/CMakeLists.txt`: add every new QML file, remove every deleted file. Refresh the plugin `README.md` (if present; create one if not) with a short description of the new capabilities and a pointer to the JSON schema. + +10. **Build + smoke.** `cmake --build` the project. Fix any QML warnings from `qmllint`. Run the simulator smoke test described in non-functional requirement 8. If any step fails, fix it and rerun. Do not hand off with a broken build. + +11. **Hand-off.** Stop. Do not create a branch, do not commit, do not open a PR. Produce a final summary containing: (a) the complete list of files added, modified, and deleted; (b) a suggested PR title and description the human can paste when they open the PR themselves (suggested title: "Signals plugin: sets / pages / items editor with filter stack"); (c) a brief screenshot / gif checklist the human should attach to the PR from the simulator smoke test. + +--- ## Open questions to flag If any of these becomes a real blocker while coding, stop and ask before guessing. Otherwise default as specified. -1. **Speed scope.** The spec says speed is per page. If during implementation it becomes obvious users want one speed per set instead, flag it. Default: per page. -2. **Legacy `signalas` typo.** Keep reading the old key on load but always write `signals` on save. No user-visible migration message needed. -3. **The old `+` free-text item input** in `Signals.qml` (the row with `TextField` that let users type arbitrary `bind` strings inline) — is it worth keeping as a shortcut next to the item editor? Default: remove it; the full item editor replaces it. +1. **Speed scope.** The spec says speed is per page. If during implementation it becomes clear operators would prefer one speed per set, flag it. Default: per page. +2. **Legacy `signalas` typo.** Accept the old key on load, always write `signals` on save, no user-visible migration notice. Default: silent. +3. **Free-text bind shortcut.** Today's `+` tab has an inline text-input `bind` shortcut. The full Signals editor replaces it. If, during smoke testing, it turns out the quick inline entry is actually useful for operators, flag it as a small follow-up — don't add it back unprompted. Default: remove. + +--- ## Constraints on your behavior -- **No new third-party dependencies.** Qt modules already referenced by the project only. -- **No unrelated file changes.** Exception: if you notice the `PligIns` typo in `src/main/AppRoot.cpp` comments or similar obvious typos *in files you are already editing*, fix them. Do not go typo-hunting. -- **No C++ signature changes** in `PluginInterface`, `Fact`, `AppRoot`, etc. QML-only refactor. -- **Ask only on real conflicts.** Do not ask for permission for obvious style or naming choices — pick the one that matches the surrounding code. -- **Terse progress updates.** After each of the 11 steps, report: files touched, lines added/removed, one-sentence summary. No essays. -- **Preserve git history where reasonable.** Use `git mv` for renames so blame is preserved. Don't rewrite existing commits. -- **Run `qmllint`** on every new and modified QML file before committing the step. - -## Reference — Discord context (2026-04-23) - -### Architect's decision (Aliaksei, @uavinda), `#gcs`, 12:59 MSK - -> If we add a pages editor to `Signals`, and give each chart the ability to enable filters, we end up with `FilteredCharts`. So let's not multiply entities — do it all inside `Signals` and drop `FilteredCharts`. -> -> What's needed: -> - In `Signals`, remove the hardcoded R/P/Y/Axy/Az/G/Pt/Ctr/RC/Usr buttons — replace them with a sets/pages editor like in `Numbers`. -> - Each chart item has: bind, color, filters (ordered list, multiple can run in sequence), warning/alarm/act, optional save to `sns.scr.*`. -> - Pages can be pinned — pinned pages are shown stacked. -> - Speed button is per page, values `[0.2, 0.5, 1, 2, 4]`, persisted. -> - A warning from an item highlights its page tab. -> - Everything persists in `signals.json` with the `{active, sets}` structure, same as `Numbers`. -> - Close PR #111, open a new PR off `main`. - -### UI review (Slava Vasiukovich), `#gcs`, 11:00 MSK - -1. Functional duplication with the existing `Signals` plugin — no reason for a second plugin with a similar widget. -2. The scale/speed button behaves differently from neighboring plugins — different values, different persistence logic. -3. Tab names "1..10" mean nothing to the operator — they need meaningful names. - -### Counter (Yury, author of PR #111) - -Yury argued the plugin is distinct because of the filter chain. Aliaksei's resolution: filter chain moves into Signals, plugin merges, PR #111 is closed. - -### PR - -- [PR #111 — "Filtred charts"](https://github.com/uavos/apx-gcs/pull/111) by SokolovskyYury — 18 files, +1638 / -88. To be closed when `signals-unified` merges. - -### FiltredCharts branch commits to cherry-pick ideas from (most recent first) - -- `c72cdab` Change speed with a button click -- `3c41e3f` Charts view refactoring -- `f66ca99` Button minor fix -- `737db79`, `3904baa`, `fa708d7` Chart/color menu fixes -- `681a715` Set running avg defaults coef -- `ef2f9ce`, `16bc4b3`, `28733ef`, `32e10d1`, `bf8b423` Various fixes -- `d90d99a` Similar names fix -- `8a414c6` Used scripts var check -- `32a3f06`, `8fc0746` newItem icon + pinned menu fixes -- `fda3b2e`, `d69f514`, `983341d`, `ee6c6c5`, `b31ed47` Chart reset/color/apply fixes -- `d1a3f5a` Coefs range and precision -- `7ecae37` Color menu added -- `4c5290d` Write values without sending implemented -- `d5a1c9e`, `b6e1ef5`, `a689078`, `0cf8c9f`, `2f7e8f9` Refactoring, saving, save-to-fact - -The `FactButton.qml` `iconColor` change from this branch is worth cherry-picking into the new PR. +- **No new third-party dependencies.** Only Qt modules already referenced by the project. +- **No unrelated file changes.** Exception: if you notice an obvious typo (a comment misspelling, a stale TODO) *in a file you are already editing for this task*, fix it inline. Do not go typo-hunting through the rest of the codebase. +- **No C++ signature changes** anywhere — not in `PluginInterface`, `Fact`, `AppRoot`, or any other C++ file. This is a QML-only task. +- **Ask only on real conflicts.** Do not ask for permission for obvious style, naming, or micro-layout choices — pick the one that matches the surrounding code. +- **Terse progress updates.** After each of the 11 steps, report: files touched, approximate lines added / removed, one-sentence summary of what now works. No essays, no recaps of the prompt. +- **No git operations, at all.** No `git add`, `git commit`, `git checkout`, `git branch`, `git mv`, `git rm`, `git rebase`, `git cherry-pick`, `git push`, `git stash`, `git restore`. Renames are plain filesystem copy-then-delete. Deletions are plain `rm`. The only acceptable git usage is **read-only inspection** (`git log`, `git diff`) to orient yourself in the repo. +- **`qmllint` clean, step by step.** Run `qmllint` on every new or modified QML file as you go, not just at the end. Report warnings in the step summary. Do not worry about committing — the human handles that. diff --git a/src/Plugins/Tools/Signals/SignalButton.qml b/src/Plugins/Tools/Signals/SignalButton.qml new file mode 100644 index 000000000..2fba74dd9 --- /dev/null +++ b/src/Plugins/Tools/Signals/SignalButton.qml @@ -0,0 +1,61 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Controls.Material + +import Apx.Common + +TextButton { + Layout.fillHeight: true + checkable: true + ButtonGroup.group: buttonGroup + + property var values: [] + property string pageToolTip: "" + property bool pageWarning: false + + textColor: pageWarning ? Material.color(Material.Orange) + : Material.primaryTextColor + + onActivated: signals.facts=Qt.binding(function(){return values}) + + toolTip: pageToolTip !== "" ? pageToolTip : getToolTip(values) + + function getToolTip(facts) + { + var s=[] + for(var i=0;i"+label+"") + else + s.push(label) + } + return s.join("
") + } +} diff --git a/src/Plugins/Tools/Signals/Signals.qml b/src/Plugins/Tools/Signals/Signals.qml index aabc1dfc5..02ee1e9f9 100644 --- a/src/Plugins/Tools/Signals/Signals.qml +++ b/src/Plugins/Tools/Signals/Signals.qml @@ -1,3 +1,5 @@ +pragma ComponentBehavior: Bound + /* * APX Autopilot project * @@ -23,337 +25,645 @@ import QtQuick import QtQuick.Layouts import QtQuick.Controls import QtQuick.Controls.Material +import QtCore import Apx.Common +import Apx.Menu -// Root widget for the Signals plugin. -// Accessible from child QML files via id: signalsWidget (context property set below). Rectangle { - id: signalsWidget + id: control + + implicitHeight: layout.implicitHeight + implicitWidth: layout.implicitWidth + + border.width: 0 + color: "#000000" + + property string selectedPage: "" + property bool pageStateRefreshPending: false + property var pageStates: [] + property var globalUi: ui + property var globalApx: apx + property var prefsStore: application.prefs + property real uiScale: control.globalUi ? control.globalUi.scale : 1 + + readonly property var pages: signalsModel.pages + readonly property var pinnedPages: signalsModel.pinnedPages + readonly property int activePageIndex: checkedPageIndex() + readonly property var activePage: pageAt(activePageIndex) + readonly property real pinnedChartHeight: Math.max(44 * uiScale, + Math.min(96 * uiScale, + (control.height - bottomArea.implicitHeight) + / Math.max(pinnedPages.length + 1, 2))) SignalsModel { id: signalsModel + prefsAdapter: control.prefsStore + + onSettingsLoaded: control.handleModelChanged() + onSettingsSaved: control.handleModelChanged() } - implicitHeight: mainLayout.implicitHeight - implicitWidth: mainLayout.implicitWidth - border.width: 0 - color: "#000" - - // Active pages — array of MenuPage Fact objects rebuilt from the active set - property var activePages: [] - // Currently selected (non-pinned) page - property var currentPage: null - // Pinned pages shown stacked - property var pinnedPages: [] - // Title of the active set - property string activeSetTitle: "" - // Index of the active set in the JSON - property int activeSetIndex: 0 - - // ----------------------------------------------------------------- - // Public API — called from child components (MenuItem, MenuPage, etc.) - // ----------------------------------------------------------------- - - function saveSettings() { - var json = signalsModel.loadJson(); - json.sets = []; - var activeSets = _buildSetsFromPages(); - json.sets = activeSets.sets; - if (!json.active) json.active = {}; - json.active["signals"] = activeSetIndex; - signalsModel.saveJson(json); - } - - function checkScrMatches(val) { - if (!val || val === "") return false; - var matches = false; - for (var i = 0; i < activePages.length; ++i) { - if (activePages[i].checkScrs(val)) matches = true; + Component { + id: factPopupComponent + + FactMenuPopup { + pinned: true } - return matches; } - function updateLayout() { - pinnedPages = activePages.filter(function(p) { return p.pinned; }); - var nonPinnedPages = activePages.filter(function(p) { return !p.pinned; }); - if (currentPage && currentPage.pinned) - currentPage = nonPinnedPages.length > 0 ? nonPinnedPages[0] : null; - if (!currentPage && nonPinnedPages.length > 0) - currentPage = nonPinnedPages[0]; + Settings { + category: "signals" + property alias page: control.selectedPage } - function updateSeriesColors() { - singleChart.updateSeriesColor(); - for (var i = 0; i < pinnedChartsRepeater.count; ++i) { - var item = pinnedChartsRepeater.itemAt(i); - if (item) - item.updateSeriesColor(); + Connections { + target: control.globalApx ? control.globalApx.fleet.current.mandala : null + + function onTelemetryDecoded() + { + control.schedulePageStateRefresh() } } - function activatePage(page) { - if (!page) return; - if (page.pinned) - return; - currentPage = page; - singleChart.resetEnable = true; - singleChart.facts = Qt.binding(function() { - return signalsWidget.currentPage ? signalsWidget.currentPage.values : []; - }); - singleChart.speedFactorValue = Qt.binding(function() { - return signalsWidget.currentPage ? signalsWidget.currentPage.speed : 1.0; - }); + Component.onCompleted: handleModelChanged() + + function createEditorPopup(pos) + { + var popupParent = control.globalUi && control.globalUi.window ? control.globalUi.window + : control + + return factPopupComponent.createObject(popupParent, { + "pos": pos + }) } - // ----------------------------------------------------------------- - // Initialization - // ----------------------------------------------------------------- + function createPopupFact(popup, url, properties) + { + var component = Qt.createComponent(Qt.resolvedUrl(url)) + if (component.status !== Component.Ready) { + console.log(component.errorString()) + popup.destroy() + return null + } - Component.onCompleted: { - loadSettings(); + var fact = component.createObject(popup, properties) + component.destroy() + + if (!fact) { + popup.destroy() + return null + } + + popup.showFact(fact) + popup.open() + return fact } - function loadSettings() { - // Destroy old page facts - _destroyActivePages(); + function openSetsEditor() + { + var popup = createEditorPopup(Qt.point(0.76, 0.08)) + if (!popup) + return + + createPopupFact(popup, "MenuSets.qml", { + "signalsModel": signalsModel + }) + } - var json = signalsModel.loadJson(); - var sets = json.sets; - var idx = signalsModel.activeIndex(json); + function pageAt(index) + { + return index >= 0 && index < pages.length ? pages[index] : null + } - activeSetIndex = idx; - var activeSet = sets[idx]; - activeSetTitle = activeSet.title || qsTr("default"); + function checkedPageIndex() + { + var button = buttonGroup.checkedButton + if (!button) + return -1 - var pages = activeSet.pages || []; - var newPages = []; - for (var i = 0; i < pages.length && i < 10; ++i) { - var pg = _createMenuPage(pages[i]); - if (pg) newPages.push(pg); + var index = button["pageIndex"] + return index === undefined ? -1 : Number(index) + } + + function pageName(index) + { + var page = pageAt(index) + if (page && page.name) + return String(page.name) + return signalsModel.defaultPageTitle(Math.max(index, 0)) + } + + function pageFacts(index) + { + var page = pageAt(index) + return page && page.items instanceof Array ? page.items : [] + } + + function pageTitle(page, fallbackIndex) + { + if (page && page.name) + return String(page.name) + return signalsModel.defaultPageTitle(Math.max(fallbackIndex, 0)) + } + + function activeSetTitle() + { + if (signalsModel.activeSet && signalsModel.activeSet.title) + return String(signalsModel.activeSet.title) + return "" + } + + function pageIndexFor(page) + { + for (var i = 0; i < pages.length; ++i) { + if (pages[i] === page) + return i } - activePages = newPages; - pinnedPages = newPages.filter(function(p) { return p.pinned; }); - var nonPinnedPages = newPages.filter(function(p) { return !p.pinned; }); - currentPage = nonPinnedPages.length > 0 ? nonPinnedPages[0] : null; - - if (currentPage) { - singleChart.resetEnable = true; - singleChart.facts = Qt.binding(function() { - return signalsWidget.currentPage ? signalsWidget.currentPage.values : []; - }); - singleChart.speedFactorValue = Qt.binding(function() { - return signalsWidget.currentPage ? signalsWidget.currentPage.speed : 1.0; - }); - } else { - singleChart.facts = []; - singleChart.speedFactorValue = 1.0; + + return -1 + } + + function pinnedPageIndex(pinnedIndex) + { + var match = 0 + + for (var i = 0; i < pages.length; ++i) { + if (!pages[i] || !pages[i].pin) + continue + + if (match === pinnedIndex) + return i + + match++ } + + return -1 } - function _loadJson() { - return signalsModel.loadJson(); + function overlayFont(pixelSize) + { + var size = Math.max(9, pixelSize) + if (control.globalApx && typeof control.globalApx.font_narrow === "function") + return control.globalApx.font_narrow(size) + return Qt.font({"pixelSize": size}) } - function _destroyActivePages() { - for (var i = 0; i < activePages.length; ++i) { - if (activePages[i] && typeof activePages[i].deleteFact === 'function') - activePages[i].deleteFact(); + function pageSpeedValue(page) + { + if (!page) + return 1.0 + + if (signalsModel && typeof signalsModel.normalizeSpeed === "function") + return signalsModel.normalizeSpeed(page.speed) + + return Number(page.speed) + } + + function pageSpeedText(page) + { + var speedValue = pageSpeedValue(page) + if (speedValue === 1.0) + return "" + + if (signalsModel && typeof signalsModel.formatSpeed === "function") + return signalsModel.formatSpeed(speedValue) + + var text = String(speedValue) + if (text.endsWith(".0")) + text = text.slice(0, -2) + return text + "x" + } + + function normalizeBindText(value) + { + var text = String(value === undefined || value === null ? "" : value).trim() + var simplePath = /^(?:mandala\.)?[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+(?:\.value)?$/ + + if (text === "" || !simplePath.test(text)) + return text + + if (text.startsWith("mandala.")) + text = text.slice(8) + if (text.endsWith(".value")) + text = text.slice(0, -6) + + return text + } + + function itemBind(item) + { + if (!item || item.bind === undefined) + return "" + + return normalizeBindText(item.bind) + } + + function defaultItemColor(index) + { + return Material.color(Material.Blue + index * 2) + } + + function itemColor(item, index) + { + if (item && item.color !== undefined && String(item.color).trim() !== "") + return item.color + return defaultItemColor(index) + } + + function itemTitle(item, index) + { + if (item) + return itemBind(item) + return qsTr("Item") + " " + (index + 1) + } + + function escapeHtml(text) + { + return String(text) + .replace(/&/g, "&") + .replace(//g, ">") + } + + function evaluateBinding(bind) + { + try { + return eval(normalizeBindText(bind)) + } catch (error) { + return NaN } - activePages = []; } - function _createMenuPage(pageData) { - var component = Qt.createComponent("MenuPage.qml"); - if (component.status !== Component.Ready) { - console.warn("Signals: cannot load MenuPage.qml: " + component.errorString()); - return null; + function evaluateCondition(expression, value, item, page) + { + if (!expression || String(expression).trim() === "") + return false + + var rawValue = value + var bind = itemBind(item) + var title = itemTitle(item, 0) + var pageNameValue = page && page.name ? page.name : "" + + try { + return !!eval(expression) + } catch (error) { + return false + } + } + + function evaluatePageState(index) + { + var page = pageAt(index) + var state = { + "warning": false, + "messages": [], + "toolTip": "" } - var pg = component.createObject(signalsWidget, { - "title": pageData.name ? pageData.name : qsTr("P"), - "isDirectEdit": true - }); - pg.parentFact = apx.fleet.local; - pg.load(pageData); - return pg; - } - - // Collect current page data back into sets JSON structure - function _buildSetsFromPages() { - var savedPages = []; - for (var i = 0; i < activePages.length; ++i) - savedPages.push(activePages[i].save()); - - // Load existing sets, replace active one - var json = signalsModel.loadJson(); - var sets = (json && json.sets) ? JSON.parse(JSON.stringify(json.sets)) : []; - if (sets.length === 0) sets.push({ title: activeSetTitle, pages: [] }); - if (activeSetIndex >= sets.length) activeSetIndex = 0; - sets[activeSetIndex].pages = savedPages; - sets[activeSetIndex].title = activeSetTitle; - return { sets: sets }; - } - - // ----------------------------------------------------------------- - // Telemetry update - // ----------------------------------------------------------------- - Connections { - target: apx.fleet.current.mandala - function onTelemetryDecoded() { - for (var i = 0; i < activePages.length; ++i) - activePages[i].updateChartsValues(); + if (!page) + return state + + var lines = ["" + escapeHtml(pageName(index)) + ""] + var items = page.items instanceof Array ? page.items : [] + + for (var i = 0; i < items.length; ++i) { + var item = items[i] + var title = itemTitle(item, i) + lines.push("\u25A0 " + + escapeHtml(title)) + + var value = evaluateBinding(itemBind(item)) + if (item.warning && evaluateCondition(item.warning, value, item, page)) { + state.warning = true + state.messages.push(qsTr("Warning") + ": " + title + " (" + item.warning + ")") + } + } + + if (state.messages.length > 0) { + lines.push("
" + escapeHtml(qsTr("Alerts")) + "") + for (var j = 0; j < state.messages.length; ++j) + lines.push(escapeHtml(state.messages[j])) } + + state.toolTip = lines.join("
") + return state + } + + function refreshPageStates() + { + var states = [] + for (var i = 0; i < pages.length; ++i) + states.push(evaluatePageState(i)) + pageStates = states + } + + function schedulePageStateRefresh() + { + if (pageStateRefreshPending) + return + + pageStateRefreshPending = true + Qt.callLater(function() { + pageStateRefreshPending = false + refreshPageStates() + }) + } + + function pageState(index) + { + return index >= 0 && index < pageStates.length + ? pageStates[index] + : { + "warning": false, + "messages": [], + "toolTip": "" + } } - // ----------------------------------------------------------------- - // Layout - // ----------------------------------------------------------------- + function selectSavedPage() + { + if (pages.length <= 0) { + selectedPage = "" + signals.facts = [] + return + } + + if (selectedPage !== "") { + for (var i = 0; i < pages.length; ++i) { + if (pageName(i) === selectedPage) + return + } + } + + selectedPage = pageName(0) + } + + function handleModelChanged() + { + Qt.callLater(function() { + selectSavedPage() + Qt.callLater(function() { + selectSavedPage() + schedulePageStateRefresh() + }) + }) + } + + function cyclePageSpeed() + { + if (!activePage) + return + + signalsModel.setPageSpeed(activePageIndex, + signalsModel.nextSpeedValue(activePage.speed)) + } + + function cycleSpeedForPage(page) + { + var index = pageIndexFor(page) + if (index < 0) + return + + signalsModel.setPageSpeed(index, + signalsModel.nextSpeedValue(page.speed)) + } + + function cycleSpeedForPinnedPage(pinnedIndex, page) + { + var index = pinnedPageIndex(pinnedIndex) + if (index < 0 && page) + index = pageIndexFor(page) + if (index < 0) + return + + var targetPage = pageAt(index) + if (!targetPage) + return + + signalsModel.setPageSpeed(index, + signalsModel.nextSpeedValue(targetPage.speed)) + } ColumnLayout { - id: mainLayout + id: layout anchors.fill: parent - spacing: 0 + spacing: 2 * control.uiScale - // Pinned pages stacked above the main view Repeater { - id: pinnedChartsRepeater - model: pinnedPages + model: control.pinnedPages + + delegate: Item { + id: pinnedChartPane + + required property var modelData + required property int index - delegate: ChartsView { Layout.fillWidth: true - Layout.preferredHeight: 80 * ui.scale - Layout.minimumHeight: 20 - facts: modelData.values - speedFactorValue: modelData.speed + Layout.preferredHeight: control.pinnedChartHeight + Layout.minimumHeight: 36 * control.uiScale + clip: true + + SignalsView { + anchors.fill: parent + uiContext: control.globalUi + apxContext: control.globalApx + facts: pinnedChartPane.modelData && pinnedChartPane.modelData.items instanceof Array + ? pinnedChartPane.modelData.items + : [] + speed: signalsModel.speedIndex(pinnedChartPane.modelData + ? pinnedChartPane.modelData.speed + : 1.0) + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton + onClicked: control.cycleSpeedForPinnedPage(pinnedChartPane.index, + pinnedChartPane.modelData) + } + + Rectangle { + anchors.top: parent.top + anchors.right: parent.right + anchors.topMargin: 4 * control.uiScale + anchors.rightMargin: 4 * control.uiScale + radius: 2 * control.uiScale + color: "#A0000000" + visible: titleLabel.text !== "" + + implicitWidth: titleColumn.implicitWidth + 10 * control.uiScale + implicitHeight: titleColumn.implicitHeight + 4 * control.uiScale + + Column { + id: titleColumn + anchors.centerIn: parent + spacing: 1 * control.uiScale + + Label { + id: titleLabel + anchors.horizontalCenter: parent.horizontalCenter + text: control.pageTitle(pinnedChartPane.modelData, pinnedChartPane.index) + color: "#FFFFFF" + font: control.overlayFont(10 * control.uiScale) + } + + Label { + id: titleSpeedLabel + anchors.horizontalCenter: parent.horizontalCenter + visible: text !== "" + text: control.pageSpeedText(pinnedChartPane.modelData) + color: "#CCCCCC" + font: control.overlayFont(8 * control.uiScale) + } + } + } } } - // Main (non-pinned) single chart - ChartsView { - id: singleChart - facts: [] + Item { + id: mainChartArea Layout.fillWidth: true Layout.fillHeight: true Layout.minimumHeight: 20 - Layout.preferredHeight: 130 * ui.scale - } + Layout.preferredHeight: 130 * control.uiScale + clip: true + + SignalsView { + id: signals + anchors.fill: parent + uiContext: control.globalUi + apxContext: control.globalApx + facts: [] + speed: control.activePage + ? signalsModel.speedIndex(control.activePage.speed) + : signalsModel.speedIndex(1.0) + } - ButtonGroup { - id: pageButtonGroup - } + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton + onClicked: control.cyclePageSpeed() + } - // Bottom bar: page tabs, set label, speed button - RowLayout { - id: bottomBar - Layout.fillWidth: true - Layout.margins: Style.spacing - spacing: 3 - Layout.maximumHeight: 24 * ui.scale + Rectangle { + anchors.top: parent.top + anchors.right: parent.right + anchors.topMargin: 4 * control.uiScale + anchors.rightMargin: 4 * control.uiScale + radius: 2 * control.uiScale + color: "#A0000000" + visible: mainTitleLabel.text !== "" + + implicitWidth: mainTitleColumn.implicitWidth + 10 * control.uiScale + implicitHeight: mainTitleColumn.implicitHeight + 4 * control.uiScale + + Column { + id: mainTitleColumn + anchors.centerIn: parent + spacing: 1 * control.uiScale + + Label { + id: mainTitleLabel + anchors.horizontalCenter: parent.horizontalCenter + text: control.activePage ? control.pageTitle(control.activePage, control.activePageIndex) : "" + color: "#FFFFFF" + font: control.overlayFont(10 * control.uiScale) + } - Repeater { - id: pageTabsRepeater - model: activePages - - delegate: PageButton { - page: modelData - text: modelData.title - Layout.fillHeight: true - ButtonGroup.group: pageButtonGroup - checked: !modelData.pinned && modelData === signalsWidget.currentPage - onClicked: { - var wasCurrent = modelData === signalsWidget.currentPage; - if (modelData.pinned) { - if (modelData) - modelData.trigger(); - } else if (wasCurrent) { - // already active — open page editor - if (modelData) modelData.trigger(); - } else { - signalsWidget.activatePage(modelData); - } + Label { + id: mainSpeedLabel + anchors.horizontalCenter: parent.horizontalCenter + visible: text !== "" + text: control.pageSpeedText(control.activePage) + color: "#CCCCCC" + font: control.overlayFont(8 * control.uiScale) } } } - Item { Layout.fillWidth: true } - - // Set name label — click opens sets editor - TextButton { - text: activeSetTitle || qsTr("default") - Layout.fillHeight: true - Layout.minimumWidth: height * 3 - toolTip: qsTr("Click to edit chart sets") - onClicked: openSetsEditor() + Rectangle { + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.rightMargin: 4 * control.uiScale + anchors.bottomMargin: 4 * control.uiScale + radius: 2 * control.uiScale + color: "#A0000000" + visible: mainSetLabel.text !== "" + + implicitWidth: mainSetLabel.implicitWidth + 10 * control.uiScale + implicitHeight: mainSetLabel.implicitHeight + 4 * control.uiScale + + Label { + id: mainSetLabel + anchors.centerIn: parent + text: control.activeSetTitle() + color: "#FFFFFF" + font: control.overlayFont(10 * control.uiScale) + } } - // Speed button — cycles per-page speed factor - TextButton { - text: currentPage ? (currentPage.speed + "x") : "1x" - Layout.fillHeight: true - Layout.minimumWidth: height * 3 - toolTip: qsTr("Chart scroll speed") - onClicked: { - if (!currentPage) return; - var factors = [0.2, 0.5, 1, 2, 4]; - var idx = factors.indexOf(currentPage.speed); - var next = (idx >= 0 && idx < factors.length - 1) - ? factors[idx + 1] : factors[0]; - currentPage.setSpeed(next); - saveSettings(); - } + Label { + anchors.centerIn: parent + visible: control.pages.length <= 0 + text: qsTr("No pages in the active set.") + color: Material.secondaryTextColor } } - } - // + button (top-right) — opens current page editor - IconButton { - anchors.top: parent.top - anchors.right: parent.right - anchors.margins: Style.spacing - size: Style.buttonSize * 0.7 - iconName: "plus" - toolTip: qsTr("Edit current page") - opacity: ui.effects ? (hovered ? 1 : 0.5) : 1 - onTriggered: { - if (currentPage) - currentPage.trigger(); + ButtonGroup { + id: buttonGroup + + onCheckedButtonChanged: { + if (checkedButton) { + control.selectedPage = checkedButton.text + signals.facts = Qt.binding(function() { + return checkedButton ? checkedButton.values : [] + }) + } else { + signals.facts = [] + } + } } - } - // ----------------------------------------------------------------- - // Sets editor popup - // ----------------------------------------------------------------- + RowLayout { + id: bottomArea + Layout.fillWidth: true + Layout.margins: Style.spacing + spacing: 3 + Layout.maximumHeight: 24 * control.uiScale - property var setsEditorPopup: null + Repeater { + id: tabsRepeater + model: control.pages - function openSetsEditor() { - if (setsEditorPopup) return; - var c = Qt.createComponent("SignalsMenuPopup.qml", Component.PreferSynchronous, ui.window); - if (c.status === Component.Ready) { - var obj = c.createObject(ui.window); - setsEditorPopup = obj; - obj.accepted.connect(function() { loadSettings(); }); - obj.closed.connect(function() { setsEditorPopup = null; }); - obj.open(); - } else { - console.warn("Signals: cannot open sets editor: " + c.errorString()); - } - } + onItemAdded: function(index, item) { + if (index === tabsRepeater.count - 1) + Qt.callLater(control.selectSavedPage) + } - // ----------------------------------------------------------------- - // Legacy migration - // ----------------------------------------------------------------- + delegate: SignalButton { + required property int index - function _migrateLegacy(oldJson) { - return signalsModel.migrateLegacy(oldJson); - } + property int pageIndex: index - // ----------------------------------------------------------------- - // Default set (mirrors the hardcoded buttons from the old Signals.qml) - // ----------------------------------------------------------------- + text: control.pageName(index) + checked: control.selectedPage !== "" + ? text === control.selectedPage + : index === 0 + values: control.pageFacts(index) + pageToolTip: control.pageState(index).toolTip + pageWarning: control.pageState(index).warning + } + } - function _buildDefaultSet() { - return signalsModel.buildDefaultSet(); + IconButton { + iconName: "plus" + toolTip: qsTr("Edit chart configuration") + Layout.fillHeight: true + Layout.minimumWidth: height + onTriggered: control.openSetsEditor() + } + } } } diff --git a/src/Plugins/Tools/Signals/SignalsMenu.qml b/src/Plugins/Tools/Signals/SignalsMenu.qml deleted file mode 100644 index 9d6c61e2e..000000000 --- a/src/Plugins/Tools/Signals/SignalsMenu.qml +++ /dev/null @@ -1,184 +0,0 @@ -/* - * APX Autopilot project - * - * Copyright (c) 2003-2020, Aliaksei Stratsilatau - * All rights reserved - * - * This file is part of APX Ground Control. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program. If not, see . - */ -import QtQuick - -import APX.Facts - -// Top-level sets editor — mirrors NumbersMenu. -// Loaded as a pinned Fact menu popup. -// Persists to signals.json using same {active, sets} structure as numbers.json. -Fact { - id: setsFact - - readonly property QtObject signalsModel: SignalsModel {} - - property var defaults - property string settingsName: "signals" - property bool destroyOnClose: true - - name: settingsName - flags: (Fact.Group | Fact.DragChildren) - title: qsTr("Signals") + ": " + settingsName - descr: qsTr("Signals chart sets editor") - icon: "poll" - - signal accepted() - - Component.onCompleted: open() - - function open() { - if (!parentFact) { - var p = parent; - parentFact = apx.fleet.local; - parent = p; - } - loadSettings(); - } - - function close() { - if (!destroyOnClose) { - setsFact.deleteChildren(); - loadSettings(); - menuBack(); - return; - } - setsFact.deleteChildren(); - menuBack(); - parentFact = null; - } - - function loadSettings() { - var sets = []; - var json = signalsModel.loadJson(); - - var currentSetIdx = -1; - - if (json && json.sets) { - for (var i in json.sets) { - var set = json.sets[i]; - if (!set || !set.pages) continue; - sets.push(set); - } - } - currentSetIdx = signalsModel.activeIndex(json); - - for (var i in sets) { - var c = createSetFact(sets[i]); - if (!c) continue; - c.selected.connect(select); - c.selected.connect(saveSettings); - } - select(currentSetIdx); - } - - function saveSettings() { - var json = signalsModel.loadJson(); - if (!json.active) - json.active = {}; - json.active[settingsName] = 0; - json.sets = []; - for (var i = 0; i < size; ++i) { - var setf = child(i); - var set = setf.save(); - if (!set) continue; - json.sets.push(set); - if (setf.active) - json.active[settingsName] = i; - } - signalsModel.saveJson(json); - accepted(); - close(); - } - - function createSetFact(setData) { - var component = Qt.createComponent("MenuSet.qml"); - if (component.status !== Component.Ready) { - console.warn("SignalsMenu: cannot load MenuSet.qml: " + component.errorString()); - return null; - } - var c = component.createObject(setsFact, { - "title": setData.title ? setData.title : qsTr("set"), - "pages": setData.pages ? setData.pages : [] - }); - c.parentFact = setsFact; - return c; - } - - function select(num) { - for (var i = 0; i < setsFact.size; ++i) { - var set = setsFact.child(i); - set.active = (set.num === num); - } - } - - // Legacy migration: old flat {page, signalas} → {active, sets} - function migrateLegacy(oldJson) { - return signalsModel.migrateLegacy(oldJson); - } - - // Default set matching the hardcoded Signals.qml pages - function buildDefaultSet() { - return signalsModel.buildDefaultSet(); - } - - // Actions - Fact { - title: qsTr("Add set") - flags: Fact.Action - icon: "plus-circle" - onTriggered: { - var newSet = { title: "#" + (setsFact.size + 1), pages: [] }; - var c = createSetFact(newSet); - if (!c) return; - c.selected.connect(select); - c.selected.connect(saveSettings); - c.trigger(); - } - } - - Fact { - title: qsTr("Reset to defaults") - flags: Fact.Action - icon: "restore" - onTriggered: { - var defaultSet = buildDefaultSet(); - var defaultFact = setsFact.size > 0 ? setsFact.child(0) : null; - if (!defaultFact) { - defaultFact = createSetFact(defaultSet); - if (!defaultFact) - return; - defaultFact.selected.connect(select); - defaultFact.selected.connect(saveSettings); - } else { - defaultFact.loadSet(defaultSet); - } - select(0); - } - } - - Fact { - title: qsTr("Save") - flags: (Fact.Action | Fact.Apply) - icon: "check-circle" - onTriggered: saveSettings() - } -} diff --git a/src/Plugins/Tools/Signals/SignalsModel.qml b/src/Plugins/Tools/Signals/SignalsModel.qml index 6411b76f7..a0e3ad816 100644 --- a/src/Plugins/Tools/Signals/SignalsModel.qml +++ b/src/Plugins/Tools/Signals/SignalsModel.qml @@ -19,152 +19,684 @@ * You should have received a copy of the GNU Lesser General Public License * along with this program. If not, see . */ -import QtQuick +import QtQml QtObject { - id: signalsModel + id: model + /* + * signals.json schema: + * { + * "active": { "signals": 0 }, + * "sets": [ + * { + * "title": "default", + * "pages": [ + * { + * "name": "R", + * "pin": false, + * "speed": 1.0, + * "items": [ + * { + * "bind": "est.att.roll", + * "color": "", + * "filters": [], + * "warning": "", + * "save": "" + * } + * ] + * } + * ] + * } + * ] + * } + * + * Legacy migration: + * { "page": "R", "signalas": [ ... ] } becomes one set titled "default" + * with one page named from "page" (or "page 1" when missing). Legacy item + * fields are copied forward and normalized into the new item schema. + */ + + property string settingsFileName: "signals.json" property string settingsName: "signals" - property string fileName: "signals.json" + property bool autoLoad: true + + // Provided by the host widget or a test harness. + property var prefsAdapter: null + + property var speedFactors: [0.2, 0.5, 1.0, 2.0, 4.0] + property var active: ({signals: 0}) + property int activeSignals: -1 + property var sets: [] + property bool loaded: false + property var filterRegistry: FilterRegistry {} + + readonly property bool hasSets: sets.length > 0 + readonly property var activeSet: hasSets ? sets[activeSignals] : null + readonly property var pages: activeSet && (activeSet.pages instanceof Array) ? activeSet.pages : [] + readonly property var pinnedPages: filterPages(true) + readonly property var unpinnedPages: filterPages(false) + + signal settingsLoaded(var settings) + signal settingsSaved(var settings) - function loadRawJson() { - var fileData = application.prefs.loadFile(fileName); - return fileData ? JSON.parse(fileData) : {}; + Component.onCompleted: { + if (autoLoad) + loadSettings() } - function saveJson(json) { - application.prefs.saveFile(fileName, JSON.stringify(json, " ", 2)); + function settingsStore() + { + return prefsAdapter } - function clone(value) { - return JSON.parse(JSON.stringify(value)); + function defaultSetTitle(index) + { + return qsTr("Set") + " " + (index + 1) } - function normalizeJson(json) { - var normalized = json && typeof json === "object" ? clone(json) : {}; + function defaultPageTitle(index) + { + return qsTr("Page") + " " + (index + 1) + } - if (normalized.signalas && !normalized.sets) - normalized = migrateLegacy(normalized); + function legacyDefaultPageTitle() + { + return qsTr("page 1") + } - if (!normalized.active) - normalized.active = {}; + function createDefaultItem(bind) + { + return { + "bind": bind, + "color": "", + "filters": [], + "warning": "", + "save": "" + } + } - var sets = Array.isArray(normalized.sets) ? normalized.sets.filter(function(setData) { - return !!setData; - }) : []; - if (sets.length === 0) - sets = [buildDefaultSet()]; + function createDefaultPage(name, bindings) + { + var page = { + "name": name, + "pin": false, + "speed": 1.0, + "items": [] + } - normalized.sets = sets; + for (var i = 0; i < bindings.length; ++i) + page.items.push(createDefaultItem(bindings[i])) - var idx = normalized.active[settingsName]; - if (idx === undefined || idx < 0 || idx >= sets.length) - normalized.active[settingsName] = 0; + return page + } - return normalized; + function createDefaultSet() + { + var specs = [ + { + "name": "R", + "bindings": ["cmd.att.roll", "est.att.roll"] + }, + { + "name": "P", + "bindings": ["cmd.att.pitch", "est.att.pitch"] + }, + { + "name": "Y", + "bindings": ["cmd.pos.bearing", "cmd.att.yaw", "est.att.yaw"] + }, + { + "name": "Axy", + "bindings": ["est.acc.x", "est.acc.y"] + }, + { + "name": "Az", + "bindings": ["est.acc.z"] + }, + { + "name": "G", + "bindings": ["est.gyro.x", "est.gyro.y", "est.gyro.z"] + }, + { + "name": "Pt", + "bindings": ["est.pos.altitude", "est.pos.vspeed", "est.air.airspeed"] + }, + { + "name": "Ctr", + "bindings": [ + "ctr.att.ail", + "ctr.att.elv", + "ctr.att.rud", + "ctr.eng.thr", + "ctr.eng.prop", + "ctr.str.rud" + ] + }, + { + "name": "RC", + "bindings": ["cmd.rc.roll", "cmd.rc.pitch", "cmd.rc.thr", "cmd.rc.yaw"] + }, + { + "name": "Usr", + "bindings": [ + "est.usr.u1", + "est.usr.u2", + "est.usr.u3", + "est.usr.u4", + "est.usr.u5", + "est.usr.u6" + ] + } + ] + var set = { + "title": "default", + "pages": [] + } + + for (var i = 0; i < specs.length; ++i) + set.pages.push(createDefaultPage(specs[i].name, specs[i].bindings)) + + return set } - function loadJson() { - return normalizeJson(loadRawJson()); + function createDefaultSettings() + { + return { + "active": { + "signals": 0 + }, + "sets": [createDefaultSet()] + } } - function activeIndex(json) { - var normalized = normalizeJson(json); - return normalized.active[settingsName]; + function copyValue(value) + { + if (value === undefined || value === null) + return value + + return JSON.parse(JSON.stringify(value)) } - function activeSet(json) { - var normalized = normalizeJson(json); - return normalized.sets[normalized.active[settingsName]]; + function asObject(value) + { + if (!value || value instanceof Array || typeof value !== "object") + return {} + + return value } - function migrateLegacy(oldJson) { - var items = (oldJson.signalas || []) - .map(function(it) { - return { - bind: it.bind || it.name || "", - title: it.title || "", - color: it.color || "", - filters: [], - warning: it.warning || it.warn || "", - alarm: it.alarm || "", - act: it.act || "", - save: it.save || "" - }; - }) - .filter(function(it) { return it.bind !== ""; }); + function asArray(value) + { + return value instanceof Array ? value : [] + } + + function asString(value, fallback) + { + if (fallback === undefined) + fallback = "" + + if (value === undefined || value === null) + return fallback + + return String(value) + } + + function asBool(value, fallback) + { + if (value === undefined || value === null) + return fallback + + return !!value + } + + function asNumber(value, fallback) + { + var number = Number(value) + return isFinite(number) ? number : fallback + } + + function clamp(value, minValue, maxValue) + { + return Math.max(minValue, Math.min(maxValue, value)) + } + + function normalizeSpeed(value) + { + var number = asNumber(value, 1.0) + + for (var i = 0; i < speedFactors.length; ++i) { + if (speedFactors[i] === number) + return number + } + + return 1.0 + } + + function speedIndex(value) + { + var speedValue = normalizeSpeed(value) + + for (var i = 0; i < speedFactors.length; ++i) { + if (speedFactors[i] === speedValue) + return i + } + + return 2 + } + + function nextSpeedValue(value) + { + var index = speedIndex(value) + return speedFactors[(index + 1) % speedFactors.length] + } + + function formatSpeed(value) + { + var text = String(normalizeSpeed(value)) + if (text.endsWith(".0")) + text = text.slice(0, -2) + return text + "x" + } + + function normalizeActiveIndex(index, count) + { + var number = Math.floor(asNumber(index, 0)) + + if (count <= 0) + return 0 + + if (number < 0 || number >= count) + return 0 + + return number + } + + function parseSettingsText(text) + { + if (!text) + return {} + + try { + return JSON.parse(text) + } catch (error) { + console.warn("SignalsModel: invalid " + settingsFileName + ": " + error) + } + + return {} + } + + function isLegacySettings(json) + { + if (!json || typeof json !== "object") + return false + + if (json.sets instanceof Array) + return false + + return json.signalas instanceof Array || json.page !== undefined + } + + function normalizeFilter(filterData) + { + return filterRegistry.normalizeFilter(filterData) + } + + function normalizeFilters(filtersData) + { + return filterRegistry.normalizeFilters(filtersData) + } + + function normalizeBind(value) + { + var text = asString(value, "").trim() + var simplePath = /^(?:mandala\.)?[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+(?:\.value)?$/ + + if (text === "" || !simplePath.test(text)) + return text + + if (text.startsWith("mandala.")) + text = text.slice(8) + if (text.endsWith(".value")) + text = text.slice(0, -6) + + return text + } + + function normalizeItem(itemData) + { + var source = asObject(itemData) + var item = copyValue(source) || {} + + item.bind = normalizeBind(source.bind !== undefined ? source.bind : source.name) + if (!item.bind) + return null + + item.color = asString(source.color, "") + item.filters = normalizeFilters(source.filters) + item.warning = asString(source.warning !== undefined ? source.warning : source.warn, "") + item.save = asString(source.save, "") + + delete item.title + delete item.act + delete item.warn + delete item.name + delete item.descr + delete item.value + + if (item.opts && item.color === "") + item.color = asString(item.opts.color, "") + + delete item.opts + + return item + } + + function normalizeItems(itemsData) + { + var list = [] + var source = asArray(itemsData) + + for (var i = 0; i < source.length; ++i) { + var item = normalizeItem(source[i]) + if (item) + list.push(item) + } + + return list + } + + function normalizePage(pageData, index) + { + var source = asObject(pageData) + var page = copyValue(source) || {} + + page.name = asString(source.name, defaultPageTitle(index)) + page.pin = asBool(source.pin, false) + page.speed = normalizeSpeed(source.speed) + page.items = normalizeItems(source.items) + + return page + } + + function normalizePages(pagesData) + { + var list = [] + var source = asArray(pagesData) + + for (var i = 0; i < source.length; ++i) + list.push(normalizePage(source[i], i)) + + return list + } + + function normalizeSet(setData, index) + { + var source = asObject(setData) + var set = copyValue(source) || {} + + set.title = asString(source.title, defaultSetTitle(index)) + set.pages = normalizePages(source.pages) + + return set + } + + function normalizeSets(setsData) + { + var list = [] + var source = asArray(setsData) + + for (var i = 0; i < source.length; ++i) + list.push(normalizeSet(source[i], i)) + + return list + } + + function normalizeLegacySettings(json) + { + var source = asObject(json) return { - active: { signals: 0 }, - sets: [{ - title: qsTr("default"), - pages: [{ - name: oldJson.page || qsTr("page 1"), - pin: false, - speed: 1.0, - items: items - }] - }] - }; - } - - function buildDefaultSet() { - return { - title: qsTr("default"), - pages: [ - { name: "R", pin: false, speed: 1.0, items: [ - { bind: "mandala.cmd.att.roll.value", title: qsTr("roll cmd") }, - { bind: "mandala.est.att.roll.value", title: qsTr("roll") } - ]}, - { name: "P", pin: false, speed: 1.0, items: [ - { bind: "mandala.cmd.att.pitch.value", title: qsTr("pitch cmd") }, - { bind: "mandala.est.att.pitch.value", title: qsTr("pitch") } - ]}, - { name: "Y", pin: false, speed: 1.0, items: [ - { bind: "mandala.cmd.pos.bearing.value", title: qsTr("bearing cmd") }, - { bind: "mandala.cmd.att.yaw.value", title: qsTr("yaw cmd") }, - { bind: "mandala.est.att.yaw.value", title: qsTr("yaw") } - ]}, - { name: "Axy", pin: false, speed: 1.0, items: [ - { bind: "mandala.est.acc.x.value", title: qsTr("Ax") }, - { bind: "mandala.est.acc.y.value", title: qsTr("Ay") } - ]}, - { name: "Az", pin: false, speed: 1.0, items: [ - { bind: "mandala.est.acc.z.value", title: qsTr("Az") } - ]}, - { name: "G", pin: false, speed: 1.0, items: [ - { bind: "mandala.est.gyro.x.value", title: qsTr("Gx") }, - { bind: "mandala.est.gyro.y.value", title: qsTr("Gy") }, - { bind: "mandala.est.gyro.z.value", title: qsTr("Gz") } - ]}, - { name: "Pt", pin: false, speed: 1.0, items: [ - { bind: "mandala.est.pos.altitude.value", title: qsTr("alt") }, - { bind: "mandala.est.pos.vspeed.value", title: qsTr("vspd") }, - { bind: "mandala.est.air.airspeed.value", title: qsTr("airspeed") } - ]}, - { name: "Ctr", pin: false, speed: 1.0, items: [ - { bind: "mandala.ctr.att.ail.value", title: qsTr("ail") }, - { bind: "mandala.ctr.att.elv.value", title: qsTr("elv") }, - { bind: "mandala.ctr.att.rud.value", title: qsTr("rud") }, - { bind: "mandala.ctr.eng.thr.value", title: qsTr("thr") }, - { bind: "mandala.ctr.eng.prop.value", title: qsTr("prop") }, - { bind: "mandala.ctr.str.rud.value", title: qsTr("str.rud") } - ]}, - { name: "RC", pin: false, speed: 1.0, items: [ - { bind: "mandala.cmd.rc.roll.value", title: qsTr("RC roll") }, - { bind: "mandala.cmd.rc.pitch.value", title: qsTr("RC pitch") }, - { bind: "mandala.cmd.rc.thr.value", title: qsTr("RC thr") }, - { bind: "mandala.cmd.rc.yaw.value", title: qsTr("RC yaw") } - ]}, - { name: "Usr", pin: false, speed: 1.0, items: [ - { bind: "mandala.est.usr.u1.value", title: qsTr("u1") }, - { bind: "mandala.est.usr.u2.value", title: qsTr("u2") }, - { bind: "mandala.est.usr.u3.value", title: qsTr("u3") }, - { bind: "mandala.est.usr.u4.value", title: qsTr("u4") }, - { bind: "mandala.est.usr.u5.value", title: qsTr("u5") }, - { bind: "mandala.est.usr.u6.value", title: qsTr("u6") } - ]} + "active": { + "signals": 0 + }, + "sets": [ + { + "title": "default", + "pages": [ + { + "name": asString(source.page, legacyDefaultPageTitle()), + "pin": false, + "speed": 1.0, + "items": normalizeItems(source.signalas) + } + ] + } ] - }; + } + } + + function normalizeSettings(json) + { + var root = isLegacySettings(json) ? normalizeLegacySettings(json) : asObject(json) + var normalizedSets = normalizeSets(root.sets) + + if (normalizedSets.length <= 0) + normalizedSets = normalizeSets([createDefaultSet()]) + + var normalized = { + "active": { + "signals": 0 + }, + "sets": normalizedSets + } + + normalized.active.signals = normalizeActiveIndex(asObject(root.active).signals, + normalized.sets.length) + return normalized + } + + function assignSettings(normalized) + { + sets = normalized.sets + active = normalized.active + activeSignals = normalized.active.signals + } + + function applySettings(settings) + { + var normalized = normalizeSettings(settings) + + assignSettings(normalized) + loaded = true + + return exportSettings() + } + + function exportSettings() + { + return normalizeSettings({ + "active": { + "signals": hasSets ? activeSignals : 0 + }, + "sets": sets + }) + } + + function loadSettings() + { + var store = settingsStore() + var text = "" + var settings = createDefaultSettings() + + if (store && typeof store.loadFile === "function") { + text = store.loadFile(settingsFileName) + if (text) + settings = parseSettingsText(text) + } + + var exported = applySettings(settings) + settingsLoaded(exported) + return exported + } + + function saveSettings(settings) + { + if (settings !== undefined) + applySettings(settings) + + var exported = exportSettings() + var store = settingsStore() + + if (store && typeof store.saveFile === "function") + store.saveFile(settingsFileName, JSON.stringify(exported, " ", 2)) + + settingsSaved(exported) + return exported + } + + function setActiveSignals(index) + { + assignSettings(normalizeSettings({ + "active": { + "signals": index + }, + "sets": sets + })) + } + + function setSets(list) + { + assignSettings(normalizeSettings({ + "active": { + "signals": activeSignals + }, + "sets": list + })) + } + + function mutateActiveSet(mutator) + { + if (!hasSets || typeof mutator !== "function") + return exportSettings() + + var settings = exportSettings() + var setIndex = normalizeActiveIndex(settings.active.signals, settings.sets.length) + var setData = setIndex >= 0 && setIndex < settings.sets.length ? settings.sets[setIndex] : null + if (!setData) + return settings + + mutator(setData) + return saveSettings(settings) + } + + function pageAt(index) + { + return index >= 0 && index < pages.length ? pages[index] : null + } + + function setPageSpeed(index, speedValue) + { + return mutateActiveSet(function(setData) { + if (!setData.pages || index < 0 || index >= setData.pages.length) + return + + setData.pages[index].speed = normalizeSpeed(speedValue) + }) + } + + function togglePagePin(index) + { + return mutateActiveSet(function(setData) { + if (!setData.pages || index < 0 || index >= setData.pages.length) + return + + setData.pages[index].pin = !setData.pages[index].pin + }) + } + + function updatePage(index, pageData) + { + return mutateActiveSet(function(setData) { + if (!setData.pages || index < 0 || index >= setData.pages.length) + return + + setData.pages[index] = normalizePage(pageData, index) + }) + } + + function removePage(index) + { + return mutateActiveSet(function(setData) { + if (!setData.pages || index < 0 || index >= setData.pages.length) + return + + setData.pages.splice(index, 1) + if (setData.pages.length <= 0) { + setData.pages.push(normalizePage({ + "name": defaultPageTitle(0), + "pin": false, + "speed": 1.0, + "items": [] + }, 0)) + } + }) + } + + function addItemToPage(pageIndex, itemData) + { + return mutateActiveSet(function(setData) { + if (!setData.pages || pageIndex < 0 || pageIndex >= setData.pages.length) + return + + var item = normalizeItem(itemData) + if (!item) + return + + setData.pages[pageIndex].items.push(item) + }) + } + + function isSaveTargetUsedInActiveSet(saveTarget, skipPageIndex, skipItemIndex) + { + var target = asString(saveTarget, "").trim() + if (target === "") + return false + + if (skipPageIndex === undefined) + skipPageIndex = -1 + if (skipItemIndex === undefined) + skipItemIndex = -1 + + for (var i = 0; i < pages.length; ++i) { + if (i === skipPageIndex && skipItemIndex < 0) + continue + + var page = pages[i] + var items = page && page.items instanceof Array ? page.items : [] + for (var j = 0; j < items.length; ++j) { + if (i === skipPageIndex && j === skipItemIndex) + continue + + var item = items[j] + if (asString(item ? item.save : "", "").trim() === target) + return true + } + } + + return false + } + + function filterPages(pinned) + { + var list = [] + + for (var i = 0; i < pages.length; ++i) { + var page = pages[i] + if (!!page.pin === pinned) + list.push(page) + } + + return list } } diff --git a/src/Plugins/Tools/Signals/SignalsPlugin.qml b/src/Plugins/Tools/Signals/SignalsPlugin.qml index 39305b7c4..27d3c54e1 100755 --- a/src/Plugins/Tools/Signals/SignalsPlugin.qml +++ b/src/Plugins/Tools/Signals/SignalsPlugin.qml @@ -31,7 +31,9 @@ AppPlugin { descr: qsTr("Realtime chart") icon: "poll" - sourceComponent: Signals { } + sourceComponent: Signals { + implicitWidth: Style.buttonSize * 15 + } uiComponent: "main" onConfigure: { ui.main.add(plugin, GroundControl.Layout.Main) diff --git a/src/Plugins/Tools/Signals/SignalsView.qml b/src/Plugins/Tools/Signals/SignalsView.qml new file mode 100644 index 000000000..967f35ecd --- /dev/null +++ b/src/Plugins/Tools/Signals/SignalsView.qml @@ -0,0 +1,496 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick +import QtCharts +import QtQuick.Controls.Material +import QtQml + +Item { + id: chartItem + + property var facts: [] + property var uiContext: ui + property var apxContext: apx + + property bool openGL: false //apx.settings.graphics.opengl.value + property bool smoothLines: uiContext ? uiContext.smooth : false + + property real speed: 0 + property real lineWidth: uiContext && uiContext.antialiasing ? 1.5 : 1 + property real lineWidthCmd: uiContext && uiContext.antialiasing ? 2.1 : 2 + + property var speedFactor: [0.2, 0.5, 1, 2, 4] + property real speedFactorValue: speed < 0 + ? speedFactor[0] + : speed >= speedFactor.length + ? speedFactor[speedFactor.length - 1] + : speedFactor[speed] + + property var seriesState: [] + + FilterRegistry { + id: filterRegistry + } + + QtObject { + id: colorProbe + + property color value: "white" + } + + onFactsChanged: chartView.reset() + + Component.onDestruction: destroySeriesState() + + Connections { + target: chartItem.apxContext ? chartItem.apxContext.fleet.current.mandala : null + + function onTelemetryDecoded() + { + chartView.appendData() + } + } + + function normalizeBindText(value) + { + var text = String(value === undefined || value === null ? "" : value).trim() + var simplePath = /^(?:mandala\.)?[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+(?:\.value)?$/ + + if (text === "" || !simplePath.test(text)) + return text + + if (text.startsWith("mandala.")) + text = text.slice(8) + if (text.endsWith(".value")) + text = text.slice(0, -6) + + return text + } + + function itemBind(fact) + { + if (!fact) + return "" + + if (fact.bind !== undefined && fact.bind !== null && String(fact.bind) !== "") + return normalizeBindText(fact.bind) + if (fact.name !== undefined && fact.name !== null) + return normalizeBindText(fact.name) + + return "" + } + + function itemTitle(fact, index) + { + var bind = itemBind(fact) + if (bind !== "") + return bind + + return qsTr("Signal") + " " + (index + 1) + } + + function defaultSeriesColor(index) + { + return Material.color(Material.Blue + index * 2) + } + + function resolvedColor(value, fallback) + { + var source = value + if (source === undefined || source === null || source === "") + source = fallback + + colorProbe.value = source + return colorProbe.value + } + + function itemColor(fact, index) + { + if (fact) { + if (fact.color !== undefined && fact.color !== null && String(fact.color) !== "") + return resolvedColor(fact.color, defaultSeriesColor(index)) + + if (fact.opts && fact.opts.color) + return resolvedColor(fact.opts.color, defaultSeriesColor(index)) + } + + return resolvedColor("", defaultSeriesColor(index)) + } + + function itemSaveTarget(fact) + { + if (!fact || fact.save === undefined || fact.save === null) + return "" + + return String(fact.save).trim() + } + + function evaluateValue(fact, bind) + { + if (fact && fact.value !== undefined) + return fact.value + + if (!bind) + return NaN + + try { + return eval(normalizeBindText(bind)) + } catch (error) { + return NaN + } + } + + function destroySeriesState() + { + for (var i = 0; i < seriesState.length; ++i) { + var state = seriesState[i] + if (!state || !(state.filters instanceof Array)) + continue + + for (var j = 0; j < state.filters.length; ++j) { + var filterObject = state.filters[j] + if (filterObject) + filterObject.destroy() + } + } + + seriesState = [] + } + + function invokeFilterMethod(filterObject, methodName, argument) + { + if (!filterObject) + return undefined + + var method = filterObject[methodName] + if (!method) + return undefined + + if (argument === undefined) + return method.call(filterObject) + + return method.call(filterObject, argument) + } + + function buildSeriesState(fact, index) + { + var bind = itemBind(fact) + var filterDefs = filterRegistry.normalizeFilters(fact && fact.filters instanceof Array + ? fact.filters + : []) + var filters = [] + + for (var i = 0; i < filterDefs.length; ++i) { + var filterData = filterDefs[i] + var source = filterRegistry.componentSource(filterData.type) + if (!source) + continue + + var component = Qt.createComponent(source) + if (!component || component.status !== Component.Ready) { + if (component) + component.destroy() + continue + } + + var filterObject = component.createObject(chartItem) + component.destroy() + + if (!filterObject) + continue + + invokeFilterMethod(filterObject, "load", filterData) + invokeFilterMethod(filterObject, "reset") + filters.push(filterObject) + } + + var saveTarget = itemSaveTarget(fact) + + return { + "bind": bind, + "title": itemTitle(fact, index), + "color": itemColor(fact, index), + "filters": filters, + "saveTarget": saveTarget, + "saveFact": saveTarget !== "" && chartItem.apxContext + ? chartItem.apxContext.fleet.current.mandala.fact(saveTarget, true) + : null + } + } + + function seriesStateAt(index) + { + while (seriesState.length <= index) + seriesState.push(null) + + if (!seriesState[index]) + seriesState[index] = buildSeriesState(chartItem.facts[index], index) + + return seriesState[index] + } + + function applyFilterChain(state, value) + { + var filtered = value + + if (!state || !(state.filters instanceof Array)) + return filtered + + for (var i = 0; i < state.filters.length; ++i) { + var filterObject = state.filters[i] + var stepFilter = filterObject ? filterObject["step"] : null + if (!stepFilter) + continue + + filtered = stepFilter.call(filterObject, filtered) + } + + return filtered + } + + function writeSavedValue(state, value) + { + if (!state || !state.saveFact) + return + + state.saveFact.setRawValueLocal(value) + } + + function isCommandBind(bind) + { + return bind.indexOf("cmd.") === 0 || bind.indexOf("cmd") === 0 + } + + function applySeriesStyle(series, state) + { + if (!series || !state) + return + + var color = state.color + + if (isCommandBind(state.bind)) { + series.width = Qt.binding(function() { + return lineWidthCmd + }) + series.color = Qt.hsla(color.hslHue, + color.hslSaturation / 2, + color.hslLightness * 1.2, + 1) + } else { + series.width = Qt.binding(function() { + return lineWidth + }) + series.color = color + } + } + + function updateSeriesColor(index, color) + { + if (index < 0 || index >= chartItem.facts.length) + return + + var state = seriesStateAt(index) + if (!state) + return + + state.color = resolvedColor(color, defaultSeriesColor(index)) + + if (index < chartView.count) + applySeriesStyle(chartView.series(index), state) + } + + ChartView { + id: chartView + + antialiasing: chartItem.uiContext && chartItem.uiContext.antialiasing + legend.visible: false + margins.top: 0 + margins.left: 0 + margins.bottom: 0 + margins.right: 0 + + anchors.fill: parent + property int margin: -8 + anchors.topMargin: margin + anchors.bottomMargin: margin + anchors.leftMargin: margin + anchors.rightMargin: margin + + plotAreaColor: "black" + backgroundColor: "black" + backgroundRoundness: 0 + dropShadowEnabled: false + + property int samples: Math.min(1000, + Math.max(25, width / (3 * chartItem.speedFactorValue))) + property int time: 0 + + property bool dataExist: false + + ValueAxis { + id: axisX + + property real t: chartView.time + + Behavior on t { + enabled: chartItem.uiContext && chartItem.uiContext.smooth && chartView.dataExist + + NumberAnimation { + duration: 500 + } + } + + min: t - chartView.samples + 20 + max: t + visible: false + gridVisible: false + labelsVisible: false + lineVisible: false + shadesVisible: false + titleVisible: false + } + + ValueAxis { + id: axisY + + min: -0 + max: 0 + tickCount: 4 + labelsColor: "white" + labelsFont.pixelSize: 8 + gridLineColor: "#555555" + } + + property real dataPadding: 0.05 + property real dataPaddingZero: 0.05 + property var sdata: [] + property int timeRescale: 0 + + function reset() + { + chartItem.destroySeriesState() + chartView.removeAllSeries() + chartView.sdata = [] + chartView.time = 0 + chartView.dataExist = false + chartView.timeRescale = 0 + axisY.min = -dataPaddingZero + axisY.max = dataPaddingZero + axisY.tickCount = 4 + axisY.applyNiceNumbers() + } + + function appendData() + { + var t = time + 1 + + for (var i = 0; i < chartItem.facts.length; ++i) + appendDataValue(chartItem.facts[i], t, i) + + if ((t - timeRescale) > 21) { + timeRescale = t + var d = sdata.length - samples * chartItem.facts.length + if (d > 0) + sdata.splice(0, d) + var p = chartItem.apxContext ? chartItem.apxContext.seriesBounds(sdata) + : Qt.point(0, 0) + var min = p.x - dataPadding + var max = p.y + dataPadding + if (min === max) { + min -= dataPaddingZero + max += dataPaddingZero + } + var bmod = false + if (axisY.min < min) { + axisY.min = min + bmod = true + } + if (axisY.max > max) { + axisY.max = max + bmod = true + } + if (bmod) { + axisY.tickCount = 4 + axisY.applyNiceNumbers() + } + } + time = t + dataExist = true + } + + function appendDataValue(fact, t, i) + { + if (i >= chartView.count) + addFactSeries(fact, i) + + var series = chartView.series(i) + var state = chartItem.seriesStateAt(i) + var value = chartItem.evaluateValue(fact, state.bind) + + if (!isFinite(value)) + value = 0 + + value = chartItem.applyFilterChain(state, value) + + if (!isFinite(value)) + value = 0 + + chartItem.writeSavedValue(state, value) + + series.append(t, value) + sdata.push(value) + if (axisY.max < value) + axisY.max = value + dataPadding + if (axisY.min > value) + axisY.min = value - dataPadding + var cnt = samples + if (series.count > cnt) + series.removePoints(0, series.count - cnt) + } + + function addFactSeries(fact, index) + { + var state = chartItem.seriesStateAt(index) + var series = chartView.createSeries(chartItem.uiContext + && chartItem.uiContext.antialiasing + ? ChartView.SeriesTypeLine + : ChartView.SeriesTypeLine, + state.title, + axisX, + axisY) + series.useOpenGL = Qt.binding(function() { + return openGL + }) + series.capStyle = Qt.RoundCap + chartItem.applySeriesStyle(series, state) + return series + } + } + + function changeSpeed() + { + if ((speed + 1) < speedFactor.length) + speed++ + else + speed = 0 + } +} diff --git a/src/main/qml/Apx/Menu/FactMenu.qml b/src/main/qml/Apx/Menu/FactMenu.qml index c9b49f6ce..c75914769 100644 --- a/src/main/qml/Apx/Menu/FactMenu.qml +++ b/src/main/qml/Apx/Menu/FactMenu.qml @@ -45,7 +45,8 @@ StackView { Component.onCompleted: { Menu.registerMenuView(stackView) - showFact(fact) + if (fact) + showFact(fact) } Component.onDestruction: { Menu.unregisterMenuView(stackView) @@ -93,6 +94,9 @@ StackView { //menu.js helpers function showFact(f) { + if (!f) + return + var opts={} opts.fact=f var c=pageDelegate.createObject(this, opts) @@ -135,4 +139,3 @@ StackView { openFact(apx) } } - diff --git a/src/main/qml/Apx/Menu/FactMenuPage.qml b/src/main/qml/Apx/Menu/FactMenuPage.qml index 3237dec91..79300fbe9 100644 --- a/src/main/qml/Apx/Menu/FactMenuPage.qml +++ b/src/main/qml/Apx/Menu/FactMenuPage.qml @@ -58,7 +58,8 @@ ColumnLayout { } Connections { enabled: valid - target: fact + target: fact ? fact : null + ignoreUnknownSignals: true function onMenuBack() { //console.log("menuBack") @@ -71,8 +72,6 @@ ColumnLayout { fact=factC.createObject(this) } } - - property real padding: Style.spacing clip: true @@ -98,12 +97,13 @@ ColumnLayout { Layout.rightMargin: padding Layout.topMargin: padding clip: true + onLoaded: menuPage.applyPageContext() } function pageSource() { - if(fact.opts.page){ - var s=fact.opts.page + if(fact && fact.opts && fact.opts.page){ + var s=String(fact.opts.page) if(s.indexOf(":")>=0){ return s } @@ -112,8 +112,23 @@ ColumnLayout { return "FactMenuPageList.qml" } + function applyPageContext() + { + if(!pageLoader.item) + return + + if(pageLoader.item.hasOwnProperty("fact")) + pageLoader.item.fact = fact + if(pageLoader.item.hasOwnProperty("menuPage")) + pageLoader.item.menuPage = menuPage + } + function factButtonTriggered(fact) { + if(fact && fact.dataType === Fact.Apply && pageLoader.item + && typeof pageLoader.item.applyPendingChanges === "function") + pageLoader.item.applyPendingChanges() + if(factMenu) factMenu.factButtonTriggered(fact) } @@ -134,8 +149,8 @@ ColumnLayout { property alias model: repeater.model Repeater { id: repeater - model: fact.actionsModel - delegate: Loader{ + model: fact ? fact.actionsModel : null + delegate: Loader { active: modelData && modelData.visible && ((modelData.options&Fact.ShowDisabled)?true:modelData.enabled) visible: active sourceComponent: Component { diff --git a/src/main/qml/Apx/Menu/FactMenuPopup.qml b/src/main/qml/Apx/Menu/FactMenuPopup.qml index a497a769c..2e60dfdce 100644 --- a/src/main/qml/Apx/Menu/FactMenuPopup.qml +++ b/src/main/qml/Apx/Menu/FactMenuPopup.qml @@ -96,7 +96,8 @@ Popup { else popup.raise() } Connections { - target: factMenu.fact + target: factMenu.fact ? factMenu.fact : null + ignoreUnknownSignals: true function onProgressChanged(){ popup.pinned=true } } onStackEmpty: popup.close() From 3332464364a96cc97c930efbe23e5fb1d4f4215f Mon Sep 17 00:00:00 2001 From: Aliaksei Stratsilatau Date: Fri, 24 Apr 2026 16:14:16 -0400 Subject: [PATCH 6/7] model refactor Co-authored-by: Copilot --- src/Plugins/Tools/Signals/CMakeLists.txt | 3 + src/Plugins/Tools/Signals/FilterBase.qml | 99 +++++++ src/Plugins/Tools/Signals/FilterFact.qml | 252 ++++++------------ src/Plugins/Tools/Signals/FilterKalman.qml | 103 +++++++ src/Plugins/Tools/Signals/FilterRegistry.qml | 124 +++------ .../Tools/Signals/FilterRunningAverage.qml | 80 ++++++ src/Plugins/Tools/Signals/MenuItem.qml | 109 +++++--- src/Plugins/Tools/Signals/MenuPage.qml | 103 +++---- src/Plugins/Tools/Signals/MenuSet.qml | 47 ++-- src/Plugins/Tools/Signals/MenuSets.qml | 157 ++++++++--- src/Plugins/Tools/Signals/REFACTOR_NOTES.md | 21 +- src/Plugins/Tools/Signals/SignalButton.qml | 5 +- src/Plugins/Tools/Signals/Signals.qml | 150 +++++++++-- src/Plugins/Tools/Signals/SignalsView.qml | 188 ++++--------- 14 files changed, 857 insertions(+), 584 deletions(-) create mode 100644 src/Plugins/Tools/Signals/FilterBase.qml create mode 100644 src/Plugins/Tools/Signals/FilterKalman.qml create mode 100644 src/Plugins/Tools/Signals/FilterRunningAverage.qml diff --git a/src/Plugins/Tools/Signals/CMakeLists.txt b/src/Plugins/Tools/Signals/CMakeLists.txt index 93d92ac82..6349d4156 100644 --- a/src/Plugins/Tools/Signals/CMakeLists.txt +++ b/src/Plugins/Tools/Signals/CMakeLists.txt @@ -1,8 +1,11 @@ set(QRC_QML ColorChoiceFact.qml ColorChooser.qml + FilterBase.qml FilterFact.qml + FilterKalman.qml FilterRegistry.qml + FilterRunningAverage.qml MenuItem.qml MenuPage.qml MenuSet.qml diff --git a/src/Plugins/Tools/Signals/FilterBase.qml b/src/Plugins/Tools/Signals/FilterBase.qml new file mode 100644 index 000000000..bf3a0b7f2 --- /dev/null +++ b/src/Plugins/Tools/Signals/FilterBase.qml @@ -0,0 +1,99 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick + +import APX.Facts + +Fact { + id: filterSettingsFact + + property var data: ({}) + property string filterType: "" + property string filterTitle: "" + property string detailsText: "" + property real outputValue: 0 + + flags: (Fact.Group | Fact.Section) + title: filterTitle + descr: detailsText + + Component.onCompleted: load(data) + + function asNumber(value, fallback) + { + var number = Number(value) + return isFinite(number) ? number : fallback + } + + function clamp(value, minValue, maxValue) + { + return Math.max(minValue, Math.min(maxValue, value)) + } + + function normalizedInput(inputValue) + { + return asNumber(inputValue, 0) + } + + function passThrough(inputValue) + { + outputValue = normalizedInput(inputValue) + return outputValue + } + + function load(filterData) + { + var source = filterData && typeof filterData === "object" ? filterData : {} + loadParameters(source) + reset() + } + + function save() + { + var filterData = saveParameters() + if (!filterData || filterData instanceof Array || typeof filterData !== "object") + filterData = {} + + filterData.type = filterType + return filterData + } + + function loadParameters(filterData) + { + } + + function saveParameters() + { + return ({}) + } + + function reset() + { + outputValue = 0 + } + + // Each concrete filter updates its own state and returns the latest output. + function update(inputValue) + { + return passThrough(inputValue) + } +} diff --git a/src/Plugins/Tools/Signals/FilterFact.qml b/src/Plugins/Tools/Signals/FilterFact.qml index 35420410e..0caf97f75 100644 --- a/src/Plugins/Tools/Signals/FilterFact.qml +++ b/src/Plugins/Tools/Signals/FilterFact.qml @@ -29,24 +29,21 @@ Fact { property var data: ({}) property var filterRegistry: FilterRegistry {} property bool isFilterItem: true - - property bool initialized: false - property real outputValue: 0 - property real stateValue: 0 - property real covariance: 0.1 property bool loading: false + property string filterType: filterRegistry.defaultType() flags: (Fact.Group | Fact.Bool) icon: "tune" value: true + title: filterRegistry.titleForType(filterType) + descr: filterDescription() signal removeTriggered() - Component.onCompleted: { - load(data) - updateTitle() - updateDescr() - } + readonly property bool filterEnabled: value !== false + readonly property var currentFilterFact: filterBody.size > 0 ? filterBody.child(0) : null + + Component.onCompleted: load(data) function itemEditor() { @@ -71,54 +68,58 @@ Fact { owner.saveAll() } - function asNumber(value, fallback) + function createFact(parent, url, opts) { - var number = Number(value) - return isFinite(number) ? number : fallback - } + var component = Qt.createComponent(Qt.resolvedUrl(url)) + if (component.status === Component.Ready) { + var properties = opts || {} + properties.parentFact = parent + return component.createObject(parent, properties) + } - function clamp(value, minValue, maxValue) - { - return Math.max(minValue, Math.min(maxValue, value)) + console.log(component.errorString()) + return null } - function enabledValue() + function selectedTypeText() { - return value ? true : false - } + if (typeFact.text !== undefined && typeFact.text !== null) + return String(typeFact.text).trim() - function typeValue() - { - var text = String(typeFact.value === undefined || typeFact.value === null - ? typeFact.text - : typeFact.value).trim() - var info = filterRegistry.typeInfo(text) - return info ? info.value : filterRegistry.defaultType() - } + if (typeFact.value !== undefined && typeFact.value !== null) + return String(typeFact.value).trim() - function isRunningAverage() - { - return typeValue() === "running_avg" + return "" } - function isKalman() + function filterDescription() { - return typeValue() === "kalman_smp" - } + var details = currentFilterFact && currentFilterFact.descr !== undefined ? String(currentFilterFact.descr) : "" + if (filterEnabled) + return details - function runningAvgCoef() - { - return clamp(asNumber(coefFact.value, 0.2), 0.0, 1.0) + return details !== "" ? qsTr("Off") + ", " + details : qsTr("Off") } - function kalmanR() + function createFilterBody(filterData) { - return Math.max(0.0, asNumber(rFact.value, 0.1)) + var info = filterRegistry.typeInfo(filterType) + if (!info) + return null + + filterBody.deleteChildren() + return createFact(filterBody, info.source, { + "data": filterData + }) } - function kalmanQ() + function setFilterType(type, filterData) { - return Math.max(0.0, asNumber(qFact.value, 0.001)) + var normalizedType = filterRegistry.valueForType(type) + var normalizedData = filterRegistry.normalizeFilter(filterData) + + filterType = normalizedType + createFilterBody(normalizedData ? normalizedData : filterRegistry.defaultFilter(normalizedType)) } function load(filterData) @@ -129,113 +130,51 @@ Fact { loading = true value = filter.enabled !== false - typeFact.value = filter.type - coefFact.value = filter.coef !== undefined ? filter.coef : 0.2 - rFact.value = filter.r !== undefined ? filter.r : 0.1 - qFact.value = filter.q !== undefined ? filter.q : 0.001 + filterType = filter.type + typeFact.value = filterRegistry.titleForType(filter.type) + createFilterBody(filter) reset() loading = false - updateTitle() - updateDescr() } function save() { - var filter = { - "type": typeValue(), - "enabled": enabledValue() - } - - switch (filter.type) { - case "running_avg": - filter.coef = runningAvgCoef() - break - case "kalman_smp": - filter.r = kalmanR() - filter.q = kalmanQ() - break - } + var filter = currentFilterFact && typeof currentFilterFact.save === "function" + ? currentFilterFact.save() + : filterRegistry.defaultFilter(filterType) + if (!filter) + return null + filter.enabled = filterEnabled + filter.type = filterType return filterRegistry.normalizeFilter(filter) } function reset() { - initialized = false - outputValue = 0 - stateValue = 0 - covariance = 0.1 + if (currentFilterFact && typeof currentFilterFact.reset === "function") + currentFilterFact.reset() } - function step(inputValue) + // The wrapper controls enable/type while the loaded child owns the actual math. + function update(inputValue) { - var input = asNumber(inputValue, 0) - var type = typeValue() + var input = Number(inputValue) + if (!isFinite(input)) + input = 0 - if (!enabledValue()) + if (!filterEnabled) return input - switch (type) { - case "running_avg": - if (!initialized) { - outputValue = input - initialized = true - return outputValue - } - - outputValue = outputValue * (1.0 - runningAvgCoef()) + input * runningAvgCoef() - return outputValue - case "kalman_smp": - if (!initialized) { - stateValue = input - covariance = 0.1 - initialized = true - return stateValue - } - - covariance = covariance + kalmanQ() - - var denominator = covariance + kalmanR() - var gain = denominator > 0 ? covariance / denominator : 0 - - stateValue = stateValue + gain * (input - stateValue) - covariance = (1.0 - gain) * covariance - return stateValue - default: - return input - } - } - - function updateTitle() - { - title = filterRegistry.titleForType(typeValue()) - } + if (currentFilterFact && typeof currentFilterFact.update === "function") + return currentFilterFact.update(input) - function updateDescr() - { - var parts = [] - - if (!enabledValue()) - parts.push(qsTr("Off")) - - switch (typeValue()) { - case "running_avg": - parts.push(qsTr("Coef") + ": " + Number(runningAvgCoef()).toFixed(2)) - break - case "kalman_smp": - parts.push(qsTr("R") + ": " + Number(kalmanR())) - parts.push(qsTr("Q") + ": " + Number(kalmanQ())) - break - } - - descr = parts.join(", ") + return input } onValueChanged: { - if (!loading) { + if (!loading) reset() - updateDescr() - } } Fact { @@ -244,63 +183,26 @@ Fact { title: qsTr("Type") descr: qsTr("Filter algorithm") flags: Fact.Enum - enumStrings: filterRegistry.typeValues() - value: filterRegistry.defaultType() + enumStrings: filterRegistry.typeTitles() + value: filterRegistry.titleForType(filterRegistry.defaultType()) onValueChanged: { - if (!filterFact.loading) { - filterFact.reset() - filterFact.updateTitle() - filterFact.updateDescr() - } - } - } + if (filterFact.loading) + return - Fact { - id: coefFact - name: "coef" - title: qsTr("Coefficient") - descr: qsTr("Running average blend coefficient") - flags: Fact.Float - visible: filterFact.isRunningAverage() - value: 0.2 - onValueChanged: { - if (!filterFact.loading) { - filterFact.reset() - filterFact.updateDescr() - } - } - } + var selectedType = filterRegistry.valueForType(filterFact.selectedTypeText()) + if (selectedType === filterFact.filterType) + return - Fact { - id: rFact - name: "r" - title: qsTr("R") - descr: qsTr("Measurement noise") - flags: Fact.Float - visible: filterFact.isKalman() - value: 0.1 - onValueChanged: { - if (!filterFact.loading) { - filterFact.reset() - filterFact.updateDescr() - } + filterFact.setFilterType(selectedType, filterRegistry.defaultFilter(selectedType)) + filterFact.reset() } } Fact { - id: qFact - name: "q" - title: qsTr("Q") - descr: qsTr("Process noise") - flags: Fact.Float - visible: filterFact.isKalman() - value: 0.001 - onValueChanged: { - if (!filterFact.loading) { - filterFact.reset() - filterFact.updateDescr() - } - } + id: filterBody + title: qsTr("Settings") + descr: qsTr("Parameters for the selected filter type") + flags: (Fact.Group | Fact.Section) } Fact { diff --git a/src/Plugins/Tools/Signals/FilterKalman.qml b/src/Plugins/Tools/Signals/FilterKalman.qml new file mode 100644 index 000000000..dd9d2ba19 --- /dev/null +++ b/src/Plugins/Tools/Signals/FilterKalman.qml @@ -0,0 +1,103 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick + +FilterBase { + id: filterFact + + property real defaultR: 0.1 + property real defaultQ: 0.001 + property bool initialized: false + property real stateEstimate: 0 + property real covariance: 0.1 + readonly property real measurementNoise: Math.max(0.0, asNumber(rFact.value, defaultR)) + readonly property real processNoise: Math.max(0.0, asNumber(qFact.value, defaultQ)) + + filterType: "kalman_smp" + filterTitle: qsTr("Simple Kalman") + detailsText: qsTr("R") + ": " + Number(measurementNoise) + + ", " + qsTr("Q") + ": " + Number(processNoise) + + function loadParameters(filterData) + { + rFact.value = Math.max(0.0, asNumber(filterData.r, defaultR)) + qFact.value = Math.max(0.0, asNumber(filterData.q, defaultQ)) + } + + function saveParameters() + { + return { + "r": measurementNoise, + "q": processNoise + } + } + + function reset() + { + initialized = false + stateEstimate = 0 + covariance = 0.1 + outputValue = 0 + } + + function update(inputValue) + { + var input = normalizedInput(inputValue) + if (!filterEnabled) + return passThrough(input) + + if (!initialized) { + stateEstimate = input + covariance = 0.1 + initialized = true + } else { + covariance = covariance + processNoise + + var denominator = covariance + measurementNoise + var gain = denominator > 0 ? covariance / denominator : 0 + + stateEstimate = stateEstimate + gain * (input - stateEstimate) + covariance = (1.0 - gain) * covariance + } + + outputValue = stateEstimate + return outputValue + } + + Fact { + id: rFact + name: "r" + title: qsTr("R") + descr: qsTr("Measurement noise") + flags: Fact.Float + value: defaultR + } + + Fact { + id: qFact + name: "q" + title: qsTr("Q") + descr: qsTr("Process noise") + flags: Fact.Float + value: defaultQ + } +} diff --git a/src/Plugins/Tools/Signals/FilterRegistry.qml b/src/Plugins/Tools/Signals/FilterRegistry.qml index 609ffb9ce..56809cf05 100644 --- a/src/Plugins/Tools/Signals/FilterRegistry.qml +++ b/src/Plugins/Tools/Signals/FilterRegistry.qml @@ -24,18 +24,28 @@ import QtQml QtObject { id: registry - readonly property string genericFilterSource: Qt.resolvedUrl("FilterFact.qml") - + // To add a filter: create a dedicated Filter*.qml component and register it here. readonly property var typeOptions: [ { "title": qsTr("Running average"), "value": "running_avg", - "source": genericFilterSource + "source": Qt.resolvedUrl("FilterRunningAverage.qml"), + "defaults": { + "type": "running_avg", + "enabled": true, + "coef": 0.2 + } }, { "title": qsTr("Simple Kalman"), "value": "kalman_smp", - "source": genericFilterSource + "source": Qt.resolvedUrl("FilterKalman.qml"), + "defaults": { + "type": "kalman_smp", + "enabled": true, + "r": 0.1, + "q": 0.001 + } } ] @@ -47,14 +57,6 @@ QtObject { return JSON.parse(JSON.stringify(value)) } - function asObject(value) - { - if (!value || value instanceof Array || typeof value !== "object") - return {} - - return value - } - function asString(value, fallback) { if (fallback === undefined) @@ -66,45 +68,18 @@ QtObject { return String(value) } - function asBool(value, fallback) - { - if (value === undefined || value === null) - return fallback - - return !!value - } - - function asNumber(value, fallback) - { - var number = Number(value) - return isFinite(number) ? number : fallback - } - - function clamp(value, minValue, maxValue) - { - return Math.max(minValue, Math.min(maxValue, value)) - } - function typeInfo(type) { + var text = asString(type, "") + for (var i = 0; i < typeOptions.length; ++i) { - if (typeOptions[i].value === type) + if (typeOptions[i].value === text || typeOptions[i].title === text) return typeOptions[i] } return null } - function typeIndex(type) - { - for (var i = 0; i < typeOptions.length; ++i) { - if (typeOptions[i].value === type) - return i - } - - return 0 - } - function titleForType(type) { var info = typeInfo(type) @@ -117,63 +92,50 @@ QtObject { return info ? info.source : "" } - function defaultType() - { - return typeOptions.length > 0 ? typeOptions[0].value : "" - } - - function typeValues() + function typeTitles() { var values = [] for (var i = 0; i < typeOptions.length; ++i) - values.push(typeOptions[i].value) + values.push(typeOptions[i].title) return values } + function valueForType(type) + { + var info = typeInfo(type) + return info ? info.value : defaultType() + } + + function defaultType() + { + return typeOptions.length > 0 ? typeOptions[0].value : "" + } + function defaultFilter(type) { - switch (type) { - case "kalman_smp": - return { - "type": "kalman_smp", - "enabled": true, - "r": 0.1, - "q": 0.001 - } - case "running_avg": - return { - "type": "running_avg", - "enabled": true, - "coef": 0.2 - } - default: - return null - } + var info = typeInfo(type) + return info ? cloneValue(info.defaults) : null } function normalizeFilter(filterData) { - var source = asObject(filterData) - var type = asString(source.type, "") - var filter = defaultFilter(type) + if (!filterData || filterData instanceof Array || typeof filterData !== "object") + return null + + var source = cloneValue(filterData) + var filter = defaultFilter(source.type) if (!filter) return null - filter = cloneValue(filter) - filter.enabled = asBool(source.enabled, true) - - switch (filter.type) { - case "running_avg": - filter.coef = clamp(asNumber(source.coef, filter.coef), 0.0, 1.0) - break - case "kalman_smp": - filter.r = Math.max(0.0, asNumber(source.r, filter.r)) - filter.q = Math.max(0.0, asNumber(source.q, filter.q)) - break - } + for (var key in source) + filter[key] = source[key] + + filter.type = defaultFilter(source.type).type + if (filter.enabled === undefined) + filter.enabled = true return filter } diff --git a/src/Plugins/Tools/Signals/FilterRunningAverage.qml b/src/Plugins/Tools/Signals/FilterRunningAverage.qml new file mode 100644 index 000000000..e78980b51 --- /dev/null +++ b/src/Plugins/Tools/Signals/FilterRunningAverage.qml @@ -0,0 +1,80 @@ +/* + * APX Autopilot project + * + * Copyright (c) 2003-2020, Aliaksei Stratsilatau + * All rights reserved + * + * This file is part of APX Ground Control. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +import QtQuick + +FilterBase { + id: filterFact + + property real defaultCoefficient: 0.2 + property bool initialized: false + property real averageValue: 0 + readonly property real coefficient: clamp(asNumber(coefFact.value, defaultCoefficient), 0.0, 1.0) + + filterType: "running_avg" + filterTitle: qsTr("Running average") + detailsText: qsTr("Coef") + ": " + Number(coefficient).toFixed(2) + + function loadParameters(filterData) + { + coefFact.value = clamp(asNumber(filterData.coef, defaultCoefficient), 0.0, 1.0) + } + + function saveParameters() + { + return { + "coef": coefficient + } + } + + function reset() + { + initialized = false + averageValue = 0 + outputValue = 0 + } + + function update(inputValue) + { + var input = normalizedInput(inputValue) + if (!filterEnabled) + return passThrough(input) + + if (!initialized) { + averageValue = input + initialized = true + } else { + averageValue = averageValue * (1.0 - coefficient) + input * coefficient + } + + outputValue = averageValue + return outputValue + } + + Fact { + id: coefFact + name: "coef" + title: qsTr("Coefficient") + descr: qsTr("Running average blend coefficient") + flags: Fact.Float + value: defaultCoefficient + } +} diff --git a/src/Plugins/Tools/Signals/MenuItem.qml b/src/Plugins/Tools/Signals/MenuItem.qml index a75721018..9e71daf44 100644 --- a/src/Plugins/Tools/Signals/MenuItem.qml +++ b/src/Plugins/Tools/Signals/MenuItem.qml @@ -79,6 +79,8 @@ Fact { ] flags: Fact.Group + title: newItem ? qsTr("Add new item") : bindText() + descr: itemDescription() signal addTriggered() signal removeTriggered() @@ -86,8 +88,6 @@ Fact { Component.onCompleted: { load() rebuildColorChoices() - updateTitle() - updateDescr() } function rootEditor() @@ -176,6 +176,16 @@ Fact { return normalizeBindValue(factTextValue(mSaveFact)) } + function colorValueCurrent() + { + return colorValue + } + + function warningText() + { + return factTextValue(mWarning) + } + function colorSummary() { return colorValue !== "" ? colorValue : qsTr("Auto") @@ -190,6 +200,14 @@ Fact { return label } + function liveFilterSummary(filterFact) + { + if (!filterFact) + return "" + + return filterFact.descr !== "" ? filterFact.title + ": " + filterFact.descr : filterFact.title + } + function clearFilterFacts() { if (!mFiltersGroup) @@ -211,9 +229,6 @@ Fact { if (!child) return null - child.titleChanged.connect(updateDescr) - child.descrChanged.connect(updateDescr) - child.removeTriggered.connect(updateDescr) return child } @@ -221,12 +236,10 @@ Fact { { var list = [] - if (!mFiltersGroup) - return list - - for (var i = 0; i < mFiltersGroup.size; ++i) { - var child = mFiltersGroup.child(i) - if (!child || !child.isFilterItem || typeof child.save !== "function") + var liveFilters = filterFacts() + for (var i = 0; i < liveFilters.length; ++i) { + var child = liveFilters[i] + if (!child || typeof child.save !== "function") continue var filterData = child.save() @@ -237,6 +250,22 @@ Fact { return list } + function filterFacts() + { + var list = [] + + if (!mFiltersGroup) + return list + + for (var i = 0; i < mFiltersGroup.size; ++i) { + var child = mFiltersGroup.child(i) + if (child && child.isFilterItem) + list.push(child) + } + + return list + } + function currentFilters() { var live = exportFilters() @@ -256,6 +285,14 @@ Fact { function filtersSummary() { + var liveFilters = filterFacts() + if (liveFilters.length > 0) { + var liveParts = [] + for (var i = 0; i < liveFilters.length; ++i) + liveParts.push(liveFilterSummary(liveFilters[i])) + return liveParts.join(", ") + } + var list = currentFilters() if (!(list instanceof Array) || list.length <= 0) return qsTr("None") @@ -266,10 +303,28 @@ Fact { return parts.join(", ") } + // The item owns the live filter chain so the chart only provides a raw sample. + function updateFilters(inputValue) + { + var output = Number(inputValue) + if (!isFinite(output)) + output = 0 + + var liveFilters = filterFacts() + for (var i = 0; i < liveFilters.length; ++i) { + var filterFact = liveFilters[i] + if (!filterFact || typeof filterFact.update !== "function") + continue + + output = filterFact.update(output) + } + + return output + } + function setColorValue(value) { colorValue = normalizeColorValue(value) - updateDescr() } function clearColorChoices() @@ -318,15 +373,11 @@ Fact { { filtersData = filterRegistry.normalizeFilters(cloneValue(value)) rebuildFilters() - updateDescr() } function addFilter() { - var child = createFilterFact(filterRegistry.defaultFilter(filterRegistry.defaultType())) - if (child) - updateDescr() - return child + return createFilterFact(filterRegistry.defaultFilter(filterRegistry.defaultType())) } function validationError() @@ -352,7 +403,6 @@ Fact { function refreshValidation() { saveError = validationError() - updateDescr() } function canSave() @@ -393,20 +443,12 @@ Fact { return item } - function updateTitle() - { - if (newItem) - return - - title = bindText() - } - - function updateDescr() + function itemDescription() { var details = [] if (colorValue !== "") details.push(qsTr("Color") + ": " + colorValue) - if (currentFilters().length > 0) + if (filtersSummary() !== qsTr("None")) details.push(qsTr("Filters") + ": " + filtersSummary()) if (factTextValue(mWarning) !== "") details.push(qsTr("Warning") + ": " + factTextValue(mWarning)) @@ -415,9 +457,7 @@ Fact { if (saveError !== "") details.push(saveError) - descr = details.join(", ") - if (mFiltersGroup) - mFiltersGroup.descr = filtersSummary() + return details.join(", ") } Fact { @@ -438,10 +478,6 @@ Fact { title: qsTr("Expression") descr: qsTr("Mandala path or JavaScript expression") flags: Fact.Text - onValueChanged: { - itemFact.updateTitle() - itemFact.updateDescr() - } } Fact { @@ -459,7 +495,7 @@ Fact { Fact { id: mFiltersGroup title: qsTr("Filters") - descr: qsTr("Ordered filter stack") + descr: itemFact.filtersSummary() // icon: "tune" flags: (Fact.Group | Fact.DragChildren) @@ -487,7 +523,6 @@ Fact { title: qsTr("Warning") descr: qsTr("Expression that raises a page warning") flags: Fact.Text - onValueChanged: itemFact.updateDescr() } Fact { diff --git a/src/Plugins/Tools/Signals/MenuPage.qml b/src/Plugins/Tools/Signals/MenuPage.qml index b4104ab9d..54b418101 100644 --- a/src/Plugins/Tools/Signals/MenuPage.qml +++ b/src/Plugins/Tools/Signals/MenuPage.qml @@ -28,26 +28,22 @@ Fact { id: pageFact property bool newItem: false - property bool standaloneEditor: false property var data: ({}) property var signalsModel: null property var setFact: null property real speedValue: 1.0 - property bool loading: false property alias itemsFact: pageItems flags: (Fact.Group | Fact.FlatModel) + title: newItem ? qsTr("Add new page") : pageName() + descr: newItem ? qsTr("Create a new chart page") : pageDescription() signal addTriggered() - signal accepted(var pageData) signal removeTriggered() - signal removedStandalone() Component.onCompleted: { load() - updateTitle() - updateDescr() } function rootEditor() @@ -135,6 +131,37 @@ Fact { return pageFact.defaultTitle() } + function currentSpeedValue() + { + return speedValue + } + + function setSpeedValue(value) + { + var nextSpeed = signalsModel && typeof signalsModel.normalizeSpeed === "function" + ? signalsModel.normalizeSpeed(value) + : asNumber(value, 1.0) + if (nextSpeed === speedValue) + return + + speedValue = nextSpeed + } + + function isPinned() + { + return !!mPin.value + } + + function itemFacts() + { + var items = [] + + for (var i = 0; i < pageItems.size; ++i) + items.push(pageItems.child(i)) + + return items + } + function speedText() { if (signalsModel && typeof signalsModel.formatSpeed === "function") @@ -188,26 +215,13 @@ Fact { return 1.0 } - function syncSpeedFact() - { - if (!mSpeed) - return - - loading = true - mSpeed.value = speedText() - loading = false - } - function load() { - loading = true mName.value = data && data.name !== undefined ? data.name : "" mPin.value = data && data.pin !== undefined ? data.pin : false speedValue = signalsModel && typeof signalsModel.normalizeSpeed === "function" ? signalsModel.normalizeSpeed(data ? data.speed : undefined) : asNumber(data ? data.speed : undefined, 1.0) - syncSpeedFact() - loading = false updateItems() } @@ -241,9 +255,6 @@ Fact { if (!child) return null - child.titleChanged.connect(updateDescr) - child.removeTriggered.connect(updateDescr) - if (itemData && itemData.bind !== undefined) maybeAdoptNameFromBind(itemData.bind) @@ -290,15 +301,7 @@ Fact { } } - function updateTitle() - { - if (newItem) - return - - title = pageName() - } - - function updateDescr() + function pageDescription() { var parts = [] if (mPin.value) @@ -312,7 +315,7 @@ Fact { if (items.length > 0) parts.push(items.join(", ")) - descr = parts.join(", ") + return parts.join(", ") } Fact { @@ -321,10 +324,6 @@ Fact { descr: qsTr("Tab label for this page") flags: Fact.Text icon: "rename-box" - onValueChanged: { - pageFact.updateTitle() - pageFact.updateDescr() - } } Fact { @@ -333,7 +332,6 @@ Fact { descr: qsTr("Show this page in the stacked pinned layout") flags: Fact.Bool icon: "pin" - onValueChanged: pageFact.updateDescr() } Fact { @@ -345,17 +343,12 @@ Fact { enumStrings: pageFact.speedOptions() value: pageFact.speedText() onValueChanged: { - if (pageFact.loading) - return - var selectedText = value === undefined || value === null ? text : value var selectedSpeed = pageFact.speedValueFromText(selectedText) if (selectedSpeed === pageFact.speedValue) return pageFact.speedValue = selectedSpeed - pageFact.updateDescr() - pageFact.syncSpeedFact() } } @@ -384,23 +377,10 @@ Fact { Fact { flags: (Fact.Action | Fact.Apply) title: qsTr("Save") - descr: pageFact.standaloneEditor ? qsTr("Apply page changes") - : qsTr("Save chart changes") + descr: qsTr("Save chart changes") visible: !pageFact.newItem icon: "check-circle" - onTriggered: { - if (!pageFact.standaloneEditor) { - pageFact.saveAll() - return - } - - var pageData = pageFact.save() - if (pageData === null) - return - - pageFact.accepted(pageData) - pageFact.menuBack() - } + onTriggered: pageFact.saveAll() } Fact { @@ -422,14 +402,9 @@ Fact { visible: !newItem icon: "delete" onTriggered: { - if (pageFact.standaloneEditor) { - removeTriggered() - removedStandalone() - pageFact.menuBack() - return - } - var ownerSet = setFact + if (ownerSet && ownerSet.stateChanged) + ownerSet.stateChanged() removeTriggered() pageFact.deleteFact() if (ownerSet && typeof ownerSet.refreshSaveWarnings === "function") diff --git a/src/Plugins/Tools/Signals/MenuSet.qml b/src/Plugins/Tools/Signals/MenuSet.qml index f96e98a6a..89a1093a0 100644 --- a/src/Plugins/Tools/Signals/MenuSet.qml +++ b/src/Plugins/Tools/Signals/MenuSet.qml @@ -31,13 +31,14 @@ Fact { property var data: ({}) flags: (Fact.Group | Fact.FlatModel) + title: setTitleText() + descr: pagesSummary() signal selected(var num) + signal stateChanged() Component.onCompleted: { load() - updateTitle() - updateDescr() refreshSaveWarnings() } @@ -79,12 +80,27 @@ Fact { return qsTr("Set") + " " + (Math.max(setFact.num, 0) + 1) } + function pageFacts() + { + var pages = [] + + for (var i = 0; i < setPages.size; ++i) + pages.push(setPages.child(i)) + + return pages + } + function load() { setTitle.value = data && data.title !== undefined ? data.title : defaultTitle() updatePages() } + function setTitleText() + { + return setTitle.text.trim() !== "" ? setTitle.text.trim() : setFact.defaultTitle() + } + function save() { refreshSaveWarnings() @@ -99,7 +115,7 @@ Fact { } return { - "title": setTitle.text.trim() !== "" ? setTitle.text.trim() : setFact.defaultTitle(), + "title": setTitleText(), "pages": pages } } @@ -122,9 +138,6 @@ Fact { }) if (!child) return null - - child.titleChanged.connect(updateDescr) - child.removeTriggered.connect(updateDescr) return child } @@ -167,17 +180,12 @@ Fact { return false } - function updateTitle() - { - title = setTitle.text.trim() !== "" ? setTitle.text.trim() : setFact.defaultTitle() - } - - function updateDescr() + function pagesSummary() { var pages = [] for (var i = 0; i < setPages.size; ++i) pages.push(setPages.child(i).title) - descr = pages.join(", ") + return pages.join(", ") } Fact { @@ -186,10 +194,6 @@ Fact { descr: qsTr("Saved chart configuration name") flags: Fact.Text icon: "rename-box" - onValueChanged: { - setFact.updateTitle() - setFact.updateDescr() - } } MenuPage { @@ -200,8 +204,10 @@ Fact { setFact: setFact onAddTriggered: { var pageData = save() - if (pageData) + if (pageData) { setFact.createPage(pageData) + setFact.stateChanged() + } } } @@ -219,6 +225,7 @@ Fact { onTriggered: { if (setFact.active) selected(0) + setFact.stateChanged() setFact.deleteFact() } } @@ -233,8 +240,8 @@ Fact { Fact { flags: (Fact.Action | Fact.Apply) - title: qsTr("Select and save") - descr: qsTr("Make this set active and save it") + title: qsTr("Select") + descr: qsTr("Make this set active") visible: !setFact.active icon: "check-circle" onTriggered: { diff --git a/src/Plugins/Tools/Signals/MenuSets.qml b/src/Plugins/Tools/Signals/MenuSets.qml index 22481f37e..b292267ee 100644 --- a/src/Plugins/Tools/Signals/MenuSets.qml +++ b/src/Plugins/Tools/Signals/MenuSets.qml @@ -29,6 +29,7 @@ Fact { property var signalsModel: null property bool destroyOnClose: true property string defaultDescr: qsTr("Chart configuration editor") + property bool loading: false name: setsFact.signalsModel && setsFact.signalsModel.settingsName ? setsFact.signalsModel.settingsName : "signals" flags: (Fact.Group | Fact.FlatModel) @@ -37,6 +38,7 @@ Fact { icon: "poll" signal accepted() + signal stateChanged() Component.onCompleted: { loadSettings() @@ -45,8 +47,6 @@ Fact { function close() { if (!setsFact.destroyOnClose) { - setsList.deleteChildren() - setsFact.loadSettings() setsFact.menuBack() return } @@ -79,36 +79,81 @@ Fact { return null child.selected.connect(select) - child.selected.connect(saveSettings) + child.stateChanged.connect(markChanged) return child } - function defaultSettings() + function activeSetIndex() { - if (setsFact.signalsModel && typeof setsFact.signalsModel.createDefaultSettings === "function") - return setsFact.signalsModel.createDefaultSettings() + for (var i = 0; i < setsList.size; ++i) { + if (setsList.child(i).active) + return i + } - return { + return setsList.size > 0 ? 0 : -1 + } + + function activeSetFact() + { + var index = activeSetIndex() + return index >= 0 && index < setsList.size ? setsList.child(index) : null + } + + function activePages() + { + var activeSet = activeSetFact() + return activeSet && typeof activeSet.pageFacts === "function" ? activeSet.pageFacts() : [] + } + + function activeSetTitle() + { + var activeSet = activeSetFact() + return activeSet ? activeSet.title : "" + } + + function exportSettings() + { + var settings = { "active": { "signals": 0 }, "sets": [] } + + for (var i = 0; i < setsList.size; ++i) { + var setEditor = setsList.child(i) + var setData = setEditor.save() + if (setData === null) + return null + + settings.sets.push(setData) + if (setEditor.active) + settings.active.signals = i + } + + if (settings.sets.length <= 0) { + var defaults = defaultSettings() + settings.sets = defaults.sets instanceof Array ? defaults.sets : [] + settings.active.signals = defaults.active ? defaults.active.signals : 0 + } + + return settings } - function loadSettings() + function markChanged() + { + if (loading) + return + + stateChanged() + } + + function loadTree(settings) { + loading = true setsFact.descr = setsFact.defaultDescr setsList.deleteChildren() - var settings = setsFact.defaultSettings() - if (setsFact.signalsModel) { - if (!setsFact.signalsModel.loaded && typeof setsFact.signalsModel.loadSettings === "function") - setsFact.signalsModel.loadSettings() - if (typeof setsFact.signalsModel.exportSettings === "function") - settings = setsFact.signalsModel.exportSettings() - } - var sets = settings && settings.sets instanceof Array ? settings.sets : [] var currentSetIdx = settings && settings.active ? settings.active.signals : 0 @@ -116,49 +161,83 @@ Fact { setsFact.createSet(sets[i]) setsFact.select(currentSetIdx) + loading = false + markChanged() } - function saveSettings() + function defaultSettings() { - setsFact.descr = setsFact.defaultDescr + if (setsFact.signalsModel && typeof setsFact.signalsModel.createDefaultSettings === "function") + return setsFact.signalsModel.createDefaultSettings() - var settings = { + return { "active": { "signals": 0 }, "sets": [] } + } - for (var i = 0; i < setsList.size; ++i) { - var setEditor = setsList.child(i) - var setData = setEditor.save() - if (setData === null) { - setsFact.descr = qsTr("Fix editor errors before saving") - return - } + function loadSettings() + { + var settings = setsFact.defaultSettings() + if (setsFact.signalsModel) { + if (!setsFact.signalsModel.loaded && typeof setsFact.signalsModel.loadSettings === "function") + setsFact.signalsModel.loadSettings() + if (typeof setsFact.signalsModel.exportSettings === "function") + settings = setsFact.signalsModel.exportSettings() + } - settings.sets.push(setData) - if (setEditor.active) - settings.active.signals = i + loadTree(settings) + } + + function saveSettings() + { + setsFact.descr = setsFact.defaultDescr + + var settings = exportSettings() + if (settings === null) { + setsFact.descr = qsTr("Fix editor errors before saving") + return } if (setsFact.signalsModel && typeof setsFact.signalsModel.saveSettings === "function") setsFact.signalsModel.saveSettings(settings) setsFact.accepted() - setsFact.close() } function resetToDefaults() { - setsFact.descr = setsFact.defaultDescr - setsList.deleteChildren() + var settings = exportSettings() + if (settings === null) + return - var settings = setsFact.defaultSettings() - for (var i = 0; i < settings.sets.length; ++i) - setsFact.createSet(settings.sets[i]) + var defaults = defaultSettings() + var defaultSet = defaults.sets instanceof Array && defaults.sets.length > 0 + ? defaults.sets[0] + : null + if (!defaultSet) + return - setsFact.select(settings.active.signals) + var targetIndex = -1 + for (var i = 0; i < settings.sets.length; ++i) { + var title = String(settings.sets[i].title === undefined ? "" : settings.sets[i].title).trim().toLowerCase() + if (title === "default") { + targetIndex = i + break + } + } + + if (targetIndex < 0) { + settings.sets.unshift(defaultSet) + targetIndex = 0 + } else { + settings.sets[targetIndex] = defaultSet + } + + settings.active.signals = targetIndex + loadTree(settings) } function select(num) @@ -167,6 +246,8 @@ Fact { var setEditor = setsList.child(i) setEditor.active = setEditor.num === num } + + markChanged() } Fact { @@ -188,8 +269,10 @@ Fact { "title": setTitle, "pages": [] }) - if (child) + if (child) { child.trigger() + markChanged() + } } } diff --git a/src/Plugins/Tools/Signals/REFACTOR_NOTES.md b/src/Plugins/Tools/Signals/REFACTOR_NOTES.md index f285cec07..65c63f618 100644 --- a/src/Plugins/Tools/Signals/REFACTOR_NOTES.md +++ b/src/Plugins/Tools/Signals/REFACTOR_NOTES.md @@ -87,11 +87,13 @@ Notes: - `ColorChooser.qml` - 12x4 Material palette plus hex input. - `FilterFact.qml` - - Plain Fact-based filter row. - - `Fact.Group | Fact.Bool` with type-specific parameter visibility. - - Save/remove actions on the filter page. + - Generic filter row with enable toggle, type selector, save/remove actions, and a dynamic settings section. +- `FilterBase.qml` + - Shared base for the type-specific settings Fact loaded inside `FilterFact.qml`. +- `FilterRunningAverage.qml`, `FilterKalman.qml` + - Dedicated filter settings Facts; each file owns its own editor fields and runtime math. - `FilterRegistry.qml` - - Filter defaults, titles, type normalization, runtime component lookup. + - Filter defaults, titles, type normalization, and component lookup. ## Filter implementation @@ -102,11 +104,11 @@ Current filter types: Current pattern: -- Filters are plain Fact children inside the item’s `Filters` group. +- Filters are generic `FilterFact.qml` rows inside the item’s `Filters` group. - Each filter is ordered and draggable. - Each filter has an enable toggle on the row itself. -- Parameter facts are shown/hidden from the selected `type`. -- Runtime execution is still registry-driven in `SignalsView.qml`. +- Each filter row owns the enable/type shell while the loaded settings Fact owns the runtime state and `update()` implementation. +- `MenuItem.qml` applies the live filter chain; `SignalsView.qml` only provides raw samples. ## Current UX decisions @@ -133,7 +135,10 @@ Current pattern: | `MenuPage.qml` | page editor | active | | `MenuItem.qml` | item editor | active | | `ColorChooser.qml` | color editor page | active | -| `FilterFact.qml` | filter editor/runtime row | active | +| `FilterFact.qml` | generic filter shell | active | +| `FilterBase.qml` | shared filter Fact contract | active | +| `FilterRunningAverage.qml` | running average filter | active | +| `FilterKalman.qml` | Kalman filter | active | | `FilterRegistry.qml` | filter metadata and normalization | active | | `PageButton.qml` | old experiment from an earlier tab rewrite | currently unused | diff --git a/src/Plugins/Tools/Signals/SignalButton.qml b/src/Plugins/Tools/Signals/SignalButton.qml index 2fba74dd9..b94978747 100644 --- a/src/Plugins/Tools/Signals/SignalButton.qml +++ b/src/Plugins/Tools/Signals/SignalButton.qml @@ -35,10 +35,13 @@ TextButton { property string pageToolTip: "" property bool pageWarning: false + signal editTriggered() + textColor: pageWarning ? Material.color(Material.Orange) : Material.primaryTextColor - onActivated: signals.facts=Qt.binding(function(){return values}) + onDoubleClicked: editTriggered() + onPressAndHold: editTriggered() toolTip: pageToolTip !== "" ? pageToolTip : getToolTip(values) diff --git a/src/Plugins/Tools/Signals/Signals.qml b/src/Plugins/Tools/Signals/Signals.qml index 02ee1e9f9..15c608948 100644 --- a/src/Plugins/Tools/Signals/Signals.qml +++ b/src/Plugins/Tools/Signals/Signals.qml @@ -40,6 +40,7 @@ Rectangle { color: "#000000" property string selectedPage: "" + property var selectedPageFact: null property bool pageStateRefreshPending: false property var pageStates: [] property var globalUi: ui @@ -47,8 +48,18 @@ Rectangle { property var prefsStore: application.prefs property real uiScale: control.globalUi ? control.globalUi.scale : 1 - readonly property var pages: signalsModel.pages - readonly property var pinnedPages: signalsModel.pinnedPages + readonly property var pages: setsFact.activePages() + readonly property var pinnedPages: { + var list = [] + var source = setsFact.activePages() + + for (var i = 0; i < source.length; ++i) { + if (control.pagePinned(source[i])) + list.push(source[i]) + } + + return list + } readonly property int activePageIndex: checkedPageIndex() readonly property var activePage: pageAt(activePageIndex) readonly property real pinnedChartHeight: Math.max(44 * uiScale, @@ -59,9 +70,21 @@ Rectangle { SignalsModel { id: signalsModel prefsAdapter: control.prefsStore + } - onSettingsLoaded: control.handleModelChanged() - onSettingsSaved: control.handleModelChanged() + MenuSets { + id: setsFact + signalsModel: signalsModel + destroyOnClose: false + } + + Connections { + target: setsFact + + function onStateChanged() + { + control.handleModelChanged() + } } Component { @@ -126,9 +149,24 @@ Rectangle { if (!popup) return - createPopupFact(popup, "MenuSets.qml", { - "signalsModel": signalsModel - }) + popup.showFact(setsFact) + popup.open() + } + + function openPageEditor(page) + { + if (!page) + return + + selectedPageFact = page + selectedPage = pageTitle(page, pageIndexFor(page)) + + var popup = createEditorPopup(Qt.point(0.76, 0.08)) + if (!popup) + return + + popup.showFact(page) + popup.open() } function pageAt(index) @@ -149,6 +187,8 @@ Rectangle { function pageName(index) { var page = pageAt(index) + if (page && typeof page.pageName === "function") + return String(page.pageName()) if (page && page.name) return String(page.name) return signalsModel.defaultPageTitle(Math.max(index, 0)) @@ -157,11 +197,15 @@ Rectangle { function pageFacts(index) { var page = pageAt(index) + if (page && typeof page.itemFacts === "function") + return page.itemFacts() return page && page.items instanceof Array ? page.items : [] } function pageTitle(page, fallbackIndex) { + if (page && typeof page.pageName === "function") + return String(page.pageName()) if (page && page.name) return String(page.name) return signalsModel.defaultPageTitle(Math.max(fallbackIndex, 0)) @@ -169,11 +213,20 @@ Rectangle { function activeSetTitle() { - if (signalsModel.activeSet && signalsModel.activeSet.title) - return String(signalsModel.activeSet.title) + if (typeof setsFact.activeSetTitle === "function") + return String(setsFact.activeSetTitle()) return "" } + function pagePinned(page) + { + if (!page) + return false + if (typeof page.isPinned === "function") + return page.isPinned() + return !!page.pin + } + function pageIndexFor(page) { for (var i = 0; i < pages.length; ++i) { @@ -189,7 +242,7 @@ Rectangle { var match = 0 for (var i = 0; i < pages.length; ++i) { - if (!pages[i] || !pages[i].pin) + if (!control.pagePinned(pages[i])) continue if (match === pinnedIndex) @@ -214,6 +267,9 @@ Rectangle { if (!page) return 1.0 + if (typeof page.currentSpeedValue === "function") + return page.currentSpeedValue() + if (signalsModel && typeof signalsModel.normalizeSpeed === "function") return signalsModel.normalizeSpeed(page.speed) @@ -254,7 +310,7 @@ Rectangle { function itemBind(item) { if (!item || item.bind === undefined) - return "" + return item && typeof item.bindText === "function" ? item.bindText() : "" return normalizeBindText(item.bind) } @@ -266,6 +322,10 @@ Rectangle { function itemColor(item, index) { + if (item && typeof item.colorValueCurrent === "function" + && String(item.colorValueCurrent()).trim() !== "") + return item.colorValueCurrent() + if (item && item.color !== undefined && String(item.color).trim() !== "") return item.color return defaultItemColor(index) @@ -273,11 +333,22 @@ Rectangle { function itemTitle(item, index) { + if (item && item.title !== undefined && String(item.title) !== "") + return String(item.title) if (item) return itemBind(item) return qsTr("Item") + " " + (index + 1) } + function itemWarningText(item) + { + if (!item) + return "" + if (typeof item.warningText === "function") + return item.warningText() + return item.warning !== undefined ? String(item.warning) : "" + } + function escapeHtml(text) { return String(text) @@ -303,7 +374,7 @@ Rectangle { var rawValue = value var bind = itemBind(item) var title = itemTitle(item, 0) - var pageNameValue = page && page.name ? page.name : "" + var pageNameValue = page ? pageTitle(page, 0) : "" try { return !!eval(expression) @@ -325,7 +396,7 @@ Rectangle { return state var lines = ["" + escapeHtml(pageName(index)) + ""] - var items = page.items instanceof Array ? page.items : [] + var items = pageFacts(index) for (var i = 0; i < items.length; ++i) { var item = items[i] @@ -334,9 +405,10 @@ Rectangle { + escapeHtml(title)) var value = evaluateBinding(itemBind(item)) - if (item.warning && evaluateCondition(item.warning, value, item, page)) { + var warningExpr = itemWarningText(item) + if (warningExpr !== "" && evaluateCondition(warningExpr, value, item, page)) { state.warning = true - state.messages.push(qsTr("Warning") + ": " + title + " (" + item.warning + ")") + state.messages.push(qsTr("Warning") + ": " + title + " (" + warningExpr + ")") } } @@ -384,18 +456,29 @@ Rectangle { function selectSavedPage() { if (pages.length <= 0) { + selectedPageFact = null selectedPage = "" signals.facts = [] return } + if (selectedPageFact && pageIndexFor(selectedPageFact) >= 0) { + selectedPage = pageTitle(selectedPageFact, pageIndexFor(selectedPageFact)) + return + } + + selectedPageFact = null + if (selectedPage !== "") { for (var i = 0; i < pages.length; ++i) { - if (pageName(i) === selectedPage) + if (pageName(i) === selectedPage) { + selectedPageFact = pageAt(i) return + } } } + selectedPageFact = pageAt(0) selectedPage = pageName(0) } @@ -415,6 +498,11 @@ Rectangle { if (!activePage) return + if (typeof activePage.setSpeedValue === "function") { + activePage.setSpeedValue(signalsModel.nextSpeedValue(pageSpeedValue(activePage))) + return + } + signalsModel.setPageSpeed(activePageIndex, signalsModel.nextSpeedValue(activePage.speed)) } @@ -425,6 +513,11 @@ Rectangle { if (index < 0) return + if (typeof page.setSpeedValue === "function") { + page.setSpeedValue(signalsModel.nextSpeedValue(pageSpeedValue(page))) + return + } + signalsModel.setPageSpeed(index, signalsModel.nextSpeedValue(page.speed)) } @@ -441,6 +534,11 @@ Rectangle { if (!targetPage) return + if (typeof targetPage.setSpeedValue === "function") { + targetPage.setSpeedValue(signalsModel.nextSpeedValue(pageSpeedValue(targetPage))) + return + } + signalsModel.setPageSpeed(index, signalsModel.nextSpeedValue(targetPage.speed)) } @@ -469,11 +567,9 @@ Rectangle { uiContext: control.globalUi apxContext: control.globalApx facts: pinnedChartPane.modelData && pinnedChartPane.modelData.items instanceof Array - ? pinnedChartPane.modelData.items - : [] - speed: signalsModel.speedIndex(pinnedChartPane.modelData - ? pinnedChartPane.modelData.speed - : 1.0) + ? pinnedChartPane.modelData.items + : control.pageFacts(control.pageIndexFor(pinnedChartPane.modelData)) + speed: signalsModel.speedIndex(control.pageSpeedValue(pinnedChartPane.modelData)) } MouseArea { @@ -536,7 +632,7 @@ Rectangle { apxContext: control.globalApx facts: [] speed: control.activePage - ? signalsModel.speedIndex(control.activePage.speed) + ? signalsModel.speedIndex(control.pageSpeedValue(control.activePage)) : signalsModel.speedIndex(1.0) } @@ -616,11 +712,13 @@ Rectangle { onCheckedButtonChanged: { if (checkedButton) { - control.selectedPage = checkedButton.text + control.selectedPageFact = checkedButton.pageFact + control.selectedPage = control.pageName(checkedButton.pageIndex) signals.facts = Qt.binding(function() { return checkedButton ? checkedButton.values : [] }) } else { + control.selectedPageFact = null signals.facts = [] } } @@ -646,14 +744,16 @@ Rectangle { required property int index property int pageIndex: index + property var pageFact: modelData text: control.pageName(index) - checked: control.selectedPage !== "" - ? text === control.selectedPage + checked: control.selectedPageFact + ? pageFact === control.selectedPageFact : index === 0 values: control.pageFacts(index) pageToolTip: control.pageState(index).toolTip pageWarning: control.pageState(index).warning + onEditTriggered: control.openPageEditor(pageFact) } } diff --git a/src/Plugins/Tools/Signals/SignalsView.qml b/src/Plugins/Tools/Signals/SignalsView.qml index 967f35ecd..70c20f8c9 100644 --- a/src/Plugins/Tools/Signals/SignalsView.qml +++ b/src/Plugins/Tools/Signals/SignalsView.qml @@ -45,12 +45,6 @@ Item { ? speedFactor[speedFactor.length - 1] : speedFactor[speed] - property var seriesState: [] - - FilterRegistry { - id: filterRegistry - } - QtObject { id: colorProbe @@ -59,8 +53,6 @@ Item { onFactsChanged: chartView.reset() - Component.onDestruction: destroySeriesState() - Connections { target: chartItem.apxContext ? chartItem.apxContext.fleet.current.mandala : null @@ -91,6 +83,9 @@ Item { if (!fact) return "" + if (typeof fact.bindText === "function") + return normalizeBindText(fact.bindText()) + if (fact.bind !== undefined && fact.bind !== null && String(fact.bind) !== "") return normalizeBindText(fact.bind) if (fact.name !== undefined && fact.name !== null) @@ -101,6 +96,9 @@ Item { function itemTitle(fact, index) { + if (fact && fact.title !== undefined && String(fact.title) !== "") + return String(fact.title) + var bind = itemBind(fact) if (bind !== "") return bind @@ -126,6 +124,10 @@ Item { function itemColor(fact, index) { if (fact) { + if (typeof fact.colorValueCurrent === "function" + && String(fact.colorValueCurrent()) !== "") + return resolvedColor(fact.colorValueCurrent(), defaultSeriesColor(index)) + if (fact.color !== undefined && fact.color !== null && String(fact.color) !== "") return resolvedColor(fact.color, defaultSeriesColor(index)) @@ -138,7 +140,13 @@ Item { function itemSaveTarget(fact) { - if (!fact || fact.save === undefined || fact.save === null) + if (!fact) + return "" + + if (typeof fact.saveTarget === "function") + return fact.saveTarget() + + if (fact.save === undefined || fact.save === null || typeof fact.save === "function") return "" return String(fact.save).trim() @@ -146,7 +154,7 @@ Item { function evaluateValue(fact, bind) { - if (fact && fact.value !== undefined) + if (fact && typeof fact.bindText !== "function" && fact.value !== undefined) return fact.value if (!bind) @@ -159,120 +167,23 @@ Item { } } - function destroySeriesState() - { - for (var i = 0; i < seriesState.length; ++i) { - var state = seriesState[i] - if (!state || !(state.filters instanceof Array)) - continue - - for (var j = 0; j < state.filters.length; ++j) { - var filterObject = state.filters[j] - if (filterObject) - filterObject.destroy() - } - } - - seriesState = [] - } - - function invokeFilterMethod(filterObject, methodName, argument) + function filteredItemValue(fact, value) { - if (!filterObject) - return undefined + if (fact && typeof fact.updateFilters === "function") + return fact.updateFilters(value) - var method = filterObject[methodName] - if (!method) - return undefined - - if (argument === undefined) - return method.call(filterObject) - - return method.call(filterObject, argument) + return value } - function buildSeriesState(fact, index) + function writeSavedValue(fact, value) { - var bind = itemBind(fact) - var filterDefs = filterRegistry.normalizeFilters(fact && fact.filters instanceof Array - ? fact.filters - : []) - var filters = [] - - for (var i = 0; i < filterDefs.length; ++i) { - var filterData = filterDefs[i] - var source = filterRegistry.componentSource(filterData.type) - if (!source) - continue - - var component = Qt.createComponent(source) - if (!component || component.status !== Component.Ready) { - if (component) - component.destroy() - continue - } - - var filterObject = component.createObject(chartItem) - component.destroy() - - if (!filterObject) - continue - - invokeFilterMethod(filterObject, "load", filterData) - invokeFilterMethod(filterObject, "reset") - filters.push(filterObject) - } - var saveTarget = itemSaveTarget(fact) - - return { - "bind": bind, - "title": itemTitle(fact, index), - "color": itemColor(fact, index), - "filters": filters, - "saveTarget": saveTarget, - "saveFact": saveTarget !== "" && chartItem.apxContext - ? chartItem.apxContext.fleet.current.mandala.fact(saveTarget, true) - : null - } - } - - function seriesStateAt(index) - { - while (seriesState.length <= index) - seriesState.push(null) - - if (!seriesState[index]) - seriesState[index] = buildSeriesState(chartItem.facts[index], index) - - return seriesState[index] - } - - function applyFilterChain(state, value) - { - var filtered = value - - if (!state || !(state.filters instanceof Array)) - return filtered - - for (var i = 0; i < state.filters.length; ++i) { - var filterObject = state.filters[i] - var stepFilter = filterObject ? filterObject["step"] : null - if (!stepFilter) - continue - - filtered = stepFilter.call(filterObject, filtered) - } - - return filtered - } - - function writeSavedValue(state, value) - { - if (!state || !state.saveFact) + if (saveTarget === "" || !chartItem.apxContext) return - state.saveFact.setRawValueLocal(value) + var saveFact = chartItem.apxContext.fleet.current.mandala.fact(saveTarget, true) + if (saveFact) + saveFact.setRawValueLocal(value) } function isCommandBind(bind) @@ -280,14 +191,15 @@ Item { return bind.indexOf("cmd.") === 0 || bind.indexOf("cmd") === 0 } - function applySeriesStyle(series, state) + function applySeriesStyle(series, fact, index) { - if (!series || !state) + if (!series || !fact) return - var color = state.color + var bind = itemBind(fact) + var color = itemColor(fact, index) - if (isCommandBind(state.bind)) { + if (isCommandBind(bind)) { series.width = Qt.binding(function() { return lineWidthCmd }) @@ -303,19 +215,24 @@ Item { } } - function updateSeriesColor(index, color) + function syncSeries(series, fact, index) { - if (index < 0 || index >= chartItem.facts.length) + if (!series || !fact) return - var state = seriesStateAt(index) - if (!state) - return + var title = itemTitle(fact, index) + if (series.name !== title) + series.name = title + + applySeriesStyle(series, fact, index) + } - state.color = resolvedColor(color, defaultSeriesColor(index)) + function updateSeriesColor(index) + { + if (index < 0 || index >= chartItem.facts.length || index >= chartView.count) + return - if (index < chartView.count) - applySeriesStyle(chartView.series(index), state) + syncSeries(chartView.series(index), chartItem.facts[index], index) } ChartView { @@ -387,7 +304,6 @@ Item { function reset() { - chartItem.destroySeriesState() chartView.removeAllSeries() chartView.sdata = [] chartView.time = 0 @@ -443,18 +359,19 @@ Item { addFactSeries(fact, i) var series = chartView.series(i) - var state = chartItem.seriesStateAt(i) - var value = chartItem.evaluateValue(fact, state.bind) + chartItem.syncSeries(series, fact, i) + + var value = chartItem.evaluateValue(fact, chartItem.itemBind(fact)) if (!isFinite(value)) value = 0 - value = chartItem.applyFilterChain(state, value) + value = chartItem.filteredItemValue(fact, value) if (!isFinite(value)) value = 0 - chartItem.writeSavedValue(state, value) + chartItem.writeSavedValue(fact, value) series.append(t, value) sdata.push(value) @@ -469,19 +386,18 @@ Item { function addFactSeries(fact, index) { - var state = chartItem.seriesStateAt(index) var series = chartView.createSeries(chartItem.uiContext && chartItem.uiContext.antialiasing ? ChartView.SeriesTypeLine : ChartView.SeriesTypeLine, - state.title, + chartItem.itemTitle(fact, index), axisX, axisY) series.useOpenGL = Qt.binding(function() { return openGL }) series.capStyle = Qt.RoundCap - chartItem.applySeriesStyle(series, state) + chartItem.applySeriesStyle(series, fact, index) return series } } From 9176951ac0e0053fda624a8f95c11ed550fa8815 Mon Sep 17 00:00:00 2001 From: Aliaksei Stratsilatau Date: Fri, 24 Apr 2026 16:17:34 -0400 Subject: [PATCH 7/7] docs update Co-authored-by: Copilot --- src/Plugins/Tools/Signals/README.md | 17 ++++++++++---- src/Plugins/Tools/Signals/REFACTOR_NOTES.md | 26 +++++++++++++++------ 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/Plugins/Tools/Signals/README.md b/src/Plugins/Tools/Signals/README.md index e2ca217aa..9532af2a7 100644 --- a/src/Plugins/Tools/Signals/README.md +++ b/src/Plugins/Tools/Signals/README.md @@ -9,9 +9,10 @@ Configurable telemetry chart plugin for APX GCS. ## Current behavior - Saved configurations live in `signals.json` as sets of pages and items. -- The bottom tab row keeps the original `Signals` workflow: `SignalButton` tabs only switch pages. +- The bottom tab row uses `SignalButton`: single click switches pages, and double-click or long-press opens that page editor. - The `+` button opens the full editor for sets, pages, items, colors, filters, and save targets. - Pinned pages are rendered as stacked charts above the main chart inside the same widget. +- Page tabs highlight active warnings and their tooltips include the page items plus any warning messages. - Chart overlays use `apx.font_narrow`: - top-right page name on every chart - speed label directly below the page name when the page speed is not `1x` @@ -39,14 +40,20 @@ Notes: ## Filters -Filters are plain `Fact` children, not a custom editor page. +Filters are edited inline from the item editor, without a separate custom filter page. Current filter types: -- `running_avg` -- `kalman_smp` +- `running_avg` (`Running average`) +- `kalman_smp` (`Simple Kalman`) -The filter stack is ordered, draggable, and can be enabled or disabled per filter. The rendered series value and the optional `save` output both use the filtered result. +The filter stack is ordered and draggable. A single `Add filter` action appends a generic filter row that owns: + +- an enable toggle +- a filter `type` selector +- a dynamically loaded settings section for the selected filter type + +The concrete math and parameter fields live in dedicated settings facts such as `FilterRunningAverage.qml` and `FilterKalman.qml`, loaded through `FilterFact.qml`. The rendered series value and the optional `save` output both use the filtered result. ## Persistence diff --git a/src/Plugins/Tools/Signals/REFACTOR_NOTES.md b/src/Plugins/Tools/Signals/REFACTOR_NOTES.md index 65c63f618..681b750ef 100644 --- a/src/Plugins/Tools/Signals/REFACTOR_NOTES.md +++ b/src/Plugins/Tools/Signals/REFACTOR_NOTES.md @@ -19,11 +19,14 @@ Current implementation snapshot for `src/Plugins/Tools/Signals/`. - `SignalsView.qml` - Owns the chart renderer. - Builds series from page items. - - Runs the filter chain per item. + - Reads raw samples from live page items. + - Delegates filter execution to each item's `updateFilters()` chain. - Writes filtered values to `sns.scr.*` save targets. - Preserves `cmd.*` desaturated styling and grow-only Y-axis behavior. - `SignalButton.qml` - - Remains the active tab-button implementation and behavioral baseline. + - Remains the active tab-button implementation. + - Single click switches pages; double-click / long-press opens that page editor. + - Surfaces warning state through tab color and tooltip content. ## Persistence and model @@ -105,16 +108,22 @@ Current filter types: Current pattern: - Filters are generic `FilterFact.qml` rows inside the item’s `Filters` group. +- One generic `Add filter` action appends a new wrapper row with the registry default type. - Each filter is ordered and draggable. - Each filter has an enable toggle on the row itself. -- Each filter row owns the enable/type shell while the loaded settings Fact owns the runtime state and `update()` implementation. +- Each filter row owns the enable/type shell and swaps the loaded settings Fact when the type changes. +- Each loaded settings Fact owns the runtime state and `update()` implementation. - `MenuItem.qml` applies the live filter chain; `SignalsView.qml` only provides raw samples. ## Current UX decisions - `+` opens the full Signals editor, not a quick-bind field. -- Tabs only switch pages. No edit affordances or pin actions are attached to the tabs. +- Single click on a tab switches pages. +- Double-click or long-press on a tab opens that page editor. +- Pin actions remain in the page editor; there is no tab pin control. - Pinned pages are controlled from the page editor. +- Page-tab tooltips list item labels and active warning messages. +- Warning state colors the affected tab. - Speed is per page. - The dedicated bottom speed button was removed. - Clicking a chart cycles that page’s speed. @@ -136,9 +145,9 @@ Current pattern: | `MenuItem.qml` | item editor | active | | `ColorChooser.qml` | color editor page | active | | `FilterFact.qml` | generic filter shell | active | -| `FilterBase.qml` | shared filter Fact contract | active | -| `FilterRunningAverage.qml` | running average filter | active | -| `FilterKalman.qml` | Kalman filter | active | +| `FilterBase.qml` | shared base for filter settings facts | active | +| `FilterRunningAverage.qml` | running average settings + runtime | active | +| `FilterKalman.qml` | Kalman settings + runtime | active | | `FilterRegistry.qml` | filter metadata and normalization | active | | `PageButton.qml` | old experiment from an earlier tab rewrite | currently unused | @@ -151,4 +160,7 @@ The markdown files should reflect these current decisions: - chart click cycles speed - speed label sits below the page title and is hidden at `1x` - `SignalButton.qml` remains the active tab path +- tab double-click / long-press opens the page editor +- page tabs surface warning state and warning text - item schema is `bind/color/filters/warning/save` +- filters use a generic wrapper row with a type selector that swaps dedicated settings facts