diff --git a/src/Plugins/Tools/Signals/README.md b/src/Plugins/Tools/Signals/README.md index fe4294aa8..86c6e2792 100644 --- a/src/Plugins/Tools/Signals/README.md +++ b/src/Plugins/Tools/Signals/README.md @@ -4,4 +4,4 @@ page: plugins # Signals -QML widget to show live chart of defined UAV physical values for easy tuning. +QML widget to show live chart of selected UAV physical values with the ability to use filters for easy tuning and analysis. diff --git a/src/Plugins/Tools/Signals/Signals.qml b/src/Plugins/Tools/Signals/Signals.qml index b79e19bbb..623ce59d3 100644 --- a/src/Plugins/Tools/Signals/Signals.qml +++ b/src/Plugins/Tools/Signals/Signals.qml @@ -27,177 +27,161 @@ import QtCore import Apx.Common - Rectangle { - id: control + id: sgControl implicitHeight: layout.implicitHeight - implicitWidth: layout.implicitWidth - + implicitWidth: Style.buttonSize * 15 border.width: 0 color: "#000" - ColumnLayout { - id: layout - anchors.fill: parent - spacing: 0 + readonly property var pages: sgMenu.getActivePages() + readonly property var activeSet: sgMenu.getActiveSet() + + onActiveSetChanged: updateModels() + Component.onCompleted: { + if (buttonGroup.buttons.length <= 0) + return; + if (buttonGroup.checkedButton == null) + buttonGroup.checkedButton = buttonGroup.buttons[0]; // check button #1 + } - SignalsView { - id: signals - facts: [] - Layout.fillWidth: true - Layout.fillHeight: true - Layout.minimumHeight: 20 - Layout.preferredHeight: 130*ui.scale + Connections { + target: apx.fleet.current.mandala + function onTelemetryDecoded() { + if (pages.length <= 0) + return; + for (var i = 0; i < pages.length; ++i) + pages[i].updateChartsValues(); } + } - TextInput { - id: textInput + function updateModels() { + pinnedModel.updateModel(sgMenu.getPinnedPages()) + buttonsModel.updateModel(sgMenu.getActivePages()) + } - Layout.fillWidth: true - Layout.minimumHeight: Style.fontSize + function checkScrMatches(val) { + var matches = false; + for (var i = 0; i < buttonGroup.buttons.length; ++i) + if (buttonGroup.buttons[i].getScrMatches(val)) + matches = true; + return matches; + } - // clip: true - // focus: true - visible: false + function allowResetChart(num) { + for(var i = 0; i < pinnedRepeater.count; ++i) { + if(num === pinnedRepeater.itemAt(i).num) + pinnedRepeater.itemAt(i).allowReset() + } + } - horizontalAlignment: Text.AlignRight - verticalAlignment: Text.AlignVCenter + function clearButtonGroup() { + buttonGroup.buttons = []; + buttonGroup.checkedButton = null; + } - font: apx.font_narrow(Style.fontSize) + ColumnLayout { + id: layout + anchors.fill: parent - color: activeFocus?Material.color(Material.Yellow):Material.primaryTextColor - text: "est.air.airspeed" + SignalsPinnedModel { + id: pinnedModel + } - activeFocusOnTab: true - selectByMouse: true + Repeater { + id: pinnedRepeater + model: pinnedModel + } - onEditingFinished: { - updateFacts() - } - onActiveFocusChanged: { - if(activeFocus)selectAll(); - } - onVisibleChanged: if(visible)forceActiveFocus() - Component.onCompleted: updateFacts() - - property var facts: [] - function updateFacts() - { - var flist=[] - var list=textInput.text.split(',') - 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 QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Controls.Material + +import Apx.Common + +TextButton { + id: sgBtn + + property var pageFact: null + property string pageToolTip: pageFact ? pageFact.pageToolTip : "" + property bool pageWarning: pageFact ? pageFact.warnings : false + property var num: pageFact ? pageFact.num : -1 + + Layout.fillHeight: true + checkable: true + ButtonGroup.group: buttonGroup + text: pageFact ? pageFact.title.slice(0, 3) : getDefaultText() + textColor: checked ? Material.color(Material.Yellow) : Material.primaryTextColor + toolTip: pageFact ? pageFact.pageToolTip : "" + Material.background: pageWarning ? Material.color(Material.Orange) : sgBtn.color + + Component.onCompleted: { + if(!pageFact) + return; + checked = pageFact.active + } + onCheckedChanged: { + if (!pageFact) + return; + if (checked) + mainChartArea.ciPageFact = pageFact; + // Check if set changed and model reset + if(buttonsModel.count === 0) + return; + pageFact.active = checked; + } + + onPressed: { + if (!pageFact) + return; + if (checked) + pageFact.trigger(); + } + + onActivated: { + mainChartArea.allowReset() + mainChartArea.ciPageFact = Qt.binding(function () { + return pageFact; + }); + } + + function getDefaultText() { + return "#" + buttonGroup.buttons.indexOf(this); + } +} diff --git a/src/Plugins/Tools/Signals/SignalsButtonsModel.qml b/src/Plugins/Tools/Signals/SignalsButtonsModel.qml new file mode 100644 index 000000000..5383c9398 --- /dev/null +++ b/src/Plugins/Tools/Signals/SignalsButtonsModel.qml @@ -0,0 +1,83 @@ +/* + * 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 QtQml.Models +import QtQuick.Layouts + +import Apx.Common + + +ObjectModel { + id: buttonsModel + + function addButton(fact) { + if(!fact) + return; + var component = Qt.createComponent("SignalsButton.qml"); + if (component.status === Component.Ready) { + var c=component.createObject(bottomArea, {"pageFact": fact}); + buttonsModel.append(c); + } + } + + function removeButton(num) { + for(var i = 0; i < buttonsModel.count; ++i) { + var btn = buttonsModel.get(i); + if(btn.num !== num) + continue; + buttonsModel.remove(i); + } + autoChecked(); + } + + function updateModel(pageList) { + // for(var i = 0; i < buttonsModel.count; ++i) { + // var btn = buttonsModel.get(i); + // buttonsModel.remove(i); + // btn.destroy(); + // } + clearModel(); + for(var i = 0; i < pageList.length; ++i) { + var f = pageList[i] + if(!f) + continue; + buttonsModel.addButton(f) + } + } + + function autoChecked() { + if(buttonsModel.count === 0) + return; + var index = 0; + for(var i = 0; i < buttonsModel.count; ++i) { + if (buttonsModel.get(i).checked){ + index = i; + } + } + buttonsModel.get(index).checked = true; + } + + function clearModel() { + buttonsModel.clear(); + sgControl.clearButtonGroup(); // ButtonGroup keep model element after clear + } +} diff --git a/src/Plugins/Tools/Signals/SignalsChartItem.qml b/src/Plugins/Tools/Signals/SignalsChartItem.qml new file mode 100644 index 000000000..96979269b --- /dev/null +++ b/src/Plugins/Tools/Signals/SignalsChartItem.qml @@ -0,0 +1,101 @@ +/* + * 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 QtCharts +import QtQml + +import Apx.Common + + +Item { + id: chartItem + + property var ciPageFact: null + property var num: ciPageFact ? ciPageFact.num : -1 + property var labelFont: apx.font_narrow(Style.fontSize * 0.8) + + SignalsChartView { + id: ciChart + anchors.fill: parent + facts: ciPageFact ? ciPageFact.values : [] + speedFactorValue: ciPageFact ? ciPageFact.speed : 1 + } + + Rectangle { + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: 4 * ui.scale + radius: 2 * ui.scale + visible: lblPage.text !== "" + color: mouseArea.containsMouse ? "#50ffffff" : "#30ffffff" + implicitWidth: columnPage.implicitWidth + 10 * ui.scale + implicitHeight: columnPage.implicitHeight + 4 * ui.scale + + Column { + id: columnPage + anchors.centerIn: parent + opacity: 0.7 + + Label { + id: lblPage + anchors.horizontalCenter: parent.horizontalCenter + text: ciPageFact ? ciPageFact.title : "" + font: labelFont + } + Label { + id: lblSpeed + anchors.right: parent.right + text: ciChart.speedFactorValue + "x" + font: labelFont + } + } + MouseArea { + id: mouseArea + anchors.fill: parent + acceptedButtons: Qt.LeftButton + hoverEnabled: true + onClicked: changeSpeed() + } + } + + function changeSpeed() { + if (!ciPageFact) + return; + if (ciChart.speedFactorValue !== ciChart.speedFactor[ciChart.speedFactor.length - 1]) { + for (var i = 0; i < ciChart.speedFactor.length - 1; ++i) { + if (ciChart.speedFactor[i] <= ciChart.speedFactorValue && ciChart.speedFactorValue < ciChart.speedFactor[i + 1]) { + ciPageFact.speed = ciChart.speedFactor[i + 1]; + break; + } + } + } else { + ciPageFact.speed = ciChart.speedFactor[0]; + } + autosaveTimer.restart(); + } + + function allowReset() { + ciChart.resetEnable = true; + } +} diff --git a/src/Plugins/Tools/Signals/SignalsChartView.qml b/src/Plugins/Tools/Signals/SignalsChartView.qml new file mode 100644 index 000000000..2dfae455b --- /dev/null +++ b/src/Plugins/Tools/Signals/SignalsChartView.qml @@ -0,0 +1,218 @@ +/* + * 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 + +import Apx.Common + +Item { + id: chartItem + + property var facts: [] + + property bool openGL: false //apx.settings.graphics.opengl.value + 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(); + } + } + + 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: apx.font_narrow(Style.fontSize * 0.65) + gridLineColor: "#555" + truncateLabels: false // don't hide labels with small vertical space + } + + 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; + 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 < 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.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); + // Check color + var color = fact.opts!== undefined ? fact.opts.color : undefined + if(!color || color === undefined) + return; + if(s.color !== color) + s.color = color + } + + 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; + + 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; + } + } +} diff --git a/src/Plugins/Tools/Signals/SignalButton.qml b/src/Plugins/Tools/Signals/SignalsColorChooser.qml similarity index 57% rename from src/Plugins/Tools/Signals/SignalButton.qml rename to src/Plugins/Tools/Signals/SignalsColorChooser.qml index 4985ed648..6dfa9bcdd 100644 --- a/src/Plugins/Tools/Signals/SignalButton.qml +++ b/src/Plugins/Tools/Signals/SignalsColorChooser.qml @@ -20,28 +20,38 @@ * 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 { + id: colorFact - property var values: [] - onActivated: signals.facts=Qt.binding(function(){return values}) + property string colorAuto: parentFact ? parentFact.colorAuto : "#E57373" + property string colorValue: "" - toolTip: getToolTip(values) + flags: Fact.CloseOnTrigger + opts: ({ + "editor": Qt.resolvedUrl("SignalsColorEditor.qml") + }) - function getToolTip(facts) - { - var s=[] - for(var i=0;i"+fact.descr+"") + onTriggered: setColor() + + function setColor() { + if(!parentFact) + return; + if(title === colorAuto) { + setAutoColor() + return; } - return s.join("
") + parentFact.value = colorValue; + } + + function setAutoColor() { + if (title !== colorAuto) + return; + if (!parentFact) + return; + parentFact.setDefaultColor() } } + diff --git a/src/Plugins/Tools/Signals/SignalsColorEditor.qml b/src/Plugins/Tools/Signals/SignalsColorEditor.qml new file mode 100644 index 000000000..6c7638ef3 --- /dev/null +++ b/src/Plugins/Tools/Signals/SignalsColorEditor.qml @@ -0,0 +1,46 @@ +/* + * 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.Material + +Rectangle { + id: editor + + readonly property string colorAuto: qsTr("Auto") + readonly property string colorText: fact && fact.value !== undefined ? fact.value.trim() : colorAuto + readonly property bool auto: colorText === colorAuto + + implicitHeight: factButton.height * 0.6 + implicitWidth: factButton.height * 1.8 + radius: height / 12 + border.width: 2 + border.color: Material.hintTextColor + color: !auto ? colorText.toUpperCase() : "transparent" + + Text { + anchors.centerIn: parent + text: qsTr("A") + font: apx.font_narrow(Math.max(10, parent.height * 0.55)) + color: Material.hintTextColor + visible: auto + } +} diff --git a/src/Plugins/Tools/Signals/SignalsFilterChooser.qml b/src/Plugins/Tools/Signals/SignalsFilterChooser.qml new file mode 100644 index 000000000..a58a3afe0 --- /dev/null +++ b/src/Plugins/Tools/Signals/SignalsFilterChooser.qml @@ -0,0 +1,70 @@ +/* + * 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: filterChooser + + function addSelected() { + for(var i = 0; i < filterChooser.size; ++i) { + var fact = filterChooser.child(i) + if(fact && fact.value) + fMenu.createFilter({"type": fact.name}) + } + } + + Fact { + id: chrRunningAvg + name: "running_avg" + title: qsTr("Running average") + descr: qsTr("Select running average filter") + flags: Fact.Bool + onTriggered: { + fMenu.createFilter({"type": name}); + filterChooser.menuBack() + } + } + Fact { + id: chrKalmanSimple + name: "kalman_smp" + title: qsTr("Kalman simple") + descr: qsTr("Select simple kalman filter") + flags: Fact.Bool + onTriggered: { + fMenu.createFilter({"type": name}); + filterChooser.menuBack() + } + } + + // Actions + Fact { + flags: (Fact.Action | Fact.Apply) + title: qsTr("Add selected") + icon: "check-circle" + onTriggered: { + addSelected() + filterChooser.menuBack() + } + } +} diff --git a/src/Plugins/Tools/Signals/SignalsFilterKalmanSimple.qml b/src/Plugins/Tools/Signals/SignalsFilterKalmanSimple.qml new file mode 100644 index 000000000..99246bf57 --- /dev/null +++ b/src/Plugins/Tools/Signals/SignalsFilterKalmanSimple.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 + +Fact { + id: ksFilter + title: qsTr("Kalman simple") + descr: qsTr("Simple kalman filter settings") + icon: "tune" + flags: (Fact.Group | Fact.Bool) + + property var filterType: "kalman_smp" + property bool changes: false + property var measCoef: 1 + property var envCoef: 1 + property var data: ({}) + + onValueChanged: fMenu.updateDescr() + onChangesChanged: changed(changes) + Component.onCompleted: load(data) + + signal changed(bool changesValue) + + function load(data) { + value = data.value !== undefined ? data.value : 0 + for (var i = 0; i < size; ++i) { + var f = child(i); + var v = data[settingName(f)]; + if(v !== undefined) + f.value = v; + } + updateDescr() + updateCoefs(); + } + + function save() { + data = {}; + data.type = filterType; + data.value = value; + 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 updateDescr() { + descr = qsTr("Coef").toUpperCase() + ": " + "Km=" + ksMeasNoise.value + ", Ke=" + ksEnvNoise.value; + changes = true; + } + + function updateCoefs() { + measCoef = ksMeasNoise.value; + envCoef = ksEnvNoise.value; + changes = false; + } + + // Use Kalman Simple filter + property var state: 0 + property var covariance: 0.1 + + function setKalmanState(st, cv) { + state = st; + covariance = cv; + } + + function processValue(value, v) { + // Time update - prediction + var x0 = state; + var p0 = covariance + measCoef; + + // Measurement update - correction + var k = p0 / (p0 + envCoef); + state = x0 + k * (v - x0); + covariance = (1 - k) * p0; + value = state; + return value; + } + + 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: updateDescr() + } + 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: updateDescr() + } + + // Actions + Fact { + flags: (Fact.Action | Fact.Apply) + title: qsTr("Save") + enabled: !mChart.newItem && changes + icon: "check-circle" + onTriggered: sgMenu.saveSettings() + } + Fact { + flags: (Fact.Action | Fact.Remove) + title: qsTr("Remove") + icon: "delete" + onTriggered: ksFilter.deleteFact(); + } +} diff --git a/src/Plugins/Tools/Signals/SignalsFilterRunningAvg.qml b/src/Plugins/Tools/Signals/SignalsFilterRunningAvg.qml new file mode 100644 index 000000000..1cdf7c910 --- /dev/null +++ b/src/Plugins/Tools/Signals/SignalsFilterRunningAvg.qml @@ -0,0 +1,121 @@ +/* + * 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 + title: qsTr("Running average") + descr: qsTr("Running average filter settings") + icon: "tune" + flags: (Fact.Group | Fact.Bool) + + property var filterType: "running_avg" + property bool changes: false + property var data: ({}) + property var coef: 1 + + onValueChanged: fMenu.updateDescr() + onChangesChanged: changed(changes) + Component.onCompleted: load(data) + + signal changed(bool changesValue) + + function load(data) { + value = data.value !== undefined ? data.value : 0 + for (var i = 0; i < size; ++i) { + var f = child(i); + var v = data[settingName(f)]; + if(v !== undefined) + f.value = v; + } + updateDescr(); + updateCoef(); + } + + function save() { + data = {}; + data.type = filterType; + data.value = value; + 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 updateDescr() { + descr = qsTr("Coef").toUpperCase() + ": K=" + raCoef.value + changes = true; + } + + function updateCoef() { + coef = raCoef.value; + changes = false; + } + + // Use Running Average filter + function processValue(value, v) { + value += (v - value) * coef; + return value; + } + + Fact { + id: raCoef + name: "coefficient" + title: qsTr("Coefficient") + descr: qsTr("Coefficient for filtration") + flags: Fact.Float + value: 1 + min: 0 + max: 1 + precision: 3 + onValueChanged: updateDescr() + } + + // Actions + Fact { + flags: (Fact.Action | Fact.Apply) + title: qsTr("Save") + enabled: !mChart.newItem && changes + icon: "check-circle" + onTriggered: sgMenu.saveSettings() + } + Fact { + flags: (Fact.Action | Fact.Remove) + title: qsTr("Remove") + icon: "delete" + onTriggered: raFilter.deleteFact(); + } +} diff --git a/src/Plugins/Tools/Signals/SignalsMenu.qml b/src/Plugins/Tools/Signals/SignalsMenu.qml new file mode 100644 index 000000000..6695e6773 --- /dev/null +++ b/src/Plugins/Tools/Signals/SignalsMenu.qml @@ -0,0 +1,281 @@ +/* + * 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: sgMenu + property var defaults + property bool destroyOnClose: true + readonly property string configs: "signals_2.json" // TODO: rename to "signals.json" after all tests + + name: "signals" + flags: (Fact.Group | Fact.DragChildren) + title: qsTr("Signals") + descr: qsTr("Realtime chart configuration editor") + icon: "gauge" + + Component.onCompleted: loadSettings() + Component.onDestruction: removed() + + function getCharts() { + var charts = []; + for (var i = 0; i < arguments.length; i++) { + var fact = arguments[i]; + if (!fact) + continue; + if (!fact.opts) + continue; + if (!fact.opts.color) + continue; + var chart = { + "bind": fact.mpath(), + "color": fact.opts.color.toString() + }; + charts.push(chart); + } + return charts; + } + + function createDefaultSet() { + var set = { + "title": "default", + "checked": 0, + "pages": [ + { + "title": "R", + "charts": getCharts(mandala.cmd.att.roll, mandala.est.att.roll) + }, + { + "title": "P", + "charts": getCharts(mandala.cmd.att.pitch, mandala.est.att.pitch) + }, + { + "title": "Y", + "charts": getCharts(mandala.cmd.pos.bearing, mandala.cmd.att.yaw, mandala.est.att.yaw) + }, + { + "title": "Axy", + "charts": getCharts(mandala.est.acc.x, mandala.est.acc.y) + }, + { + "title": "Az", + "charts": getCharts(mandala.est.acc.z) + }, + { + "title": "G", + "charts": getCharts(mandala.est.gyro.x, mandala.est.gyro.y, mandala.est.gyro.z) + }, + { + "title": "Pt", + "charts": getCharts(mandala.est.pos.altitude, mandala.est.pos.vspeed, mandala.est.air.airspeed) + }, + { + "title": "Ctr", + "charts": getCharts(mandala.ctr.att.ail, mandala.ctr.att.elv, mandala.ctr.att.rud, mandala.ctr.eng.thr, mandala.ctr.eng.prop, mandala.ctr.str.rud) + }, + { + "title": "RC", + "charts": getCharts(mandala.cmd.rc.roll, mandala.cmd.rc.pitch, mandala.cmd.rc.thr, mandala.cmd.rc.yaw) + }, + { + "title": "Usr", + "charts": getCharts(mandala.est.usr.u1, mandala.est.usr.u2, mandala.est.usr.u3, mandala.est.usr.u4, mandala.est.usr.u5, mandala.est.usr.u6) + } + ] + }; + return set; + } + + function loadSettings() { + var sets = []; + var f = application.prefs.loadFile(configs); + var json = f ? JSON.parse(f) : {}; + var set = {}; + var currentSetIdx = -1; + if (json && json.sets) { + for (var i in json.sets) { + set = json.sets[i]; + if (!(set.pages && (set.pages instanceof Array))) + continue; + sets.push(set); + } + //set index + if(!json.active)json.active={} + var setIdx = json.active[name]; + if (setIdx >= 0 && setIdx < sets.length) + currentSetIdx = setIdx; + else if (sets.length > 0) + currentSetIdx = 0; + } + //defaults + if (sets.length <= 0 || !json.active) { + set = {}; + set = createDefaultSet(); + sets.push(set); + currentSetIdx = sets.length - 1; + } + + //create facts + for (i in sets) { + var c = createFact(sgMenu, "SignalsMenuSet.qml", { + "data": sets[i] + }); + c.selected.connect(select); + c.selected.connect(saveSettings); + } + select(currentSetIdx); + } + + function saveSettings() { + var fjson = application.prefs.loadFile(configs); + var json = fjson ? JSON.parse(fjson) : {}; + if (!json.active) + json.active = {}; + json.active[name] = 0; + json.sets = []; + for (var i = 0; i < size; ++i) { + var setFact = child(i); + var set = setFact.save(); + if (!set) + continue; + json.sets.push(set); + if (setFact.active) + json.active[name] = i; + } + application.prefs.saveFile(configs, JSON.stringify(json, ' ', 2)); + // console.log("Signals settings saved"); + } + + 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; + } + } + + function select(num) { + for (var i = 0; i < sgMenu.size; ++i) { + var set = sgMenu.child(i); + set.active = set.num == num; + } + } + + function createSet() { + var set = {}; + set.title = "Set" + (sgMenu.size + 1); + set.values = []; + set.pages = []; + set.checked = 0; + var c = createFact(sgMenu, "SignalsMenuSet.qml", { + "data": set + }); + c.selected.connect(select); + c.selected.connect(saveSettings); + c.trigger(); + } + + function resetToDefaults() { + + var setIndex = -1 + var set = createDefaultSet(); + for(var i = 0; i < sgMenu.size; ++ i) { + var title = sgMenu.child(i).title.trim().toLowerCase() + if (title === "default") { + var defaultSet = sgMenu.child(i); + if(defaultSet.active) { + pinnedModel.clear(); + buttonsModel.clearModel() + } + defaultSet.load(set); + defaultSet.move(0) + setIndex = i; + buttonsModel.autoChecked() + break; + } + } + if (setIndex < 0) { + var c = createFact(sgMenu, "SignalsMenuSet.qml", { + "data": set + }); + c.selected.connect(select); + c.selected.connect(saveSettings); + c.move(0); + } + select(0); + } + + function getActiveSetIndex() { + for (var i = 0; i < sgMenu.size; ++i) { + if (sgMenu.child(i).active) + return i; + } + return sgMenu.size > 0 ? 0 : -1; + } + + function getActiveSet() { + var index = getActiveSetIndex(); + if (index < 0 || index >= sgMenu.size) + return null; + return sgMenu.child(index); + } + + function getActivePages() { + if (sgMenu.size <= 0) + return []; + var activeSet = getActiveSet(); + if (!activeSet) + return []; + return activeSet.getPages(); + } + + function getPinnedPages() { + if (sgMenu.size <= 0) + return []; + var activeSet = getActiveSet(); + if (!activeSet) + return []; + return activeSet.getPinned(); + } + + Fact { + title: qsTr("Add set") + flags: Fact.Action + icon: "plus-circle" + onTriggered: createSet() + } + Fact { + title: qsTr("Reset to defaults") + flags: Fact.Action + icon: "restore" + onTriggered: resetToDefaults() + } + Fact { + title: qsTr("Save") + flags: (Fact.Action | Fact.Apply) + icon: "check-circle" + onTriggered: saveSettings() + } +} diff --git a/src/Plugins/Tools/Signals/SignalsMenuChart.qml b/src/Plugins/Tools/Signals/SignalsMenuChart.qml new file mode 100644 index 000000000..9661627e1 --- /dev/null +++ b/src/Plugins/Tools/Signals/SignalsMenuChart.qml @@ -0,0 +1,307 @@ +/* + * 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: mChart + + flags: Fact.Group + precision: 2 + icon: "rectangle" + + property bool changes: false + property bool newItem: false + property bool warning: false + property var warningMsg: "" + property var data: ({}) + + // Chart values + property var type: "none" + property var expr: "" + property var exprWarn: "" + property var scr: "" + + signal addTriggered + + Component.onCompleted: { + load(data); + updateTitle(); + updateDescr(); + mBind.valueChanged.connect(updateTitle); + mBind.valueChanged.connect(updateDescr); + mColor.valueChanged.connect(updateDescr); + mFilters.descrChanged.connect(updateDescr); + mWarn.textChanged.connect(updateDescr); + mFact2Save.valueChanged.connect(updateDescr); + } + + onValueChanged: saveValue2Fact() + onChangesChanged: {if (changes && !newItem) pageFact.changes = true} + + function load(data) { + for (var i = 0; i < mChart.size; ++i) { + var f = child(i); + var v = data[settingName(f)]; + f.value = v; + } + updateChartVars(); + } + + function save() { + data = {}; + for (var i = 0; i < mChart.size; ++i) { + var f = child(i); + var s = f.text.trim(); + if (f.size != 0 && f.name !== "color") { + s = f.save(); + } + if (s === "") { + continue; + } + data[settingName(f)] = s; + } + updateChartVars(); + return data; + } + + function settingName(f) { + var n = f.name; + if (n.includes("_")) + return n.slice(0, n.indexOf("_")); + return n; + } + + function updateChartVars() { + expr = mBind.text; + exprWarn = mWarn.text; + type = mFilters.value; + scr = mFact2Save.text; + if (type === "kalman_smp") { + var v = value !== undefined ? value : 0; + mFilters.setKalmanSimpleState(v, 0.1); // set start state and covariance + } + changes = false; + setColor(); + } + + function updateTitle() { + if (newItem) + return; + if (mBind.text != "") + title = mBind.text; + pageFact.updateDescr(); + } + + function updateDescr() { + if (newItem) + return; + + // List non-zero values in descr + var descrList = []; + for (var i = 0; i < mChart.size; ++i) { + var f = child(i); + if (!f.name) + continue; + if (f.text === "") + continue; + if (f.name === "filt") { + var usedFilters = f.getUsedFilterNames(); + if (usedFilters.length > 0) { + descrList.push(f.name.toUpperCase() + ": " + f.getUsedFilterNames().join(", ")); + } + } else if (f.name === "color") { + descrList.push(f.name.toUpperCase() + ": " + f.text.toUpperCase()); + } else { + descrList.push(f.name.toUpperCase() + ": " + f.text); + } + } + if (descrList.length > 0) + descr = descrList.join(", "); + else + descr = ""; + } + + function setColor() { + var opt = mChart.opts; + opt.color = mColor.text ? mColor.text : "#ffffff"; // Black color for chart turn into white + mChart.opts = opt; + if (!newItem) + updateIconColor(opt.color) + mColor.changes = false; + pageFact.updateBtnValues(); + } + + function updateIconColor(iconColor) { + var opt = mChart.opts; + opt.iconColor = iconColor; + mChart.opts = opt; + } + + function updateValue() { + try { + var v = new Function('return ' + expr)(); + if (v === undefined) + throw new Error(qsTr("expression is undefined")); + // For first init + if (value === undefined) { + mFilters.setKalmanSimpleState(v, 0.1); + value = v; + return; + } + // Use filters + value = mFilters.useFilters(value, v) + } catch (e) { + chartWarning(e.message); + } + } + + function updateWarning () { + if (!exprWarn || String(exprWarn).trim() === "") { + warning = false; + return; + } + try { + warning = !!eval(exprWarn) + } catch (error) { + chartWarning(error.message) + warning = false; + } + } + + function saveValue2Fact() { + var fname = scr; + if (!fname || fname === "" || fname === undefined) + return; + if (!apx.fleet.current.mandala.fact(fname, true)) + return; + if (!fname.includes("sns.scr")) { + chartWarning(qsTr("Unacceptable variable name. Use 'sns.scr' vars for saving!")); + return; + } + apx.fleet.current.mandala.fact(fname, true).setRawValueLocal(value); + } + + function checkFact2SaveName(fname) { + if (fname.trim() === "" || fname.includes("sns.scr")) + return; + console.warn(qsTr("Chart") + " " + mBind.text + ": " + qsTr("Unacceptable variable name. Use 'sns.scr' vars for saving!")); + } + + function chartWarning(msg) { + if (warningMsg == msg && warnTimer.running) + return; + warningMsg = msg; + console.warn(qsTr("Chart") + " " + title + ": " + msg); + warnTimer.restart(); + } + + function hasScr(val) { + if (!val || val !== scr) + return; + chartWarning(val + " " + qsTr("variable already used")); + } + + Fact { + id: mFact + title: qsTr("Binding") + descr: qsTr("Fact value") + flags: Fact.Int + units: "mandala" + onTextChanged: if (value) mBind.setValue(text) + } + Fact { + id: mBind + name: "bind" + title: qsTr("Expression") + descr: "Math.atan(est.att.pitch/est.att.roll)" + flags: Fact.Text + onTextChanged: changes = true; + } + SignalsMenuColor { + id: mColor + name: "color" + title: qsTr("Color") + descr: qsTr("Chart color") + opts: ({ + "editor": Qt.resolvedUrl("SignalsColorEditor.qml") + }) + value: "#ffffff" + } + SignalsMenuFilters { + id: mFilters + name: "filt" + title: qsTr("Filters") + descr: qsTr("Filters settings") + onDescrChanged: changes = true + } + Fact { + id: mWarn + name: "warn" + title: qsTr("Warning") + descr: qsTr("Expression for warning") + "(value>1.5 || value <0)" + flags: Fact.Text + onTextChanged: changes = true; + } + Fact { + id: mFact2Save + name: "save" + title: qsTr("Save to") + descr: qsTr("Variable for saving chart value") + flags: Fact.Int + units: "mandala" + onTextChanged: { + checkFact2SaveName(text); + setFact.checkScrMatches(text); + changes = true; + } + } + + // Actions + Fact { + id: mAdd + flags: (Fact.Action | Fact.Apply) + title: qsTr("Add") + enabled: newItem && mBind && mBind.value + icon: "plus-circle" + onTriggered: { + mChart.menuBack(); + addTriggered(); + } + } + Fact { + flags: (Fact.Action | Fact.Apply) + title: qsTr("Save") + enabled: !newItem && !mAdd.enabled && changes + icon: "check-circle" + onTriggered: sgMenu.saveSettings() + } + Fact { + flags: (Fact.Action | Fact.Remove) + title: qsTr("Remove") + visible: !newItem + icon: "delete" + onTriggered: mChart.deleteFact(); + } +} diff --git a/src/Plugins/Tools/Signals/SignalsMenuColor.qml b/src/Plugins/Tools/Signals/SignalsMenuColor.qml new file mode 100644 index 000000000..ce02cb12d --- /dev/null +++ b/src/Plugins/Tools/Signals/SignalsMenuColor.qml @@ -0,0 +1,165 @@ +/* + * 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.Material + +import APX.Facts + +Fact { + id: mColor + + readonly property var colorAuto: qsTr("Auto") + property bool changes: false + + readonly property var colorBaseLabels: [ + qsTr("Red"), + qsTr("Pink"), + qsTr("Purple"), + qsTr("Deep Purple"), + qsTr("Indigo"), + qsTr("Blue"), + qsTr("Cyan"), + qsTr("Teal"), + qsTr("Green"), + qsTr("Yellow"), + qsTr("Orange"), + qsTr("Blue Grey") + ] + readonly property var colorBaseValues: [ + Material.Red, + Material.Pink, + Material.Purple, + Material.DeepPurple, + Material.Indigo, + Material.Blue, + Material.Cyan, + Material.Teal, + Material.Green, + Material.Yellow, + 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 + ] + + onValueChanged: updateChartColor() + onChangesChanged: { if (changes) mChart.changes = true;} + Component.onCompleted: { + if (!value || value === undefined) + value = colorAuto + if(!mChart.newItem && value === colorAuto) + setDefaultColor() + rebuildColorChoices() + } + + function clearColorChoices() + { + if (!mColor) + return + for (var i = mColor.size - 1; i >= 0; --i) { + var child = mColor.child(i) + if (child) { + child.deleteFact() + } + } + } + + function rebuildColorChoices() + { + clearColorChoices(); + createFact(mColor, "SignalsColorChooser.qml", { + "title": colorAuto, + "descr": qsTr("Use automatic series color"), + "colorValue": "", + "section": "", + "value": colorAuto + }) + for (var i = 0; i < colorShadeValues.length; ++i) { + var shadeLabel = colorShadeLabels[i] + for (var j = 0; j < colorBaseValues.length; ++j) { + var colorCode = Material.color(colorBaseValues[j], + colorShadeValues[i]).toString().toUpperCase() + createFact(mColor, "SignalsColorChooser.qml", { + "title": colorBaseLabels[j], + "descr": colorCode, + "colorValue": colorCode, + "section": shadeLabel, + "value": colorCode + }) + } + } + } + + 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; + } + } + + function setDefaultColor() + { + var colorsCount = colorBaseValues.length + if (colorsCount <= 0) { + value = "#FFFFFF"; + return; + } + var index = 0; + if (mCharts.size !== 0) { + if(mChart.num === 0) { + var f = mChart.parentFact + index = !(f && f.title === "Charts") ? mCharts.size % colorsCount : 0 + } else { + index = mChart.num % colorsCount + } + } + value = Material.color(colorBaseValues[index], colorShadeValues[0]).toString().toUpperCase(); + } + + function updateChartColor() { + if(!mChart.newItem) + mChart.updateIconColor(value) + updateDescr(); + } + + function updateDescr() { + var descrText = ""; + if(!value || value === undefined) + descrText = colorAuto + else + descrText = value + descr = qsTr("Color").toUpperCase() + ": " + descrText.toString().toUpperCase(); + changes = true; + } +} diff --git a/src/Plugins/Tools/Signals/SignalsMenuFilters.qml b/src/Plugins/Tools/Signals/SignalsMenuFilters.qml new file mode 100644 index 000000000..f1ad371e6 --- /dev/null +++ b/src/Plugins/Tools/Signals/SignalsMenuFilters.qml @@ -0,0 +1,176 @@ +/* + * 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: fMenu + + flags: (Fact.Group | Fact.FlatModel) + + property bool changes: false + property var data: ({}) + + Component.onCompleted: load(value) + onChangesChanged: { if (changes) mChart.changes = true;} + + function load(value) { + if(!value) + return; + var filters = value.filters + if (filters === undefined || filters.length === 0) + return; + fSet.deleteChildren(); + for (var i in filters) { + createFilter(filters[i]); + } + pageFact.updateBtnValues(); + changes = false; + } + + function save() { + var tmpFilters = []; + for (var i = 0; i < fSet.size; ++i) { + var mfilter = fSet.child(i).save(); + tmpFilters.push(mfilter); + } + var fmenu = {}; + fmenu.filters = tmpFilters; + changes = false; + return fmenu; + } + + function settingName(f) { + var n = f.name; + if (n.includes("_")) + return n.slice(0, n.indexOf("_")); + return n; + } + + // Filters creation + function createFilter(filterData) { + var type = filterData.type; + switch (type) { + case "running_avg": + createRunningAvg(filterData); + break; + case "kalman_smp": + createKalmanSimple(filterData) + break; + default: + console.warn(qsTr("Wrong filter type. Filter creation failed")) + } + } + + function createRunningAvg(filterData) { + var c = createFact(fSet, "SignalsFilterRunningAvg.qml", { + "data": filterData + }); + } + + function createKalmanSimple(filterData) { + var c = createFact(fSet, "SignalsFilterKalmanSimple.qml", { + "data": filterData + }); + } + + function createFact(parent, url, opts) { + var component = Qt.createComponent(url); + if (component.status === Component.Ready) { + var c = component.createObject(parent, opts); + c.parentFact = parent; + c.changed.connect(setChanges) + return c; + } + } + + function setChanges(changesValue) { + if(changesValue) + fMenu.changes = changesValue; + } + + function updateDescr() { + var descrList = []; + var filtersList = getUsedFilterNames(); + if (filtersList.length > 0) { + var filtersOn = qsTr("On").toUpperCase() + ": " + filtersList.join(", "); + descrList.push(filtersOn); + } + descr = descrList.join(", "); + changes = true; + } + + function getUsedFilterNames() { + var filtersList = []; + for(var i = 0; i < fSet.size; ++i) { + var f = fSet.child(i); + if(!f || !f.value) + continue; + var shortName = f.filterType.replace(/(?:_| |\b)(\w)/g, (match, c1) => c1.toUpperCase()); + filtersList.push(shortName); + } + return filtersList; + } + + // Using filters + function useFilters(value, v) { + for(var i = 0; i < fSet.size; ++i) { + if(!fSet.child(i).value) + continue; + v = fSet.child(i).processValue(value, v) + } + return v; + } + + function setKalmanSimpleState(st, cv) { + for(var i = 0; i < fSet.size; ++i) + if(fSet.child(i).filterType === "kalman_smp") + fSet.child(i).setKalmanState(st, cv); + } + + SignalsFilterChooser { + title: qsTr("Add new filter") + descr: qsTr("Selecting and adding filters") + icon: "plus-circle" + } + + Fact { + id: fSet + title: qsTr("Filters") + flags: (Fact.Group | Fact.Section | Fact.DragChildren) + onItemMoved: updateDescr(); + onSizeChanged: { + fMenu.value = size > 0 ? size : "" + updateDescr(); + } + } + + // Actions + Fact { + flags: (Fact.Action | Fact.Apply) + title: qsTr("Save") + enabled: !mChart.newItem && changes + icon: "check-circle" + onTriggered: sgMenu.saveSettings() + } +} diff --git a/src/Plugins/Tools/Signals/SignalsMenuPage.qml b/src/Plugins/Tools/Signals/SignalsMenuPage.qml new file mode 100644 index 000000000..aa76434cc --- /dev/null +++ b/src/Plugins/Tools/Signals/SignalsMenuPage.qml @@ -0,0 +1,286 @@ +/* + * 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: pageFact + + flags: (Fact.Group | Fact.FlatModel) + + property var newItem: false + property bool changes: false + property bool warnings: false + property string pageToolTip: "" + property alias speed: mSpeed.value + property alias pinned: mPinned.value + property var charts: [] //from config + property var values: [] + property var data: ({}) + + signal addTriggered + + onPinnedChanged: updatePinnedModel() + Component.onDestruction: removed() // pinned menu closes when the plugin is closed + Component.onCompleted: { + load(data); + updateTitle(); + updateDescr(); + mPageName.textChanged.connect(updateTitle); + mPinned.valueChanged.connect(updateDescr); + mSpeed.valueChanged.connect(updateDescr); + mCharts.sizeChanged.connect(updateDescr); + } + onActiveChanged: { + if (active) + setFact.checkedPage = num; + } + + function addNewChart() { + mMenuChart.trigger(); + } + + function updateBtnValues() { + values = []; + for (var i = 0; i < mCharts.size; ++i) + values.push(mCharts.child(i)); + updateToolTip(); + } + + function updateChartsValues() { + for (var i = 0; i < mCharts.size; ++i) { + mCharts.child(i).updateValue(); + mCharts.child(i).updateWarning(); + } + updatePageWarning(); + } + + function updatePageWarning() { + for (var i = 0; i < mCharts.size; ++i) { + if (mCharts.child(i).warning) { + warnings = true; + return; + } + } + warnings = false; + } + + function save() { + changes = false; + var tmpCharts = []; + for (var i = 0; i < mCharts.size; ++i) { + var mchart = mCharts.child(i).save(); + if (!mchart.bind) + continue; + tmpCharts.push(mchart); + } + var page = {}; + page.title = mPageName.value; + page.pinned = mPinned.value; + page.speed = mSpeed.value; + page.charts = tmpCharts; + updateBtnValues(); + return page; + } + + function load(page) { + mPageName.value = page.title; + mSpeed.value = page.speed ? page.speed : 1; + mPinned.value = page.pinned; + charts = page.charts; + updatePageItems(); + changes = false; + } + + function updatePageItems() { + mCharts.deleteChildren(); + for (var i in charts) { + createChart(charts[i]); + } + updateBtnValues(); + } + + function createChart(mchart) { + if (!mchart.bind) + return; + if (mchart.bind === "") + return; + var c = createFact(mCharts, "SignalsMenuChart.qml", { + "data": mchart + }); + changes = true; + } + + 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; + } + } + + // Check for binding script variable matches + function checkScrs(val) { + for (var i = 0; i < mCharts.size; ++i) + mCharts.child(i).hasScr(val) + } + + function updateTitle() { + if (newItem) + return; + var text = mPageName.text.trim(); + title = text != "" ? text : qsTr("P") + (Math.max(pageFact.num, 0) + 1); + setFact.updateDescr(); + } + + function updateDescr() { + var descrList = []; + if(mPinned.value) + descrList.push(qsTr("Pinned").toUpperCase()) + var speedDescr = qsTr("Speed").toUpperCase() + ": " + mSpeed.value; + descrList.push(speedDescr); + var descrCharts = []; + for (var i = 0; i < mCharts.size; ++i) { + var f = mCharts.child(i); + if (!f) + continue; + var text = f.title ? f.title.trim() : ""; + descrCharts.push(text); + } + if (descrCharts.length > 0) { + var chartsDescr = descrCharts.join(", "); + chartsDescr = qsTr("Charts").toUpperCase() + ": " + chartsDescr; + descrList.push(chartsDescr); + } + descr = descrList.length > 0 ? descrList.join(", ") : ""; + } + + function updateToolTip() { + var s = []; + s.push("" + title + ""); + for (var i = 0; i < mCharts.size; ++i) { + var fact = mCharts.child(i); + var color = fact.color ? fact.color : (fact.opts ? fact.opts.color : undefined); + var label = fact.title ? fact.title : (fact.descr ? fact.descr : fact.bind); + if (color) + s.push("\u25A0 " + label); + else + s.push(label); + } + pageToolTip = s.join("
"); + } + + function updatePinnedModel() { + if(!setFact.active) + return; + if(pinned) + pinnedModel.addPage(pageFact); + else + pinnedModel.removePage(num); + } + + Fact { + id: mPageName + title: qsTr("Page name") + descr: qsTr("Charts page name") + flags: Fact.Text + icon: "rename-box" + onTextChanged: changes = true; + } + Fact { + id: mPinned + title: qsTr("Pinned") + descr: qsTr("Pin this page to the signals layout") + flags: Fact.Bool + icon: "pin" + onValueChanged: changes = true; + } + Fact { + id: mSpeed + title: qsTr("Speed") + descr: qsTr("Charts speed") + flags: Fact.Float + icon: "speedometer" + value: 1.0 + precision: 1 + min: 0.2 + max: 4 + onValueChanged: changes = true; + } + SignalsMenuChart { + id: mMenuChart + title: qsTr("Add new chart") + descr: qsTr("Creating and setting a new chart") + icon: "plus-circle" + visible: !pageFact.newItem + newItem: true + onAddTriggered: createChart(save()) + } + Fact { + id: mCharts + title: qsTr("Charts") + flags: (Fact.Group | Fact.Section | Fact.DragChildren) + onSizeChanged: { + if (pageFact.active) + mainChartArea.allowReset(); + if(pageFact.pinned) + sgControl.allowResetChart(pageFact.num) + changes = true; + } + } + + // Actions + Fact { + id: pageAdd + flags: (Fact.Action | Fact.Apply) + title: qsTr("Add") + enabled: newItem + icon: "plus-circle" + onTriggered: { + pageFact.menuBack(); + addTriggered(); + } + } + Fact { + flags: (Fact.Action | Fact.Apply) + title: qsTr("Save") + enabled: !newItem && changes + icon: "check-circle" + onTriggered: sgMenu.saveSettings() + } + Fact { + flags: (Fact.Action | Fact.Remove) + title: qsTr("Remove") + visible: !newItem + icon: "delete" + onTriggered: { + if(pageFact.active) + setFact.setChecked(0) + if(setFact.active) + buttonsModel.removeButton(pageFact.num) + pageFact.pinned = false; + pageFact.deleteFact(); + } + } +} diff --git a/src/Plugins/Tools/Signals/SignalsMenuSet.qml b/src/Plugins/Tools/Signals/SignalsMenuSet.qml new file mode 100644 index 000000000..1b935b72d --- /dev/null +++ b/src/Plugins/Tools/Signals/SignalsMenuSet.qml @@ -0,0 +1,206 @@ +/* + * 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: setFact + + flags: (Fact.Group | Fact.FlatModel) + title: "Set" + + property bool changes: false + property var pages: [] //from config + property var checkedPage: 0 + property var data: ({}) + + readonly property var pagesLimit: 10 + + signal selected(var num) + + Component.onCompleted: load(data) + Component.onDestruction: removed() // pinned menu closes when the plugin is closed + onCheckedPageChanged: if(active) sgMenu.saveSettings() + + function save() { + changes = false; + var tmpPages = []; + for (var i = 0; i < mPages.size; ++i) { + var mpage = mPages.child(i).save(); + tmpPages.push(mpage); + } + var set = {}; + set.title = mSetName.value; + set.checked = checkedPage; + set.pages = tmpPages; + return set; + } + + function load(set) { + mSetName.value = set.title; + checkedPage = set.checked; + pages = set.pages; + updateSetItems(); + setChecked(checkedPage); + changes = false; + } + + function updateSetItems() { + mPages.deleteChildren(); + for (var i in pages) { + createPage(pages[i]); + } + } + + function setChecked(num) { + for (var i = 0; i < mPages.size; ++i) { + mPages.child(i).active = i == num; + } + } + + function createPage(mpage) { + var text = mpage.title.trim(); + if (text == "") + mpage.title = "P" + (mPages.size + 1); + if (mpage.speed <= 0) + mpage.speed = 1; + var c = createFact(mPages, "SignalsMenuPage.qml", { + "data": mpage + }); + } + + 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; + } + } + + function updateDescr() { + var descrList = []; + for (var i = 0; i < mPages.size; ++i) { + var f = mPages.child(i); + if (!f) + continue; + var text = f.title ? f.title.trim() : ""; + descrList.push(text); + } + descr = descrList.length > 0 ? descrList.join(", ") : ""; + changes = true; + } + + function getPinned() { + var pinnedPages = []; + for (var i = 0; i < mPages.size; ++i) { + var f = mPages.child(i); + if (!f) + continue; + if (!f.pinned) + continue; + pinnedPages.push(f); + } + return pinnedPages.length > 0 ? pinnedPages : []; + } + + function getPages() { + var pages = []; + for (var i = 0; i < mPages.size; ++i) { + var f = mPages.child(i); + if (!f) + continue; + pages.push(f); + } + return pages.length > 0 ? pages : []; + } + + // Check for binding script variable matches + function checkScrMatches(val) { + for (var i = 0; i < mPages.size; ++i) + mPages.child(i).checkScrs(val) + } + + Fact { + id: mSetName + title: qsTr("Set name") + descr: qsTr("Saved chart configuration name") + flags: Fact.Text + icon: "rename-box" + onValueChanged: { + setFact.title = value; + changes = true; + } + } + SignalsMenuPage { + id: mMenuPage + title: qsTr("Add new page") + descr: qsTr("Creating and add new page") + icon: "plus-circle" + newItem: true + onAddTriggered: { + if (mPages.size >= pagesLimit) { + console.warn(qsTr("Maximum page limit reached")) + return; + } + createPage(save()); + if(mPages.size === 1) setChecked(0); + } + } + Fact { + id: mPages + title: qsTr("Pages") + flags: (Fact.Group | Fact.Section | Fact.DragChildren) + onSizeChanged: updateDescr() + onItemInserted: (fact)=>{if(setFact.active) buttonsModel.addButton(fact)} + } + + // Actions + Fact { + flags: (Fact.Action | Fact.Apply) + title: qsTr("Save") + visible: changes + icon: "check-circle" + onTriggered: sgMenu.saveSettings() + } + Fact { + flags: (Fact.Action | Fact.Apply) + title: qsTr("Select") + visible: !setFact.active + icon: "text-box-check" + onTriggered: { + setFact.selected(setFact.num); + setFact.menuBack(); + } + } + Fact { + flags: (Fact.Action | Fact.Remove) + title: qsTr("Remove") + icon: "delete" + onTriggered: { + var isActive = setFact.active; + setFact.deleteFact(); + if (isActive) setFact.selected(0); + } + } +} diff --git a/src/Plugins/Tools/Signals/SignalsPinnedModel.qml b/src/Plugins/Tools/Signals/SignalsPinnedModel.qml new file mode 100644 index 000000000..eb13fa049 --- /dev/null +++ b/src/Plugins/Tools/Signals/SignalsPinnedModel.qml @@ -0,0 +1,63 @@ +/* + * 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 QtQml.Models +import QtQuick.Layouts + +import Apx.Common + +ObjectModel { + id: pinnedModel + + function addPage(fact) { + if(!fact) + return; + var component = Qt.createComponent("SignalsChartItem.qml"); + if (component.status === Component.Ready) { + var c=component.createObject(layout, {"Layout.fillWidth": true, + "Layout.preferredHeight": Qt.binding(function(){return 110 * ui.scale}), + "Layout.minimumHeight": 20, + "clip": true}); + c.allowReset(); + c.ciPageFact = fact; + pinnedModel.append(c); + } + } + + function removePage(num) { + for(var i = 0; i < pinnedModel.count; ++i) { + if(pinnedModel.get(i).num !== num) + continue; + pinnedModel.remove(i) + } + } + + function updateModel(pageList) { + pinnedModel.clear(); + for(var i = 0; i < pageList.length; ++i) { + var f = pageList[i] + if(!f) + continue; + pinnedModel.addPage(f) + } + } +} 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)f_application, "plugins", tr("Plugins"), - tr("Application PligIns"), + tr("Application Plugins"), Group); App::jsync(f_settings); } diff --git a/src/lib/ApxData/Mandala/MandalaFact.h b/src/lib/ApxData/Mandala/MandalaFact.h index 19ef962b4..d5b093fa7 100644 --- a/src/lib/ApxData/Mandala/MandalaFact.h +++ b/src/lib/ApxData/Mandala/MandalaFact.h @@ -53,11 +53,11 @@ class MandalaFact : public Fact Q_INVOKABLE mandala::uid_t offset() const; + Q_INVOKABLE bool setRawValueLocal(QVariant v); + // units conversions void setValueFromStream(const QVariant &v); - bool setRawValueLocal(QVariant v); - void increment_rx_cnt(); auto rx_cnt() const { return _rx_cnt; } auto everReceived() const { return _everReceived; } diff --git a/src/main/qml/Apx/Common/FactButton/FactButton.qml b/src/main/qml/Apx/Common/FactButton/FactButton.qml index 3414318d4..22c7eab16 100644 --- a/src/main/qml/Apx/Common/FactButton/FactButton.qml +++ b/src/main/qml/Apx/Common/FactButton/FactButton.qml @@ -104,7 +104,7 @@ ActionButton { property bool showNext: expandable property bool showDescr: descr - + iconColor: (fact && fact.opts.iconColor)?fact.opts.iconColor:Material.iconColor toolTipItem.visible: pressed toolTipItem.delay: Qt.styleHints.mousePressAndHoldInterval