diff options
64 files changed, 2966 insertions, 1512 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index ab1af0b46..8091cf863 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,9 @@ +- mobile: add GF fields to adjust Buhlmann algorithm parameters for calculated ceiling - undo: save to git after editing weights [#3159] - undo: reset dive-mode on undo of set-point addition - desktop: complete rewrite of the statistics code, significantly expanding capabilities - desktop: add preferences option to disable default cylinder types +- mobile: redesigned dive edit experience - mobile: fix broken 'use current location' in dive edit - mobile: add ability to show fundamentally the same statistics as on the desktop - mobile: add settings for DC and calculated ceilings and show calculated ceilings diff --git a/CMakeLists.txt b/CMakeLists.txt index 49a9ef092..031f6b819 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -301,7 +301,7 @@ endif() #set up the subsurface_link_libraries variable set(SUBSURFACE_LINK_LIBRARIES ${SUBSURFACE_LINK_LIBRARIES} ${LIBDIVECOMPUTER_LIBRARIES} ${LIBGIT2_LIBRARIES} ${LIBUSB_LIBRARIES} ${LIBMTP_LIBRARIES}) if (NOT SUBSURFACE_TARGET_EXECUTABLE MATCHES "DownloaderExecutable") - qt5_add_resources(SUBSURFACE_RESOURCES subsurface.qrc map-widget/qml/map-widget.qrc desktop-widgets/qml/statsview2.qrc) + qt5_add_resources(SUBSURFACE_RESOURCES subsurface.qrc stats/statsicons.qrc map-widget/qml/map-widget.qrc desktop-widgets/qml/statsview2.qrc) endif() # hack to build successfully on LGTM diff --git a/Subsurface-mobile.pro b/Subsurface-mobile.pro index 19d69c2f2..8e35c835d 100644 --- a/Subsurface-mobile.pro +++ b/Subsurface-mobile.pro @@ -131,10 +131,14 @@ SOURCES += subsurface-mobile-main.cpp \ stats/statsview.cpp \ stats/barseries.cpp \ stats/boxseries.cpp \ + stats/chartitem.cpp \ stats/chartlistmodel.cpp \ + stats/histogrammarker.cpp \ stats/informationbox.cpp \ stats/legend.cpp \ stats/pieseries.cpp \ + stats/quartilemarker.cpp \ + stats/regressionitem.cpp \ stats/scatterseries.cpp \ stats/statsaxis.cpp \ stats/statscolors.cpp \ @@ -279,10 +283,14 @@ HEADERS += \ backend-shared/roundrectitem.h \ stats/barseries.h \ stats/boxseries.h \ + stats/chartitem.h \ stats/chartlistmodel.h \ + stats/histogrammarker.h \ stats/informationbox.h \ stats/legend.h \ stats/pieseries.h \ + stats/quartilemarker.h \ + stats/regressionitem.h \ stats/scatterseries.h \ stats/statsaxis.h \ stats/statscolors.h \ @@ -334,7 +342,8 @@ HEADERS += \ RESOURCES += mobile-widgets/qml/mobile-resources.qrc \ mobile-widgets/3rdparty/icons.qrc \ - map-widget/qml/map-widget.qrc + map-widget/qml/map-widget.qrc \ + stats/statsicons.qrc android { SOURCES += core/android.cpp \ diff --git a/core/metrics.cpp b/core/metrics.cpp index 23bf14253..9351bcb0d 100644 --- a/core/metrics.cpp +++ b/core/metrics.cpp @@ -24,7 +24,6 @@ IconMetrics::IconMetrics() : QFont defaultModelFont() { QFont font; -// font.setPointSizeF(font.pointSizeF() * 0.8); return font; } diff --git a/core/qt-gui.h b/core/qt-gui.h index 9bfe0e001..8915b7ccd 100644 --- a/core/qt-gui.h +++ b/core/qt-gui.h @@ -5,12 +5,14 @@ void init_qt_late(); void init_ui(); -void run_ui(); void exit_ui(); void set_non_bt_addresses(); #if defined(SUBSURFACE_MOBILE) #include <QQuickWindow> +void run_mobile_ui(double initial_font_size); +#else +void run_ui(); #endif #endif // QT_GUI_H diff --git a/core/settings/qPrefTechnicalDetails.cpp b/core/settings/qPrefTechnicalDetails.cpp index 2db4898ae..3c616170d 100644 --- a/core/settings/qPrefTechnicalDetails.cpp +++ b/core/settings/qPrefTechnicalDetails.cpp @@ -62,6 +62,7 @@ void qPrefTechnicalDetails::set_gfhigh(int value) if (value != prefs.gfhigh) { prefs.gfhigh = value; disk_gfhigh(true); + set_gf(-1, prefs.gfhigh); emit instance()->gfhighChanged(value); } } @@ -82,6 +83,7 @@ void qPrefTechnicalDetails::set_gflow(int value) if (value != prefs.gflow) { prefs.gflow = value; disk_gflow(true); + set_gf(prefs.gflow, -1); emit instance()->gflowChanged(value); } } diff --git a/desktop-widgets/preferences/preferences_graph.cpp b/desktop-widgets/preferences/preferences_graph.cpp index b7fd5b4b9..687a8d84e 100644 --- a/desktop-widgets/preferences/preferences_graph.cpp +++ b/desktop-widgets/preferences/preferences_graph.cpp @@ -68,7 +68,6 @@ void PreferencesGraph::syncSettings() prefs.planner_deco_mode = ui->buehlmann->isChecked() ? BUEHLMANN : VPMB; qPrefTechnicalDetails::set_gflow(ui->gflow->value()); qPrefTechnicalDetails::set_gfhigh(ui->gfhigh->value()); - set_gf(ui->gflow->value(), ui->gfhigh->value()); qPrefTechnicalDetails::set_vpmb_conservatism(ui->vpmb_conservatism->value()); set_vpmb_conservatism(ui->vpmb_conservatism->value()); qPrefTechnicalDetails::set_show_ccr_setpoint(ui->show_ccr_setpoint->isChecked()); diff --git a/desktop-widgets/statswidget.cpp b/desktop-widgets/statswidget.cpp index e0090395f..33174778d 100644 --- a/desktop-widgets/statswidget.cpp +++ b/desktop-widgets/statswidget.cpp @@ -185,7 +185,9 @@ void StatsWidget::var2OperationChanged(int idx) void StatsWidget::featureChanged(int idx, bool status) { state.featureChanged(idx, status); - updateUi(); + // No need for a full chart replot - just show/hide the features + if (view) + view->updateFeatures(state); } void StatsWidget::showEvent(QShowEvent *e) diff --git a/desktop-widgets/statswidget.h b/desktop-widgets/statswidget.h index b85d89730..51b94ac87 100644 --- a/desktop-widgets/statswidget.h +++ b/desktop-widgets/statswidget.h @@ -33,7 +33,6 @@ private: std::vector<std::unique_ptr<QCheckBox>> features; ChartListModel charts; - //QStringListModel charts; void showEvent(QShowEvent *) override; }; diff --git a/mobile-widgets/3rdparty/0005-breadcrumbs-get-better-font-size.patch b/mobile-widgets/3rdparty/0005-breadcrumbs-get-better-font-size.patch index eceb3a4fd..b83326d48 100644 --- a/mobile-widgets/3rdparty/0005-breadcrumbs-get-better-font-size.patch +++ b/mobile-widgets/3rdparty/0005-breadcrumbs-get-better-font-size.patch @@ -5,26 +5,9 @@ Subject: [PATCH 05/11] breadcrumbs: get better font size Signed-off-by: Dirk Hohndel <dirk@hohndel.org> --- - src/controls/Units.qml | 5 +++++ src/controls/private/globaltoolbar/BreadcrumbControl.qml | 1 + - 2 files changed, 6 insertions(+) + 1 file changed, 1 insertion(+) -diff --git a/src/controls/Units.qml b/src/controls/Units.qml -index 615228a2..f957046f 100644 ---- a/src/controls/Units.qml -+++ b/src/controls/Units.qml -@@ -105,6 +105,11 @@ QtObject { - */ - readonly property int wheelScrollLines: 3 - -+ /** -+ * Use this to hardcode the font size of the global toolbar that Kirigami gets wrong -+ */ -+ property double defaultFontSize: fontMetrics.font.pixelSize -+ - /** - * metrics used by the default font - */ diff --git a/src/controls/private/globaltoolbar/BreadcrumbControl.qml b/src/controls/private/globaltoolbar/BreadcrumbControl.qml index ad80d222..c45db280 100644 --- a/src/controls/private/globaltoolbar/BreadcrumbControl.qml @@ -33,7 +16,7 @@ index ad80d222..c45db280 100644 } Kirigami.Heading { Layout.leftMargin: Kirigami.Units.largeSpacing -+ font.pixelSize: Math.max(1, Kirigami.Units.defaultFontSize) ++ font: Kirigami.Theme.defaultFont color: Kirigami.Theme.textColor verticalAlignment: Text.AlignVCenter wrapMode: Text.NoWrap diff --git a/mobile-widgets/3rdparty/0017-make-space-for-button-in-passive-notification.patch b/mobile-widgets/3rdparty/0017-make-space-for-button-in-passive-notification.patch new file mode 100644 index 000000000..c1f704afc --- /dev/null +++ b/mobile-widgets/3rdparty/0017-make-space-for-button-in-passive-notification.patch @@ -0,0 +1,28 @@ +From a0532ed879b5068ce64a9d5e5464d36507a9a92a Mon Sep 17 00:00:00 2001 +From: Dirk Hohndel <dirk@hohndel.org> +Date: Sun, 17 Jan 2021 17:31:22 -0800 +Subject: [PATCH 17/17] make space for button in passive notification + +Not sure how this is supposed to work otherwise. + +Signed-off-by: Dirk Hohndel <dirk@hohndel.org> +--- + src/controls/templates/private/PassiveNotification.qml | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/src/controls/templates/private/PassiveNotification.qml b/src/controls/templates/private/PassiveNotification.qml +index ceb57aca..aaa438ae 100644 +--- a/src/controls/templates/private/PassiveNotification.qml ++++ b/src/controls/templates/private/PassiveNotification.qml +@@ -172,7 +172,7 @@ Controls.Popup { + Controls.Label { + id: label + color: subsurfaceTheme.primaryTextColor +- Layout.maximumWidth: Math.min(root.parent.width - Kirigami.Units.largeSpacing * 4, implicitWidth) ++ Layout.maximumWidth: Math.min(root.parent.width - actionButton.implicitWidth - Kirigami.Units.largeSpacing * 4, implicitWidth) + elide: Text.ElideRight + wrapMode: Text.WordWrap + maximumLineCount: 4 +-- +2.29.2 + diff --git a/mobile-widgets/qml/DiveDetails.qml b/mobile-widgets/qml/DiveDetails.qml index e505d7bc1..1215e86ad 100644 --- a/mobile-widgets/qml/DiveDetails.qml +++ b/mobile-widgets/qml/DiveDetails.qml @@ -115,7 +115,7 @@ Kirigami.Page { enabled: manager.redoText !== "" onTriggered: manager.redo() } - property variant contextactions: [ removeDiveFromTripAction, addDiveToTripAboveAction, addDiveToTripBelowAction, undoAction, redoAction ] + property variant contextactions: [ removeDiveFromTripAction, addDiveToTripAboveAction, addDiveToTripBelowAction, deleteAction, undoAction, redoAction ] states: [ State { @@ -123,7 +123,7 @@ Kirigami.Page { PropertyChanges { target: diveDetailsPage; actions { - right: deleteAction + right: null left: currentItem ? (currentItem.modelData && currentItem.modelData.gps !== "" ? mapAction : null) : null } contextualActions: contextactions diff --git a/mobile-widgets/qml/DiveDetailsEdit.qml b/mobile-widgets/qml/DiveDetailsEdit.qml index 703d573b9..6f5690bae 100644 --- a/mobile-widgets/qml/DiveDetailsEdit.qml +++ b/mobile-widgets/qml/DiveDetailsEdit.qml @@ -84,31 +84,31 @@ Item { var state = diveDetailsPage.state diveDetailsPage.state = "view" // run the transition // join cylinder info from separate string into a list. - if (usedCyl[0] != null) { + if (usedCyl[0] !== undefined) { usedCyl[0] = cylinderBox0.currentText usedGas[0] = txtGasMix0.text startpressure[0] = txtStartPressure0.text endpressure[0] = txtEndPressure0.text } - if (usedCyl[1] != null) { + if (usedCyl[1] !== undefined) { usedCyl[1] = cylinderBox1.currentText usedGas[1] = txtGasMix1.text startpressure[1] = txtStartPressure1.text endpressure[1] = txtEndPressure1.text } - if (usedCyl[2] != null) { + if (usedCyl[2] !== undefined) { usedCyl[2] = cylinderBox2.currentText usedGas[2] = txtGasMix2.text startpressure[2] = txtStartPressure2.text endpressure[2] = txtEndPressure2.text } - if (usedCyl[3] != null) { + if (usedCyl[3] !== undefined) { usedCyl[3] = cylinderBox3.currentText usedGas[3] = txtGasMix3.text startpressure[3] = txtStartPressure3.text endpressure[3] = txtEndPressure3.text } - if (usedCyl[4] != null) { + if (usedCyl[4] !== undefined) { usedCyl[4] = cylinderBox4.currentText usedGas[4] = txtGasMix4.text startpressure[4] = txtStartPressure4.text @@ -128,78 +128,160 @@ Item { clearDetailsEdit() } - height: editArea.height + height: editArea.height + Kirigami.Units.gridUnit * 3 width: diveDetailsPage.width - diveDetailsPage.leftPadding - diveDetailsPage.rightPadding - Kirigami.Units.smallSpacing * 2 - ColumnLayout { - id: editArea - spacing: Kirigami.Units.smallSpacing - width: parent.width - - GridLayout { - id: editorDetails + Item { + // there is a maximum width above which this becomes less pleasant to use. 42 gridUnits + // allows for two of the large drop downs or four of the text fields or all of a cylinder + // to be in one row. More just doesn't look good + width: Math.min(parent.width - Kirigami.Units.smallSpacing, Kirigami.Units.gridUnit * 42) + // weird way to create a little space from the left edge - but I can't do a margin here + x: Kirigami.Units.smallSpacing + Flow { + id: editArea + // with larger fonts we need more space, or things look too crowded + spacing: subsurfaceTheme.currentScale > 1.0 ? 1.5 * Kirigami.Units.largeSpacing : Kirigami.Units.largeSpacing width: parent.width - columns: 2 - TemplateLabelSmall { - Layout.alignment: Qt.AlignRight - text: qsTr("Dive number:") - } - SsrfTextField { - id: txtNumber; - Layout.fillWidth: true - flickable: detailsEditFlickable - } - - TemplateLabelSmall { - Layout.alignment: Qt.AlignRight - text: qsTr("Date:") + flow: GridLayout.LeftToRight + RowLayout { + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 4 + horizontalAlignment: Text.AlignRight + text: qsTr("Date/Time:") + } + SsrfTextField { + id: txtDate; + Layout.preferredWidth: Kirigami.Units.gridUnit * 10 + flickable: detailsEditFlickable + } } - SsrfTextField { - id: txtDate; - Layout.fillWidth: true - flickable: detailsEditFlickable + RowLayout { + TemplateLabelSmall { + horizontalAlignment: Text.AlignRight + Layout.preferredWidth: Kirigami.Units.gridUnit * 4 + text: qsTr("Dive number:") + } + SsrfTextField { + id: txtNumber; + Layout.preferredWidth: Kirigami.Units.gridUnit * 3 + flickable: detailsEditFlickable + } + Item { + // if date and dive number are on the same line, don't have the Depth behind them + // to ensure that we add an element that fills enough of the line that the flow + // will not pull the next element up + visible: editArea.width > Kirigami.Units.gridUnit * 27 + Layout.preferredWidth: editArea.width - Kirigami.Units.gridUnit * 26 + } } - TemplateLabelSmall { - Layout.alignment: Qt.AlignRight - text: qsTr("Location:") + RowLayout { + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 4 + horizontalAlignment: Text.AlignRight + text: qsTr("Depth:") + } + SsrfTextField { + Layout.preferredWidth: Kirigami.Units.gridUnit * 3 + id: txtDepth + validator: RegExpValidator { regExp: /[^-]*/ } + flickable: detailsEditFlickable + } } - TemplateEditComboBox { - id: locationBox - flickable: detailsEditFlickable - model: diveDetailsListView.currentItem && diveDetailsListView.currentItem.modelData !== null ? - manager.locationList : null - onAccepted: { - focus = false - gpsText = manager.getGpsFromSiteName(editText) + RowLayout { + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 4 + horizontalAlignment: Text.AlignRight + text: qsTr("Duration:") } - onActivated: { - focus = false - gpsText = manager.getGpsFromSiteName(editText) + SsrfTextField { + Layout.preferredWidth: Kirigami.Units.gridUnit * 3 + id: txtDuration + validator: RegExpValidator { regExp: /[^-]*/ } + flickable: detailsEditFlickable } + } + RowLayout { + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 4 + horizontalAlignment: Text.AlignRight + text: qsTr("Air Temp:") + } + SsrfTextField { + id: txtAirTemp + Layout.preferredWidth: Kirigami.Units.gridUnit * 3 + flickable: detailsEditFlickable + } - TemplateLabelSmall { - Layout.alignment: Qt.AlignRight - text: qsTr("Coordinates:") } - SsrfTextField { - id: txtGps - Layout.fillWidth: true - flickable: detailsEditFlickable + RowLayout { + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 4 + horizontalAlignment: Text.AlignRight + text: qsTr("Water Temp:") + } + SsrfTextField { + Layout.preferredWidth: Kirigami.Units.gridUnit * 3 + id: txtWaterTemp + flickable: detailsEditFlickable + } } - - TemplateLabelSmall { - Layout.alignment: Qt.AlignRight - text: qsTr("Use current\nGPS location:") - visible: manager.locationServiceAvailable + RowLayout { + width: Kirigami.Units.gridUnit * 20 + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 4 + horizontalAlignment: Text.AlignRight + text: qsTr("Location:") + } + TemplateEditComboBox { + // this one needs more space + id: locationBox + flickable: detailsEditFlickable + model: diveDetailsListView.currentItem && diveDetailsListView.currentItem.modelData !== null ? + manager.locationList : null + onAccepted: { + focus = false + gpsText = manager.getGpsFromSiteName(editText) + } + onActivated: { + focus = false + gpsText = manager.getGpsFromSiteName(editText) + } + } } - TemplateCheckBox { - id: checkboxGPS - visible: manager.locationServiceAvailable - onCheckedChanged: { - if (checked) - gpsText = manager.getCurrentPosition() + RowLayout { + spacing: Kirigami.Units.smallSpacing + Layout.preferredWidth: Kirigami.Units.gridUnit * 20 + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 4 + horizontalAlignment: Text.AlignRight + text: qsTr("Coordinates:") + } + SsrfTextField { + Layout.preferredWidth: Kirigami.Units.gridUnit * 16 + id: txtGps + flickable: detailsEditFlickable } } + RowLayout { + width: manager.locationServiceAvailable ? Kirigami.Units.gridUnit * 12 : 0 + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 6 + horizontalAlignment: Text.AlignRight + text: qsTr("Use current\nGPS location:") + visible: manager.locationServiceAvailable + } + TemplateCheckBox { + Layout.preferredWidth: Kirigami.Units.gridUnit * 6 + id: checkboxGPS + visible: manager.locationServiceAvailable + onCheckedChanged: { + if (checked) + gpsText = manager.getCurrentPosition() + } + } + } + Connections { target: manager onWaitingForPositionChanged: { @@ -207,443 +289,514 @@ Item { manager.appendTextToLog("received updated position info " + gpsText) } } - - TemplateLabelSmall { - Layout.alignment: Qt.AlignRight - text: qsTr("Depth:") - } - SsrfTextField { - id: txtDepth - Layout.fillWidth: true - validator: RegExpValidator { regExp: /[^-]*/ } - flickable: detailsEditFlickable - } - TemplateLabelSmall { - Layout.alignment: Qt.AlignRight - text: qsTr("Duration:") - } - SsrfTextField { - id: txtDuration - Layout.fillWidth: true - validator: RegExpValidator { regExp: /[^-]*/ } - flickable: detailsEditFlickable - } - - TemplateLabelSmall { - Layout.alignment: Qt.AlignRight - text: qsTr("Air Temp:") - } - SsrfTextField { - id: txtAirTemp - Layout.fillWidth: true - flickable: detailsEditFlickable - } - - TemplateLabelSmall { - Layout.alignment: Qt.AlignRight - text: qsTr("Water Temp:") - } - SsrfTextField { - id: txtWaterTemp - Layout.fillWidth: true - flickable: detailsEditFlickable - } - - TemplateLabelSmall { - Layout.alignment: Qt.AlignRight - text: qsTr("Suit:") - } - TemplateEditComboBox { - id: suitBox - flickable: detailsEditFlickable - model: diveDetailsListView.currentItem && diveDetailsListView.currentItem.modelData !== null ? - manager.suitList : null - } - - TemplateLabelSmall { - Layout.alignment: Qt.AlignRight - text: qsTr("Buddy:") - } - TemplateEditComboBox { - id: buddyBox - flickable: detailsEditFlickable - model: diveDetailsListView.currentItem && diveDetailsListView.currentItem.modelData !== null ? - manager.buddyList : null - } - - TemplateLabelSmall { - Layout.alignment: Qt.AlignRight - text: qsTr("Divemaster:") - } - TemplateEditComboBox { - id: divemasterBox - flickable: detailsEditFlickable - model: diveDetailsListView.currentItem && diveDetailsListView.currentItem.modelData !== null ? - manager.divemasterList : null - } - - TemplateLabelSmall { - Layout.alignment: Qt.AlignRight - text: qsTr("Weight:") - } - SsrfTextField { - id: txtWeight - readOnly: text === "cannot edit multiple weight systems" - Layout.fillWidth: true - flickable: detailsEditFlickable - } -// all cylinder info should be able to become dynamic instead of this blob of code. -// first cylinder - TemplateLabelSmall { - Layout.alignment: Qt.AlignRight - text: qsTr("Cylinder1:") - } - TemplateComboBox { - id: cylinderBox0 - flickable: detailsEditFlickable - flat: true - model: diveDetailsListView.currentItem && diveDetailsListView.currentItem.modelData !== null ? - diveDetailsListView.currentItem.modelData.cylinderList : null - inputMethodHints: Qt.ImhNoPredictiveText - Layout.fillWidth: true - } - - TemplateLabelSmall { - Layout.alignment: Qt.AlignRight - text: qsTr("Gas mix:") - } - SsrfTextField { - id: txtGasMix0 - text: usedGas[0] != null ? usedGas[0] : null - Layout.fillWidth: true - validator: RegExpValidator { regExp: /(EAN100|EAN\d\d|AIR|100|\d{1,2}|\d{1,2}\/\d{1,2})/i } - flickable: detailsEditFlickable - } - - TemplateLabelSmall { - Layout.alignment: Qt.AlignRight - text: qsTr("Start Pressure:") - } - SsrfTextField { - id: txtStartPressure0 - text: startpressure[0] != null ? startpressure[0] : null - Layout.fillWidth: true - flickable: detailsEditFlickable - } - - TemplateLabelSmall { - Layout.alignment: Qt.AlignRight - text: qsTr("End Pressure:") - } - SsrfTextField { - id: txtEndPressure0 - text: endpressure[0] != null ? endpressure[0] : null - Layout.fillWidth: true - flickable: detailsEditFlickable - } -//second cylinder - TemplateLabelSmall { - visible: usedCyl[1] != null ? true : false - Layout.alignment: Qt.AlignRight - text: qsTr("Cylinder2:") - } - TemplateComboBox { - visible: usedCyl[1] != null ? true : false - id: cylinderBox1 - flickable: detailsEditFlickable - flat: true - model: diveDetailsListView.currentItem && diveDetailsListView.currentItem.modelData !== null ? - diveDetailsListView.currentItem.modelData.cylinderList : null - inputMethodHints: Qt.ImhNoPredictiveText - Layout.fillWidth: true - } - - TemplateLabelSmall { - visible: usedCyl[1] != null ? true : false - Layout.alignment: Qt.AlignRight - text: qsTr("Gas mix:") - } - SsrfTextField { - visible: usedCyl[1] != null ? true : false - id: txtGasMix1 - text: usedGas[1] != null ? usedGas[1] : null - Layout.fillWidth: true - validator: RegExpValidator { regExp: /(EAN100|EAN\d\d|AIR|100|\d{1,2}|\d{1,2}\/\d{1,2})/i } - flickable: detailsEditFlickable - } - - TemplateLabelSmall { - visible: usedCyl[1] != null ? true : false - Layout.alignment: Qt.AlignRight - text: qsTr("Start Pressure:") - } - SsrfTextField { - visible: usedCyl[1] != null ? true : false - id: txtStartPressure1 - text: startpressure[1] != null ? startpressure[1] : null - Layout.fillWidth: true - flickable: detailsEditFlickable - } - - TemplateLabelSmall { - visible: usedCyl[1] != null ? true : false - Layout.alignment: Qt.AlignRight - text: qsTr("End Pressure:") - } - SsrfTextField { - visible: usedCyl[1] != null ? true : false - id: txtEndPressure1 - text: endpressure[1] != null ? endpressure[1] : null - Layout.fillWidth: true - flickable: detailsEditFlickable + RowLayout { + width: Kirigami.Units.gridUnit * 20 + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 4 + horizontalAlignment: Text.AlignRight + text: qsTr("Suit:") + } + TemplateEditComboBox { + id: suitBox + flickable: detailsEditFlickable + model: diveDetailsListView.currentItem && diveDetailsListView.currentItem.modelData !== null ? + manager.suitList : null + } } -// third cylinder - TemplateLabelSmall { - visible: usedCyl[2] != null ? true : false - Layout.alignment: Qt.AlignRight - text: qsTr("Cylinder3:") + RowLayout { + width: Kirigami.Units.gridUnit * 20 + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 4 + horizontalAlignment: Text.AlignRight + text: qsTr("Buddy:") + } + TemplateEditComboBox { + id: buddyBox + flickable: detailsEditFlickable + model: diveDetailsListView.currentItem && diveDetailsListView.currentItem.modelData !== null ? + manager.buddyList : null + } } - TemplateComboBox { - visible: usedCyl[2] != null ? true : false - id: cylinderBox2 - flickable: detailsEditFlickable - currentIndex: find(usedCyl[2]) - flat: true - model: diveDetailsListView.currentItem && diveDetailsListView.currentItem.modelData !== null ? - diveDetailsListView.currentItem.modelData.cylinderList : null - inputMethodHints: Qt.ImhNoPredictiveText - Layout.fillWidth: true + RowLayout { + width: Kirigami.Units.gridUnit * 20 + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 4 + horizontalAlignment: Text.AlignRight + text: qsTr("Divemaster:") + } + TemplateEditComboBox { + id: divemasterBox + flickable: detailsEditFlickable + model: diveDetailsListView.currentItem && diveDetailsListView.currentItem.modelData !== null ? + manager.divemasterList : null + } } - TemplateLabelSmall { - visible: usedCyl[2] != null ? true : false - Layout.alignment: Qt.AlignRight - text: qsTr("Gas mix:") - } - SsrfTextField { - visible: usedCyl[2] != null ? true : false - id: txtGasMix2 - text: usedGas[2] != null ? usedGas[2] : null - Layout.fillWidth: true - validator: RegExpValidator { regExp: /(EAN100|EAN\d\d|AIR|100|\d{1,2}|\d{1,2}\/\d{1,2})/i } - } - TemplateLabelSmall { - visible: usedCyl[2] != null ? true : false - Layout.alignment: Qt.AlignRight - text: qsTr("Start Pressure:") - } - SsrfTextField { - visible: usedCyl[2] != null ? true : false - id: txtStartPressure2 - text: startpressure[2] != null ? startpressure[2] : null - Layout.fillWidth: true - flickable: detailsEditFlickable + RowLayout { + width: Kirigami.Units.gridUnit * 16 + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 4 + horizontalAlignment: Text.AlignRight + text: qsTr("Weight:") + } + SsrfTextField { + id: txtWeight + Layout.preferredWidth: Kirigami.Units.gridUnit * 12 + readOnly: text === "cannot edit multiple weight systems" + flickable: detailsEditFlickable + } } - TemplateLabelSmall { - visible: usedCyl[2] != null ? true : false - Layout.alignment: Qt.AlignRight - text: qsTr("End Pressure:") - } - SsrfTextField { - visible: usedCyl[2] != null ? true : false - id: txtEndPressure2 - text: endpressure[2] != null ? endpressure[2] : null - Layout.fillWidth: true - flickable: detailsEditFlickable - } -// fourth cylinder - TemplateLabelSmall { - visible: usedCyl[3] != null ? true : false - Layout.alignment: Qt.AlignRight - text: qsTr("Cylinder4:") - } - TemplateComboBox { - visible: usedCyl[3] != null ? true : false - id: cylinderBox3 - flickable: detailsEditFlickable - currentIndex: find(usedCyl[3]) - flat: true - model: diveDetailsListView.currentItem && diveDetailsListView.currentItem.modelData !== null ? - diveDetailsListView.currentItem.modelData.cylinderList : null - inputMethodHints: Qt.ImhNoPredictiveText - Layout.fillWidth: true + // all cylinder info should be able to become dynamic instead of this blob of code. + // first cylinder + Flow { + width: parent.width + RowLayout { + width: Kirigami.Units.gridUnit * 12 + id: cb1 + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 4 + horizontalAlignment: Text.AlignRight + text: qsTr("Cylinder1:") + } + TemplateComboBox { + id: cylinderBox0 + flickable: detailsEditFlickable + flat: true + model: diveDetailsListView.currentItem && diveDetailsListView.currentItem.modelData !== null ? + diveDetailsListView.currentItem.modelData.cylinderList : null + inputMethodHints: Qt.ImhNoPredictiveText + } + } + RowLayout { + height: cb1.height + width: Kirigami.Units.gridUnit * 8 + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 4 + horizontalAlignment: Text.AlignRight + text: qsTr("Gas mix:") + } + SsrfTextField { + id: txtGasMix0 + text: usedGas[0] !== undefined ? usedGas[0] : null + Layout.fillWidth: true + validator: RegExpValidator { regExp: /(EAN100|EAN\d\d|AIR|100|\d{1,2}|\d{1,2}\/\d{1,2})/i } + flickable: detailsEditFlickable + } + } + RowLayout { + height: cb1.height + width: Kirigami.Units.gridUnit * 10 + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 6 + horizontalAlignment: Text.AlignRight + text: qsTr("Start Pressure:") + } + SsrfTextField { + id: txtStartPressure0 + text: startpressure[0] !== undefined ? startpressure[0] : null + Layout.fillWidth: true + flickable: detailsEditFlickable + } + } + RowLayout { + height: cb1.height + width: Kirigami.Units.gridUnit * 10 + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 6 + horizontalAlignment: Text.AlignRight + text: qsTr("End Pressure:") + } + SsrfTextField { + id: txtEndPressure0 + text: endpressure[0] !== undefined ? endpressure[0] : null + Layout.fillWidth: true + flickable: detailsEditFlickable + } + } } - - TemplateLabelSmall { - visible: usedCyl[3] != null ? true : false - Layout.alignment: Qt.AlignRight - text: qsTr("Gas mix:") + //second cylinder + Flow { + width: parent.width + visible: usedCyl[1] !== undefined ? true : false + RowLayout { + id: cb2 + width: Kirigami.Units.gridUnit * 12 + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 4 + horizontalAlignment: Text.AlignRight + text: qsTr("Cylinder2:") + } + TemplateComboBox { + id: cylinderBox1 + flickable: detailsEditFlickable + flat: true + model: diveDetailsListView.currentItem && diveDetailsListView.currentItem.modelData !== null ? + diveDetailsListView.currentItem.modelData.cylinderList : null + inputMethodHints: Qt.ImhNoPredictiveText + } + } + RowLayout { + width: Kirigami.Units.gridUnit * 8 + height: cb2.height + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 4 + horizontalAlignment: Text.AlignRight + text: qsTr("Gas mix:") + } + SsrfTextField { + id: txtGasMix1 + text: usedGas[1] !== undefined ? usedGas[1] : null + Layout.fillWidth: true + validator: RegExpValidator { regExp: /(EAN100|EAN\d\d|AIR|100|\d{1,2}|\d{1,2}\/\d{1,2})/i } + flickable: detailsEditFlickable + } + } + RowLayout { + width: Kirigami.Units.gridUnit * 10 + height: cb2.height + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 6 + horizontalAlignment: Text.AlignRight + text: qsTr("Start Pressure:") + } + SsrfTextField { + id: txtStartPressure1 + text: startpressure[1] !== undefined ? startpressure[1] : null + Layout.fillWidth: true + flickable: detailsEditFlickable + } + } + RowLayout { + width: Kirigami.Units.gridUnit * 10 + height: cb2.height + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 6 + horizontalAlignment: Text.AlignRight + text: qsTr("End Pressure:") + } + SsrfTextField { + id: txtEndPressure1 + text: endpressure[1] !== undefined ? endpressure[1] : null + Layout.fillWidth: true + flickable: detailsEditFlickable + } + } } - SsrfTextField { - visible: usedCyl[3] != null ? true : false - id: txtGasMix3 - text: usedGas[3] != null ? usedGas[3] : null - Layout.fillWidth: true - validator: RegExpValidator { regExp: /(EAN100|EAN\d\d|AIR|100|\d{1,2}|\d{1,2}\/\d{1,2})/i } - flickable: detailsEditFlickable + // third cylinder + Flow { + width: parent.width + visible: usedCyl[2] !== undefined ? true : false + RowLayout { + id: cb3 + width: Kirigami.Units.gridUnit * 12 + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 4 + horizontalAlignment: Text.AlignRight + text: qsTr("Cylinder3:") + } + TemplateComboBox { + id: cylinderBox2 + flickable: detailsEditFlickable + currentIndex: find(usedCyl[2]) + flat: true + model: diveDetailsListView.currentItem && diveDetailsListView.currentItem.modelData !== null ? + diveDetailsListView.currentItem.modelData.cylinderList : null + inputMethodHints: Qt.ImhNoPredictiveText + } + } + RowLayout { + width: Kirigami.Units.gridUnit * 8 + height: cb3.height + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 4 + horizontalAlignment: Text.AlignRight + text: qsTr("Gas mix:") + } + SsrfTextField { + id: txtGasMix2 + text: usedGas[2] !== undefined ? usedGas[2] : null + Layout.fillWidth: true + validator: RegExpValidator { regExp: /(EAN100|EAN\d\d|AIR|100|\d{1,2}|\d{1,2}\/\d{1,2})/i } + flickable: detailsEditFlickable + } + } + RowLayout { + width: Kirigami.Units.gridUnit * 10 + height: cb3.height + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 6 + horizontalAlignment: Text.AlignRight + text: qsTr("Start Pressure:") + } + SsrfTextField { + id: txtStartPressure2 + text: startpressure[2] !== undefined ? startpressure[2] : null + Layout.fillWidth: true + flickable: detailsEditFlickable + } + } + RowLayout { + width: Kirigami.Units.gridUnit * 10 + height: cb3.height + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 6 + horizontalAlignment: Text.AlignRight + text: qsTr("End Pressure:") + } + SsrfTextField { + id: txtEndPressure2 + text: endpressure[2] !== undefined ? endpressure[2] : null + Layout.fillWidth: true + flickable: detailsEditFlickable + } + } } + // fourth cylinder + Flow { + width: parent.width + visible: usedCyl[3] !== undefined ? true : false + RowLayout { + id: cb4 + width: Kirigami.Units.gridUnit * 12 + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 4 + horizontalAlignment: Text.AlignRight + text: qsTr("Cylinder4:") + } + TemplateComboBox { + id: cylinderBox3 + flickable: detailsEditFlickable + currentIndex: find(usedCyl[3]) + flat: true + model: diveDetailsListView.currentItem && diveDetailsListView.currentItem.modelData !== null ? + diveDetailsListView.currentItem.modelData.cylinderList : null + inputMethodHints: Qt.ImhNoPredictiveText + } - TemplateLabelSmall { - visible: usedCyl[3] != null ? true : false - Layout.alignment: Qt.AlignRight - text: qsTr("Start Pressure:") - } - SsrfTextField { - visible: usedCyl[3] != null ? true : false - id: txtStartPressure3 - text: startpressure[3] != null ? startpressure[3] : null - Layout.fillWidth: true - flickable: detailsEditFlickable - } + } + RowLayout { + width: Kirigami.Units.gridUnit * 8 + height: cb4.height + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 4 + horizontalAlignment: Text.AlignRight + text: qsTr("Gas mix:") + } + SsrfTextField { + id: txtGasMix3 + text: usedGas[3] !== undefined ? usedGas[3] : null + Layout.fillWidth: true + validator: RegExpValidator { regExp: /(EAN100|EAN\d\d|AIR|100|\d{1,2}|\d{1,2}\/\d{1,2})/i } + flickable: detailsEditFlickable + } - TemplateLabelSmall { - visible: usedCyl[3] != null ? true : false - Layout.alignment: Qt.AlignRight - text: qsTr("End Pressure:") - } - SsrfTextField { - visible: usedCyl[3] != null ? true : false - id: txtEndPressure3 - text: endpressure[3] != null ? endpressure[3] : null - Layout.fillWidth: true - flickable: detailsEditFlickable - } -// fifth cylinder - TemplateLabelSmall { - visible: usedCyl[4] != null ? true : false - Layout.alignment: Qt.AlignRight - text: qsTr("Cylinder5:") - } - TemplateComboBox { - visible: usedCyl[4] != null ? true : false - id: cylinderBox4 - flickable: detailsEditFlickable - currentIndex: find(usedCyl[4]) - flat: true - model: diveDetailsListView.currentItem && diveDetailsListView.currentItem.modelData !== null ? - diveDetailsListView.currentItem.modelData.cylinderList : null - inputMethodHints: Qt.ImhNoPredictiveText - Layout.fillWidth: true - } + } + RowLayout { + width: Kirigami.Units.gridUnit * 10 + height: cb4.height + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 6 + horizontalAlignment: Text.AlignRight + text: qsTr("Start Pressure:") + } + SsrfTextField { + id: txtStartPressure3 + text: startpressure[3] !== undefined ? startpressure[3] : null + Layout.fillWidth: true + flickable: detailsEditFlickable + } - TemplateLabelSmall { - visible: usedCyl[4] != null ? true : false - Layout.alignment: Qt.AlignRight - text: qsTr("Gas mix:") - } - SsrfTextField { - visible: usedCyl[4] != null ? true : false - id: txtGasMix4 - text: usedGas[4] != null ? usedGas[4] : null - Layout.fillWidth: true - validator: RegExpValidator { regExp: /(EAN100|EAN\d\d|AIR|100|\d{1,2}|\d{1,2}\/\d{1,2})/i } - flickable: detailsEditFlickable + } + RowLayout { + width: Kirigami.Units.gridUnit * 10 + height: cb4.height + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 6 + horizontalAlignment: Text.AlignRight + text: qsTr("End Pressure:") + } + SsrfTextField { + id: txtEndPressure3 + text: endpressure[3] !== undefined ? endpressure[3] : null + Layout.fillWidth: true + flickable: detailsEditFlickable + } + } } + // fifth cylinder + Flow { + width: parent.width + visible: usedCyl[4] !== undefined ? true : false + RowLayout { + id: cb5 + width: Kirigami.Units.gridUnit * 12 + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 4 + horizontalAlignment: Text.AlignRight + text: qsTr("Cylinder5:") + } + TemplateComboBox { + id: cylinderBox4 + flickable: detailsEditFlickable + currentIndex: find(usedCyl[4]) + flat: true + model: diveDetailsListView.currentItem && diveDetailsListView.currentItem.modelData !== null ? + diveDetailsListView.currentItem.modelData.cylinderList : null + inputMethodHints: Qt.ImhNoPredictiveText + Layout.fillWidth: true + } - TemplateLabelSmall { - visible: usedCyl[4] != null ? true : false - Layout.alignment: Qt.AlignRight - text: qsTr("Start Pressure:") - } - SsrfTextField { - visible: usedCyl[4] != null ? true : false - id: txtStartPressure4 - text: startpressure[4] != null ? startpressure[4] : null - Layout.fillWidth: true - flickable: detailsEditFlickable - } + } + RowLayout { + width: Kirigami.Units.gridUnit * 8 + height: cb5.height + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 4 + horizontalAlignment: Text.AlignRight + text: qsTr("Gas mix:") + } + SsrfTextField { + id: txtGasMix4 + text: usedGas[4] !== undefined ? usedGas[4] : null + Layout.fillWidth: true + validator: RegExpValidator { regExp: /(EAN100|EAN\d\d|AIR|100|\d{1,2}|\d{1,2}\/\d{1,2})/i } + flickable: detailsEditFlickable + } - TemplateLabelSmall { - visible: usedCyl[4] != null ? true : false - Layout.alignment: Qt.AlignRight - text: qsTr("End Pressure:") - } - SsrfTextField { - visible: usedCyl[4] != null ? true : false - id: txtEndPressure4 - text: endpressure[4] != null ? endpressure[4] : null - Layout.fillWidth: true - flickable: detailsEditFlickable - } + } + RowLayout { + width: Kirigami.Units.gridUnit * 10 + height: cb5.height + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 6 + horizontalAlignment: Text.AlignRight + text: qsTr("Start Pressure:") + } + SsrfTextField { + id: txtStartPressure4 + text: startpressure[4] !== undefined ? startpressure[4] : null + Layout.fillWidth: true + flickable: detailsEditFlickable + } - TemplateLabelSmall { - Layout.alignment: Qt.AlignRight - text: qsTr("Rating:") - } - TemplateSpinBox { - id: ratingPicker - from: 0 - to: 5 - value: rating - onValueChanged: rating = value + } + RowLayout { + width: Kirigami.Units.gridUnit * 10 + height: cb5.height + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 6 + horizontalAlignment: Text.AlignRight + text: qsTr("End Pressure:") + } + SsrfTextField { + id: txtEndPressure4 + text: endpressure[4] !== undefined ? endpressure[4] : null + Layout.fillWidth: true + flickable: detailsEditFlickable + } + } } + // rating / visibility + RowLayout { + width: parent.width - TemplateLabelSmall { - Layout.alignment: Qt.AlignRight - text: qsTr("Visibility:") - } - TemplateSpinBox { - id: visibilityPicker - from: 0 - to: 5 - value: visibility - onValueChanged: visibility = value - } + RowLayout { + width: Kirigami.Units.gridUnit * 10 + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 4 + horizontalAlignment: Text.AlignRight + text: qsTr("Rating:") + } + TemplateSpinBox { + id: ratingPicker + Layout.preferredWidth: Kirigami.Units.gridUnit * 6 + from: 0 + to: 5 + value: rating + onValueChanged: rating = value + } - TemplateLabelSmall { - Layout.columnSpan: 2 - Layout.alignment: Qt.AlignLeft - text: qsTr("Notes:") + } + RowLayout { + width: Kirigami.Units.gridUnit * 10 + TemplateLabelSmall { + Layout.preferredWidth: Kirigami.Units.gridUnit * 4 + horizontalAlignment: Text.AlignRight + text: qsTr("Visibility:") + } + TemplateSpinBox { + id: visibilityPicker + Layout.preferredWidth: Kirigami.Units.gridUnit * 6 + from: 0 + to: 5 + value: visibility + onValueChanged: visibility = value + } + Item { Layout.fillWidth: true } + } } - Controls.TextArea { - Layout.columnSpan: 2 + ColumnLayout { width: parent.width - id: txtNotes - textFormat: TextEdit.RichText - focus: true - color: subsurfaceTheme.textColor - Layout.fillWidth: true - Layout.fillHeight: true - Layout.minimumHeight: Kirigami.Units.gridUnit * 6 - selectByMouse: true - wrapMode: TextEdit.WrapAtWordBoundaryOrAnywhere - property bool firstTime: true - property int visibleTop: detailsEditFlickable.contentY - property int visibleBottom: visibleTop + detailsEditFlickable.height - 4 * Kirigami.Units.gridUnit - onPressed: waitForKeyboard.start() - onCursorRectangleChanged: { - ensureVisible(y + cursorRectangle.y) + TemplateLabelSmall { + Layout.preferredWidth: parent.width + text: qsTr("Notes:") } - - // ensure that the y coordinate is inside the visible part of the detailsEditFlickable (our flickable) - function ensureVisible(yDest) { - if (yDest > visibleBottom) - detailsEditFlickable.contentY += yDest - visibleBottom - if (yDest < visibleTop) - detailsEditFlickable.contentY -= visibleTop - yDest - } - - // give the OS enough time to actually resize the flickable - Timer { - id: waitForKeyboard - interval: 300 // 300ms seems like FOREVER - onTriggered: { - if (!Qt.inputMethod.visible) { - if (txtNotes.firstTime) { - txtNotes.firstTime = false - restart() + Controls.TextArea { + Layout.preferredWidth: parent.width + width: parent.width + id: txtNotes + textFormat: TextEdit.RichText + focus: true + color: subsurfaceTheme.textColor + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumHeight: Kirigami.Units.gridUnit * 6 + selectByMouse: true + wrapMode: TextEdit.WrapAtWordBoundaryOrAnywhere + property bool firstTime: true + onPressed: waitForKeyboard.start() + onCursorRectangleChanged: { + ensureVisible() + } + // ensure that the y coordinate is inside the visible part of the detailsEditFlickable (our flickable) + function ensureVisible() { + // make sure there's enough space for the TextArea above the keyboard and action button + // and that it's not too far up, either + var flickable = detailsEditFlickable + var positionInFlickable = txtNotes.mapToItem(flickable.contentItem, 0, 0) + var taY = positionInFlickable.y + cursorRectangle.y + if (manager.verboseEnabled) + manager.appendTextToLog("position check: lower edge of view is " + + (0 + flickable.contentY + flickable.height) + + " and text area is at " + taY) + if (taY > flickable.contentY + flickable.height - 4 * Kirigami.Units.gridUnit) + flickable.contentY = Math.max(0, 4 * Kirigami.Units.gridUnit + taY - flickable.height) + while (taY < flickable.contentY) + flickable.contentY -= 2 * Kirigami.Units.gridUnit + } + // give the OS enough time to actually resize the flickable + Timer { + id: waitForKeyboard + interval: 300 // 300ms seems like FOREVER + onTriggered: { + if (!Qt.inputMethod.visible) { + if (txtNotes.firstTime) { + txtNotes.firstTime = false + restart() + } + return } - return + // make sure at least half the Notes field is visible + txtNotes.ensureVisible() } - // make sure at least half the Notes field is visible - txtNotes.ensureVisible(txtNotes.y + txtNotes.cursorRectangle.y) } } } } Item { + anchors.top: editArea.bottom height: Kirigami.Units.gridUnit * 3 width: height // just to make sure the spacer doesn't produce scrollbars, but also isn't null } diff --git a/mobile-widgets/qml/Settings.qml b/mobile-widgets/qml/Settings.qml index 3d28bec2a..e0497d36c 100644 --- a/mobile-widgets/qml/Settings.qml +++ b/mobile-widgets/qml/Settings.qml @@ -309,35 +309,58 @@ TemplatePage { Layout.bottomMargin: Kirigami.Units.largeSpacing / 2 Layout.columnSpan: 2 } - RowLayout { + Flow { Layout.columnSpan: 2 spacing: Kirigami.Units.largeSpacing TemplateButton { - text: qsTr("smaller") + text: qsTr("very small") + fontSize: subsurfaceTheme.regularPointSize / subsurfaceTheme.currentScale * 0.75 + enabled: subsurfaceTheme.currentScale !== 0.75 + onClicked: { + subsurfaceTheme.currentScale = 0.75 + rootItem.setupUnits() + } + } + TemplateButton { + text: qsTr("small") Layout.fillWidth: true + fontSize: subsurfaceTheme.regularPointSize / subsurfaceTheme.currentScale * 0.85 enabled: subsurfaceTheme.currentScale !== 0.85 onClicked: { subsurfaceTheme.currentScale = 0.85 + rootItem.setupUnits() } } TemplateButton { text: qsTr("regular") Layout.fillWidth: true + fontSize: subsurfaceTheme.regularPointSize / subsurfaceTheme.currentScale * 0.85 enabled: subsurfaceTheme.currentScale !== 1.0 onClicked: { subsurfaceTheme.currentScale = 1.0 + rootItem.setupUnits() } } TemplateButton { - text: qsTr("larger") + text: qsTr("large") Layout.fillWidth: true + fontSize: subsurfaceTheme.regularPointSize / subsurfaceTheme.currentScale * 1.15 enabled: subsurfaceTheme.currentScale !== 1.15 onClicked: { subsurfaceTheme.currentScale = 1.15 + rootItem.setupUnits() + } + } + TemplateButton { + text: qsTr("very large") + Layout.fillWidth: true + fontSize: subsurfaceTheme.regularPointSize / subsurfaceTheme.currentScale * 1.3 + enabled: subsurfaceTheme.currentScale !== 1.3 + onClicked: { + subsurfaceTheme.currentScale = 1.3 + rootItem.setupUnits() } } - } - Rectangle { } } } @@ -516,37 +539,31 @@ TemplatePage { } TemplateLabel { text: qsTr("Distance threshold (meters)") - //Layout.preferredWidth: gridWidth * 0.75 } TemplateTextField { id: distanceThreshold + Layout.preferredWidth: Kirigami.Units.gridUnit * 2 text: PrefLocationService.distance_threshold - //Layout.preferredWidth: gridWidth * 0.25 onEditingFinished: { PrefLocationService.distance_threshold = distanceThreshold.text } } TemplateLabel { text: qsTr("Time threshold (minutes)") - //Layout.preferredWidth: gridWidth * 0.75 } TemplateTextField { id: timeThreshold + Layout.preferredWidth: Kirigami.Units.gridUnit * 2 text: PrefLocationService.time_threshold / 60 - //Layout.preferredWidth: gridWidth * 0.25 onEditingFinished: { PrefLocationService.time_threshold = timeThreshold.text * 60 } } - } - TemplateLine { - visible: sectionAdvanced.isExpanded - } - GridLayout { - id: whichBluetoothDevices - visible: sectionAdvanced.isExpanded - width: parent.width - columns: 2 + + TemplateLine { + visible: sectionAdvanced.isExpanded + Layout.columnSpan: 2 + } TemplateLabel { text: qsTr("Bluetooth") font.pointSize: subsurfaceTheme.headingPointSize @@ -558,25 +575,19 @@ TemplatePage { TemplateLabel { text: qsTr("Temporarily show all bluetooth devices \neven if not recognized as dive computers.\nPlease report DCs that need this setting") Layout.fillWidth: true - //Layout.preferredWidth: gridWidth * 0.75 } SsrfSwitch { id: nonDCButton checked: manager.showNonDiveComputers - //Layout.preferredWidth: gridWidth * 0.25 onClicked: { manager.showNonDiveComputers = checked } } - } - TemplateLine { - visible: sectionAdvanced.isExpanded - } - GridLayout { - id: display - visible: sectionAdvanced.isExpanded - width: parent.width - columns: 2 + + TemplateLine { + visible: sectionAdvanced.isExpanded + Layout.columnSpan: 2 + } TemplateLabel { text: qsTr("Display") font.pointSize: subsurfaceTheme.headingPointSize @@ -588,25 +599,18 @@ TemplatePage { TemplateLabel { text: qsTr("Show only one column in Portrait mode") Layout.fillWidth: true - //Layout.preferredWidth: gridWidth * 0.75 } SsrfSwitch { id: singleColumnButton checked: PrefDisplay.singleColumnPortrait - //Layout.preferredWidth: gridWidth * 0.25 onClicked: { PrefDisplay.singleColumnPortrait = checked } } - } - TemplateLine { - visible: sectionAdvanced.isExpanded - } - GridLayout { - id: profilePrefs - visible: sectionAdvanced.isExpanded - width: parent.width - columns: 2 + TemplateLine { + visible: sectionAdvanced.isExpanded + Layout.columnSpan: 2 + } TemplateLabel { text: qsTr("Profile deco ceiling") font.pointSize: subsurfaceTheme.headingPointSize @@ -622,7 +626,7 @@ TemplatePage { checked: PrefTechnicalDetails.dcceiling onClicked: { PrefTechnicalDetails.dcceiling = checked - rootItem.settingChanged() + rootItem.settingsChanged() } } TemplateLabel { @@ -635,16 +639,36 @@ TemplatePage { rootItem.settingsChanged() } } - } - TemplateLine { - visible: sectionAdvanced.isExpanded - } - - GridLayout { - id: developer - visible: sectionAdvanced.isExpanded - width: parent.width - columns: 2 + TemplateLabel { + text: qsTr("GFLow") + } + TemplateTextField { + id: gfLow + Layout.preferredWidth: Kirigami.Units.gridUnit * 2 + text: PrefTechnicalDetails.gflow + inputMask: "99" + onEditingFinished: { + PrefTechnicalDetails.gflow = gfLow.text + rootItem.settingsChanged() + } + } + TemplateLabel { + text: qsTr("GFHigh") + } + TemplateTextField { + id: gfHigh + Layout.preferredWidth: Kirigami.Units.gridUnit * 2 + text: PrefTechnicalDetails.gfhigh + inputMask: "99" + onEditingFinished: { + PrefTechnicalDetails.gfhigh = gfHigh.text + rootItem.settingsChanged() + } + } + TemplateLine { + visible: sectionAdvanced.isExpanded + Layout.columnSpan: 2 + } TemplateLabel { text: qsTr("Developer") font.pointSize: subsurfaceTheme.headingPointSize @@ -656,12 +680,10 @@ TemplatePage { TemplateLabel { text: qsTr("Display Developer menu") Layout.fillWidth: true - //Layout.preferredWidth: gridWidth * 0.75 } SsrfSwitch { id: developerButton checked: PrefDisplay.show_developer - //sLayout.preferredWidth: gridWidth * 0.25 onClicked: { PrefDisplay.show_developer = checked } diff --git a/mobile-widgets/qml/SsrfTextField.qml b/mobile-widgets/qml/SsrfTextField.qml index d91e13c2d..8f07f5ee1 100644 --- a/mobile-widgets/qml/SsrfTextField.qml +++ b/mobile-widgets/qml/SsrfTextField.qml @@ -11,9 +11,30 @@ Controls.TextField { property var flickable property bool firstTime: true + /** + * set inComboBox if the TextField is used in an editable ComboBox + * this ensures that the baseline that is used to visually indicate that the user can + * edit the text as well as use the drop down is placed much closer to the actual text + */ + property bool inComboBox: false + id: stf + background: Item { + Rectangle { + width: parent.width - Kirigami.Units.smallSpacing + x: inComboBox ? Kirigami.Units.smallSpacing : -1 + height: 1 + color: stf.focus ? subsurfaceTheme.primaryColor : Qt.darker(subsurfaceTheme.backgroundColor, 1.2) + anchors.bottom: parent.bottom + anchors.bottomMargin: inComboBox ? Kirigami.Units.largeSpacing : 1 + visible: !stf.readOnly + } + } // while we are at it, let's put some common settings here into the shared element + font.pointSize: subsurfaceTheme.regularPointSize + topPadding: 0 + bottomPadding: 0 color: subsurfaceTheme.textColor onEditingFinished: { focus = false @@ -44,8 +65,8 @@ Controls.TextField { // make sure there's enough space for the input field above the keyboard and action button (and that it's not too far up, either) var positionInFlickable = stf.mapToItem(flickable.contentItem, 0, 0) var stfY = positionInFlickable.y - if (verbose) - manager.appendTextToLogFile("position check: lower edge of view is " + (0 + flickable.contentY + flickable.height) + " and text field is at " + stfY) + if (manager.verboseEnabebled) + manager.appendTextToLog("position check: lower edge of view is " + (0 + flickable.contentY + flickable.height) + " and text field is at " + stfY) if (stfY + stf.height > flickable.contentY + flickable.height - 3 * Kirigami.Units.gridUnit || stfY < flickable.contentY) flickable.contentY = Math.max(0, 3 * Kirigami.Units.gridUnit + stfY + stf.height - flickable.height) } diff --git a/mobile-widgets/qml/StatisticsPage.qml b/mobile-widgets/qml/StatisticsPage.qml index 72a50602f..0a9a7bdea 100644 --- a/mobile-widgets/qml/StatisticsPage.qml +++ b/mobile-widgets/qml/StatisticsPage.qml @@ -15,6 +15,7 @@ Kirigami.Page { bottomPadding: 0 width: rootItem.width implicitWidth: rootItem.width + background: Rectangle { color: subsurfaceTheme.backgroundColor } property bool wide: width > rootItem.height StatsManager { id: statsManager @@ -31,6 +32,18 @@ Kirigami.Page { statsManager.doit() } } + onWideChanged: { + // so this means we rotated the device - and sometimes after rotation + // the stats widget is empty. + rotationRedrawTrigger.start() + } + Timer { + // wait .5 seconds (so the OS rotation animation has a chance to run) and then set var1 again + // to its current value, which appears to be enough to ensure that the chart is drawn again + id: rotationRedrawTrigger + interval: 500 + onTriggered: statsManager.var1Changed(i1.var1currentIndex) + } Component { id: chartListDelegate @@ -56,6 +69,7 @@ Kirigami.Page { Label { text: chartName font.bold: isHeader + color: subsurfaceTheme.textColor } } } @@ -87,6 +101,7 @@ Kirigami.Page { Layout.row: 0 Layout.leftMargin: Kirigami.Units.smallSpacing Layout.topMargin: Kirigami.Units.smallSpacing + property alias var1currentIndex: var1.currentIndex TemplateLabelSmall { text: qsTr("Base variable") } @@ -193,10 +208,10 @@ Kirigami.Page { onClicked: chartTypePopup.open() } Item { - Layout.column: wide ? 0 : 1 + Layout.column: wide ? 0 : 2 Layout.row: wide ? 6 : 2 Layout.preferredHeight: wide ? parent.height - Kirigami.Units.gridUnit * 16 : Kirigami.Units.gridUnit - Layout.preferredWidth: wide ? parent.width - i1.implicitWidt - i2.implicitWidt - i3.implicitWidt - i4.implicitWidth : Kirigami.Units.gridUnit + Layout.fillWidth: wide ? false : true // just used for spacing } StatsView { diff --git a/mobile-widgets/qml/TemplateButton.qml b/mobile-widgets/qml/TemplateButton.qml index 49ca8c0dd..5586214a1 100644 --- a/mobile-widgets/qml/TemplateButton.qml +++ b/mobile-widgets/qml/TemplateButton.qml @@ -5,6 +5,7 @@ import org.kde.kirigami 2.4 as Kirigami Button { id: root + property double fontSize: subsurfaceTheme.regularPointSize background: Rectangle { id: buttonBackground color: root.enabled? (root.pressed ? subsurfaceTheme.darkerPrimaryColor : subsurfaceTheme.primaryColor) : "gray" @@ -15,7 +16,7 @@ Button { contentItem: Text { id: buttonText text: root.text - font.pointSize: subsurfaceTheme.regularPointSize + font.pointSize: root.fontSize anchors.centerIn: buttonBackground color: root.pressed ? subsurfaceTheme.darkerPrimaryTextColor :subsurfaceTheme.primaryTextColor } diff --git a/mobile-widgets/qml/TemplateComboBox.qml b/mobile-widgets/qml/TemplateComboBox.qml index 82b5fbf9f..10ffdb93e 100644 --- a/mobile-widgets/qml/TemplateComboBox.qml +++ b/mobile-widgets/qml/TemplateComboBox.qml @@ -6,10 +6,12 @@ import org.kde.kirigami 2.4 as Kirigami ComboBox { id: cb + editable: false Layout.fillWidth: true - Layout.preferredHeight: Kirigami.Units.gridUnit * 2.5 + Layout.preferredHeight: Kirigami.Units.gridUnit * 2.0 inputMethodHints: Qt.ImhNoPredictiveText font.pointSize: subsurfaceTheme.regularPointSize + rightPadding: Kirigami.Units.smallSpacing property var flickable // used to ensure the combobox is visible on screen delegate: ItemDelegate { width: cb.width @@ -49,6 +51,7 @@ ComboBox { } contentItem: SsrfTextField { + inComboBox: cb.editable readOnly: !cb.editable anchors.right: indicator.left anchors.left: cb.left @@ -59,6 +62,7 @@ ComboBox { font: cb.font color: subsurfaceTheme.textColor verticalAlignment: Text.AlignVCenter + onPressed: { if (readOnly) { if (cb.popup.opened) { diff --git a/mobile-widgets/qml/TemplateSlimComboBox.qml b/mobile-widgets/qml/TemplateSlimComboBox.qml index 36137770a..896871be1 100644 --- a/mobile-widgets/qml/TemplateSlimComboBox.qml +++ b/mobile-widgets/qml/TemplateSlimComboBox.qml @@ -8,7 +8,7 @@ TemplateComboBox { id: cb Layout.fillWidth: false Layout.preferredHeight: Kirigami.Units.gridUnit * 2 - Layout.preferredWidth: Kirigami.Units.gridUnit * 8 + Layout.minimumWidth: Kirigami.Units.gridUnit * 8 contentItem: Text { text: cb.displayText font.pointSize: subsurfaceTheme.regularPointSize diff --git a/mobile-widgets/qml/ThemeTest.qml b/mobile-widgets/qml/ThemeTest.qml index 49f8d6192..a15d33c90 100644 --- a/mobile-widgets/qml/ThemeTest.qml +++ b/mobile-widgets/qml/ThemeTest.qml @@ -23,114 +23,143 @@ Kirigami.Page { Kirigami.Heading { Layout.columnSpan: 2 text: "Theme Information" + color: subsurfaceTheme.textColor } Kirigami.Heading { text: "Screen" + color: subsurfaceTheme.textColor Layout.columnSpan: 2 level: 3 } - Controls.Label { + TemplateLabel { text: "Geometry (pixels):" } - Controls.Label { + TemplateLabel { text: rootItem.width + "x" + rootItem.height } - Controls.Label { + TemplateLabel { text: "Geometry (gridUnits):" } - Controls.Label { + TemplateLabel { text: Math.round(rootItem.width / Kirigami.Units.gridUnit) + "x" + Math.round(rootItem.height / Kirigami.Units.gridUnit) } - Controls.Label { + TemplateLabel { text: "Units.gridUnit:" } - Controls.Label { + TemplateLabel { text: Kirigami.Units.gridUnit } - Controls.Label { + TemplateLabel { text: "Units.devicePixelRatio:" } - Controls.Label { + TemplateLabel { text: Screen.devicePixelRatio } Kirigami.Heading { text: "Font Metrics" + color: subsurfaceTheme.textColor level: 3 Layout.columnSpan: 2 } - Controls.Label { + TemplateLabel { text: "basePointSize:" } - Controls.Label { + TemplateLabel { text: subsurfaceTheme.basePointSize } - Controls.Label { + TemplateLabel { text: "FontMetrics pointSize:" } - Controls.Label { + TemplateLabel { text: fontMetrics.font.pointSize } - Controls.Label { + TemplateLabel { text: "FontMetrics pixelSize:" } - Controls.Label { + TemplateLabel { text: Number(fontMetrics.height).toFixed(2) } - Controls.Label { + TemplateLabel { text: "FontMetrics devicePixelRatio:" } - Controls.Label { + TemplateLabel { text: Number(fontMetrics.height / fontMetrics.font.pointSize).toFixed(2) } - Controls.Label { + TemplateLabel { text: "Text item pixelSize:" } - Text { + TemplateLabel { text: fontMetrics.font.pixelSize } - Controls.Label { + TemplateLabel { text: "Text item pointSize:" } - Text { + TemplateLabel { text: fontMetrics.font.pointSize } - Controls.Label { + TemplateLabel { text: "Pixel density:" } - Text { + TemplateLabel { text: Number(Screen.pixelDensity).toFixed(2) } - Controls.Label { + TemplateLabel { text: "Height of default font:" } - Text { + TemplateLabel { text: Number(fontMetrics.font.pixelSize / Screen.pixelDensity).toFixed(2) + "mm" } - Controls.Label { + TemplateLabel { text: "2cm x 2cm square:" } Rectangle { width: Math.round(Screen.pixelDensity * 20) height: Math.round(Screen.pixelDensity * 20) - color: "black" + color: subsurfaceTheme.textColor + } + TemplateLabel { + text: "text in 4 gridUnit square" + } + Rectangle { + id: backSquare + width: Kirigami.Units.gridUnit * 4 + height: width + color: subsurfaceTheme.primaryColor + border.color: subsurfaceTheme.primaryColor + border.width: 1 + + Controls.Label { + anchors.top: backSquare.top + anchors.left: backSquare.left + color: subsurfaceTheme.primaryTextColor + font.pointSize: subsurfaceTheme.regularPointSize + text: "Simply 27 random characters" + } + Controls.Label { + anchors.bottom: backSquare.bottom + anchors.left: backSquare.left + color: subsurfaceTheme.primaryTextColor + font.pointSize: subsurfaceTheme.smallPointSize + text: "Simply 27 random characters" + } } - Controls.Label { + TemplateLabel { Layout.columnSpan: 2 Layout.fillHeight: true } diff --git a/mobile-widgets/qml/main.qml b/mobile-widgets/qml/main.qml index 1e5e1bdba..2ef9a4332 100644 --- a/mobile-widgets/qml/main.qml +++ b/mobile-widgets/qml/main.qml @@ -53,17 +53,19 @@ Kirigami.ApplicationWindow { onNotificationTextChanged: { // once the app is fully initialized and the UI is running, we use passive // notifications to show the notification text, but during initialization - // we instead dump the information into the textBlock below - and to make - // this visually more useful we interpret a "\r" at the beginning of a notification - // to mean that we want to simply over-write the last line, not create a new one + // we instead dump the information into the textBlock below if (initialized) { - // make sure any old notification is hidden - // hiding notifications is no longer supported???? - // hidePassiveNotification() if (notificationText !== "") { - // there's a risk that we have a >5 second gap in update events; - // still, keep the timeout at 5s to avoid odd unchanging notifications - showPassiveNotification(notificationText, 5000) + var actionEnd = notificationText.indexOf("]") + if (notificationText.startsWith("[") && actionEnd !== -1) { + // we have a notification text that starts with our special syntax to indication + // an action that the user can take (the actual action is always opening the context drawer + // so the action text should always be something that can then be found in the context drawer) + showPassiveNotification(notificationText.substring(actionEnd + 1), 5000, notificationText.substring(1,actionEnd), + function() { contextDrawer.open() }) + } else { + showPassiveNotification(notificationText, 5000) + } } } else { textBlock.text = textBlock.text + "\n" + notificationText @@ -663,32 +665,50 @@ if you have network connectivity and want to sync your data to cloud storage."), } } + property double regularFontsize: subsurfaceTheme.regularPointSize + + FontMetrics { + id: fontMetrics + font.pointSize: regularFontsize + } + + onRegularFontsizeChanged: { + manager.appendTextToLog("regular font size changed to " + regularFontsize) + rootItem.font.pointSize = regularFontsize + } + function setupUnits() { + // since Kirigami was initially instantiated, the font size may have + // changed, so recalculate the gridUnit + var kirigamiGridUnit = fontMetrics.height + // some screens are too narrow for Subsurface-mobile to render well - // try to hack around that by making sure that we can fit at least 21 gridUnits in a row - var numColumns = Math.max(Math.floor(rootItem.width / (21 * Kirigami.Units.gridUnit)), 1) + // things don't look greate with fewer than 21 gridUnits in a row + var numColumns = Math.max(Math.floor(rootItem.width / (21 * kirigamiGridUnit)), 1) if (Screen.primaryOrientation === Qt.PortraitOrientation && PrefDisplay.singleColumnPortrait) { manager.appendTextToLog("show only one column in portrait mode"); numColumns = 1; } - rootItem.colWidth = numColumns > 1 ? Math.floor(rootItem.width / numColumns) : rootItem.width; - var kirigamiGridUnit = Kirigami.Units.gridUnit + + // If we can't fit 21 gridUnits into a line, let the user know and suggest using a smaller font var widthInGridUnits = Math.floor(rootItem.colWidth / kirigamiGridUnit) if (widthInGridUnits < 21) { - kirigamiGridUnit = Math.floor(rootItem.colWidth / 21) - widthInGridUnits = Math.floor(rootItem.colWidth / kirigamiGridUnit) + showPassiveNotification(qsTr("Font size likely too big for the display, switching to smaller font suggested"), 3000) } - var factor = 1.0 manager.appendTextToLog(numColumns + " columns with column width of " + rootItem.colWidth) manager.appendTextToLog("width in Grid Units " + widthInGridUnits + " original gridUnit " + Kirigami.Units.gridUnit + " now " + kirigamiGridUnit) if (Kirigami.Units.gridUnit !== kirigamiGridUnit) { - factor = kirigamiGridUnit / Kirigami.Units.gridUnit - // change our glabal grid unit - Kirigami.Units.gridUnit = kirigamiGridUnit + // change our global grid unit and prevent Kirigami from resizing our rootItem + var fixWidth = rootItem.width + var fixHeight = rootItem.height + Kirigami.Units.gridUnit = kirigamiGridUnit * 1.0 + rootItem.width = fixWidth + rootItem.height = fixHeight } + pageStack.defaultColumnWidth = rootItem.colWidth - manager.appendTextToLog("Done setting up sizes") + manager.appendTextToLog("Done setting up sizes width " + rootItem.width + " gridUnit " + kirigamiGridUnit) } QtObject { @@ -709,6 +729,7 @@ if you have network connectivity and want to sync your data to cloud storage."), onWidthChanged: { manager.appendTextToLog("[screensetup] width changed now " + width + " x " + height + " vs screen " + Screen.width + " x " + Screen.height) + if (screenSizeObject.lastOrientation === undefined) { manager.appendTextToLog("[screensetup] found initial orientation " + Screen.orientation) screenSizeObject.lastOrientation = Screen.orientation @@ -741,6 +762,7 @@ if you have network connectivity and want to sync your data to cloud storage."), manager.appendTextToLog("[screensetup] remembering better height") screenSizeObject.initialWidth = width } + setupUnits() } } } else { diff --git a/mobile-widgets/qmlmanager.cpp b/mobile-widgets/qmlmanager.cpp index a40eb7f6c..fddc61d3a 100644 --- a/mobile-widgets/qmlmanager.cpp +++ b/mobile-widgets/qmlmanager.cpp @@ -128,7 +128,12 @@ static void showProgress(QString msg) // show the git progress in the passive notification area extern "C" int gitProgressCB(const char *text) { - showProgress(QString(text)); + // regular users, during regular operation, likely really don't + // care at all about the git progress + if (verbose) { + showProgress(QString(text)); + appendTextToLogStandalone(text); + } // return 0 so that we don't end the download return 0; } @@ -328,6 +333,9 @@ QMLManager::QMLManager() : m_locationServiceEnabled(false), // we start out with clean data updateHaveLocalChanges(false); + + // setup Command infrastructure + Command::init(); } void QMLManager::applicationStateChanged(Qt::ApplicationState state) @@ -1324,7 +1332,7 @@ void QMLManager::addDiveToTrip(int id, int tripId) changesNeedSaving(); } -void QMLManager::changesNeedSaving() +void QMLManager::changesNeedSaving(bool fromUndo) { // we no longer save right away on iOS because file access is so slow; on the other hand, // on Android the save as the user switches away doesn't seem to work... drat. @@ -1335,9 +1343,9 @@ void QMLManager::changesNeedSaving() mark_divelist_changed(true); emit syncStateChanged(); #if defined(Q_OS_IOS) - saveChangesLocal(); + saveChangesLocal(fromUndo); #else - saveChangesCloud(false); + saveChangesCloud(false, fromUndo); #endif updateAllGlobalLists(); } @@ -1368,7 +1376,7 @@ void QMLManager::openNoCloudRepo() openLocalThenRemote(filename); } -void QMLManager::saveChangesLocal() +void QMLManager::saveChangesLocal(bool fromUndo) { if (unsavedChanges()) { if (qPrefCloudStorage::cloud_verification_status() == qPrefCloudStorage::CS_NOCLOUD) { @@ -1398,12 +1406,21 @@ void QMLManager::saveChangesLocal() mark_divelist_changed(false); Command::setClean(); updateHaveLocalChanges(true); + // provide a useful undo/redo notification + // NOTE: the QML UI interprets a leading '[action]' (where only the two brackets are checked for) + // as an indication to use the text between those two brackets as the label of a button that + // can be used to open the context menu + QString msgFormat = tr("[%1]Changes saved:'%2'.\n%1 possible via context menu"); + if (fromUndo) + setNotificationText(msgFormat.arg(tr("Redo")).arg(tr("Undo: %1").arg(getRedoText()))); + else + setNotificationText(msgFormat.arg(tr("Undo")).arg(getUndoText())); } else { appendTextToLog("local save requested with no unsaved changes"); } } -void QMLManager::saveChangesCloud(bool forceRemoteSync) +void QMLManager::saveChangesCloud(bool forceRemoteSync, bool fromUndo) { if (!unsavedChanges() && !forceRemoteSync) { appendTextToLog("asked to save changes but no unsaved changes"); @@ -1411,7 +1428,7 @@ void QMLManager::saveChangesCloud(bool forceRemoteSync) } // first we need to store any unsaved changes to the local repo gitProgressCB("Save changes to local cache"); - saveChangesLocal(); + saveChangesLocal(fromUndo); // if the user asked not to push to the cloud we are done if (git_local_only && !forceRemoteSync) return; @@ -1431,7 +1448,7 @@ void QMLManager::saveChangesCloud(bool forceRemoteSync) void QMLManager::undo() { Command::getUndoStack()->undo(); - changesNeedSaving(); + changesNeedSaving(true); } void QMLManager::redo() diff --git a/mobile-widgets/qmlmanager.h b/mobile-widgets/qmlmanager.h index 7a031a73a..9db2f991c 100644 --- a/mobile-widgets/qmlmanager.h +++ b/mobile-widgets/qmlmanager.h @@ -190,9 +190,9 @@ public slots: void removeDiveFromTrip(int id); void addTripForDive(int id); void addDiveToTrip(int id, int tripId); - void changesNeedSaving(); + void changesNeedSaving(bool fromUndo = false); void openNoCloudRepo(); - void saveChangesCloud(bool forceRemoteSync); + void saveChangesCloud(bool forceRemoteSync, bool fromUndo = false); void selectDive(int id); void deleteDive(int id); void toggleDiveInvalid(int id); @@ -283,7 +283,7 @@ private: void consumeFinishedLoad(); void mergeLocalRepo(); void openLocalThenRemote(QString url); - void saveChangesLocal(); + void saveChangesLocal(bool fromUndo = false); #if defined(Q_OS_ANDROID) || defined(Q_OS_IOS) QString appLogFileName; diff --git a/mobile-widgets/themeinterface.cpp b/mobile-widgets/themeinterface.cpp index 87fcd3d88..8dcfc1903 100644 --- a/mobile-widgets/themeinterface.cpp +++ b/mobile-widgets/themeinterface.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: GPL-2.0 #include "themeinterface.h" +#include "core/subsurface-string.h" #include "qmlmanager.h" #include "core/metrics.h" #include "core/settings/qPrefDisplay.h" @@ -55,13 +56,7 @@ ThemeInterface::ThemeInterface() // get current theme m_currentTheme = qPrefDisplay::theme(); update_theme(); - - // check system font and create QFontInfo in order to reliably get the point size - QFontInfo qfi(defaultModelFont()); - m_basePointSize = qfi.pointSize(); - - // set initial font size - set_currentScale(qPrefDisplay::mobile_scale()); + m_basePointSize = -1.0; // simply a placeholder to declare 'this isn't set, yet' } void ThemeInterface::set_currentTheme(const QString &theme) @@ -72,6 +67,12 @@ void ThemeInterface::set_currentTheme(const QString &theme) emit currentThemeChanged(); } +void ThemeInterface::setInitialFontSize(double fontSize) +{ + m_basePointSize = fontSize; + set_currentScale(qPrefDisplay::mobile_scale()); +} + double ThemeInterface::currentScale() { return qPrefDisplay::mobile_scale(); @@ -79,26 +80,30 @@ double ThemeInterface::currentScale() void ThemeInterface::set_currentScale(double newScale) { - if (newScale != qPrefDisplay::mobile_scale()) { + static bool needSignals = true; // make sure the signals fire the first time + + if (!IS_FP_SAME(newScale, qPrefDisplay::mobile_scale())) { qPrefDisplay::set_mobile_scale(newScale); emit currentScaleChanged(); + needSignals = true; } + if (needSignals) { + // adjust all used font sizes + m_regularPointSize = m_basePointSize * newScale; + defaultModelFont().setPointSizeF(m_regularPointSize); + emit regularPointSizeChanged(); - // Set current font size - defaultModelFont().setPointSizeF(m_basePointSize * qPrefDisplay::mobile_scale()); + m_headingPointSize = m_regularPointSize * 1.2; + emit headingPointSizeChanged(); - // adjust all used font sizes - m_regularPointSize = m_basePointSize * qPrefDisplay::mobile_scale(); - emit regularPointSizeChanged(); + m_smallPointSize = m_regularPointSize * 0.8; + emit smallPointSizeChanged(); - m_headingPointSize = m_regularPointSize * 1.2; - emit headingPointSizeChanged(); + m_titlePointSize = m_regularPointSize * 1.5; + emit titlePointSizeChanged(); - m_smallPointSize = m_regularPointSize * 0.8; - emit smallPointSizeChanged(); - - m_titlePointSize = m_regularPointSize * 1.5; - emit titlePointSizeChanged(); + needSignals = false; + } } void ThemeInterface::update_theme() diff --git a/mobile-widgets/themeinterface.h b/mobile-widgets/themeinterface.h index e4e61144f..922d6531e 100644 --- a/mobile-widgets/themeinterface.h +++ b/mobile-widgets/themeinterface.h @@ -37,6 +37,7 @@ class ThemeInterface : public QObject { public: static ThemeInterface *instance(); double currentScale(); + void setInitialFontSize(double fontSize); public slots: void set_currentTheme(const QString &theme); diff --git a/profile-widget/profilewidget2.cpp b/profile-widget/profilewidget2.cpp index 4759c007a..043c39258 100644 --- a/profile-widget/profilewidget2.cpp +++ b/profile-widget/profilewidget2.cpp @@ -185,6 +185,8 @@ ProfileWidget2::ProfileWidget2(QWidget *parent) : QGraphicsView(parent), auto tec = qPrefTechnicalDetails::instance(); connect(tec, &qPrefTechnicalDetails::calcalltissuesChanged , this, &ProfileWidget2::actionRequestedReplot); connect(tec, &qPrefTechnicalDetails::calcceilingChanged , this, &ProfileWidget2::actionRequestedReplot); + connect(tec, &qPrefTechnicalDetails::gflowChanged , this, &ProfileWidget2::actionRequestedReplot); + connect(tec, &qPrefTechnicalDetails::gfhighChanged , this, &ProfileWidget2::actionRequestedReplot); connect(tec, &qPrefTechnicalDetails::dcceilingChanged , this, &ProfileWidget2::actionRequestedReplot); connect(tec, &qPrefTechnicalDetails::eadChanged , this, &ProfileWidget2::actionRequestedReplot); connect(tec, &qPrefTechnicalDetails::calcceiling3mChanged , this, &ProfileWidget2::actionRequestedReplot); diff --git a/stats/CMakeLists.txt b/stats/CMakeLists.txt index a084dd0b5..daeb22146 100644 --- a/stats/CMakeLists.txt +++ b/stats/CMakeLists.txt @@ -9,14 +9,23 @@ set(SUBSURFACE_STATS_SRCS barseries.cpp boxseries.h boxseries.cpp + chartitem.h + chartitem.cpp chartlistmodel.h chartlistmodel.cpp + histogrammarker.h + histogrammarker.cpp + chartlistmodel.cpp informationbox.h informationbox.cpp legend.h legend.cpp pieseries.h pieseries.cpp + quartilemarker.h + quartilemarker.cpp + regressionitem.h + regressionitem.cpp scatterseries.h scatterseries.cpp statsaxis.h diff --git a/stats/barseries.cpp b/stats/barseries.cpp index 49170bf80..766843703 100644 --- a/stats/barseries.cpp +++ b/stats/barseries.cpp @@ -4,6 +4,7 @@ #include "statscolors.h" #include "statshelper.h" #include "statstranslations.h" +#include "statsview.h" #include "zvalues.h" #include <math.h> // for lrint() @@ -12,6 +13,7 @@ // Constants that control the bar layout static const double barWidth = 0.8; // 1.0 = full width of category static const double subBarWidth = 0.9; // For grouped bar charts +static const double barBorderWidth = 1.0; // Default constructor: invalid index. BarSeries::Index::Index() : bar(-1), subitem(-1) @@ -27,19 +29,19 @@ bool BarSeries::Index::operator==(const Index &i2) const return std::tie(bar, subitem) == std::tie(i2.bar, i2.subitem); } -BarSeries::BarSeries(QGraphicsScene *scene, StatsAxis *xAxis, StatsAxis *yAxis, +BarSeries::BarSeries(StatsView &view, StatsAxis *xAxis, StatsAxis *yAxis, bool horizontal, bool stacked, const QString &categoryName, const StatsVariable *valueVariable, std::vector<QString> valueBinNames) : - StatsSeries(scene, xAxis, yAxis), + StatsSeries(view, xAxis, yAxis), horizontal(horizontal), stacked(stacked), categoryName(categoryName), valueVariable(valueVariable), valueBinNames(std::move(valueBinNames)) { } -BarSeries::BarSeries(QGraphicsScene *scene, StatsAxis *xAxis, StatsAxis *yAxis, +BarSeries::BarSeries(StatsView &view, StatsAxis *xAxis, StatsAxis *yAxis, bool horizontal, const QString &categoryName, const std::vector<CountItem> &items) : - BarSeries(scene, xAxis, yAxis, horizontal, false, categoryName, nullptr, std::vector<QString>()) + BarSeries(view, xAxis, yAxis, horizontal, false, categoryName, nullptr, std::vector<QString>()) { for (const CountItem &item: items) { StatsOperationResults res; @@ -50,10 +52,10 @@ BarSeries::BarSeries(QGraphicsScene *scene, StatsAxis *xAxis, StatsAxis *yAxis, } } -BarSeries::BarSeries(QGraphicsScene *scene, StatsAxis *xAxis, StatsAxis *yAxis, +BarSeries::BarSeries(StatsView &view, StatsAxis *xAxis, StatsAxis *yAxis, bool horizontal, const QString &categoryName, const StatsVariable *valueVariable, const std::vector<ValueItem> &items) : - BarSeries(scene, xAxis, yAxis, horizontal, false, categoryName, valueVariable, std::vector<QString>()) + BarSeries(view, xAxis, yAxis, horizontal, false, categoryName, valueVariable, std::vector<QString>()) { for (const ValueItem &item: items) { add_item(item.lowerBound, item.upperBound, makeSubItems(item.value, item.label), @@ -61,11 +63,11 @@ BarSeries::BarSeries(QGraphicsScene *scene, StatsAxis *xAxis, StatsAxis *yAxis, } } -BarSeries::BarSeries(QGraphicsScene *scene, StatsAxis *xAxis, StatsAxis *yAxis, +BarSeries::BarSeries(StatsView &view, StatsAxis *xAxis, StatsAxis *yAxis, bool horizontal, bool stacked, const QString &categoryName, const StatsVariable *valueVariable, std::vector<QString> valueBinNames, const std::vector<MultiItem> &items) : - BarSeries(scene, xAxis, yAxis, horizontal, stacked, categoryName, valueVariable, std::move(valueBinNames)) + BarSeries(view, xAxis, yAxis, horizontal, stacked, categoryName, valueVariable, std::move(valueBinNames)) { for (const MultiItem &item: items) { StatsOperationResults res; @@ -85,97 +87,79 @@ BarSeries::~BarSeries() { } -BarSeries::BarLabel::BarLabel(QGraphicsScene *scene, const std::vector<QString> &labels, int bin_nr, int binCount) : - totalWidth(0.0), totalHeight(0.0), isOutside(false) +BarSeries::BarLabel::BarLabel(StatsView &view, const std::vector<QString> &labels, int bin_nr, int binCount) : + isOutside(false) { - items.reserve(labels.size()); - for (const QString &label: labels) { - items.emplace_back(createItem<QGraphicsSimpleTextItem>(scene)); - items.back()->setText(label); - items.back()->setZValue(ZValues::seriesLabels); - QRectF rect = items.back()->boundingRect(); - if (rect.width() > totalWidth) - totalWidth = rect.width(); - totalHeight += rect.height(); - } - highlight(false, bin_nr, binCount); + QFont f; // make configurable + item = view.createChartItem<ChartTextItem>(ChartZValue::SeriesLabels, f, labels, true); + //highlight(false, bin_nr, binCount); } void BarSeries::BarLabel::setVisible(bool visible) { - for (auto &item: items) - item->setVisible(visible); + item->setVisible(visible); } -void BarSeries::BarLabel::highlight(bool highlight, int bin_nr, int binCount) +void BarSeries::BarLabel::highlight(bool highlight, int bin_nr, int binCount, const QColor &background) { - QBrush brush(highlight || isOutside ? darkLabelColor : labelColor(bin_nr, binCount)); - for (auto &item: items) - item->setBrush(brush); + // For labels that are on top of a bar, use the corresponding bar color + // as background. Rendering on a transparent background gives ugly artifacts. + item->setColor(highlight || isOutside ? darkLabelColor : labelColor(bin_nr, binCount), + isOutside ? Qt::transparent : background); } void BarSeries::BarLabel::updatePosition(bool horizontal, bool center, const QRectF &rect, - int bin_nr, int binCount) + int bin_nr, int binCount, const QColor &background) { + QSizeF itemSize = item->getRect().size(); if (!horizontal) { - if (totalWidth > rect.width()) { + if (itemSize.width() > rect.width()) { setVisible(false); return; } QPointF pos = rect.center(); + pos.rx() -= round(itemSize.width() / 2.0); // Heuristics: if the label fits nicely into the bar (bar height is at least twice the label height), // then put the label in the middle of the bar. Otherwise, put it at the top of the bar. - isOutside = !center && rect.height() < 2.0 * totalHeight; + isOutside = !center && rect.height() < 2.0 * itemSize.height(); if (isOutside) { - pos.ry() = rect.top() - (totalHeight + 2.0); // Leave two pixels(?) space + pos.ry() = rect.top() - (itemSize.height() + 2.0); // Leave two pixels(?) space } else { - if (totalHeight > rect.height()) { + if (itemSize.height() > rect.height()) { setVisible(false); return; } - pos.ry() -= totalHeight / 2.0; - } - for (auto &it: items) { - QPointF itemPos = pos; - QRectF rect = it->boundingRect(); - itemPos.rx() -= rect.width() / 2.0; - it->setPos(itemPos); - pos.ry() += rect.height(); + pos.ry() -= round(itemSize.height() / 2.0); } + item->setPos(pos); } else { - if (totalHeight > rect.height()) { + if (itemSize.height() > rect.height()) { setVisible(false); return; } QPointF pos = rect.center(); - pos.ry() -= totalHeight / 2.0; + pos.ry() -= round(itemSize.height() / 2.0); // Heuristics: if the label fits nicely into the bar (bar width is at least twice the label height), // then put the label in the middle of the bar. Otherwise, put it to the right of the bar. - isOutside = !center && rect.width() < 2.0 * totalWidth; + isOutside = !center && rect.width() < 2.0 * itemSize.width(); if (isOutside) { - pos.rx() = rect.right() + (totalWidth / 2.0 + 2.0); // Leave two pixels(?) space + pos.rx() = round(rect.right() + 2.0); // Leave two pixels(?) space } else { - if (totalWidth > rect.width()) { + if (itemSize.width() > rect.width()) { setVisible(false); return; } } - for (auto &it: items) { - QPointF itemPos = pos; - QRectF rect = it->boundingRect(); - itemPos.rx() -= rect.width() / 2.0; - it->setPos(itemPos); - pos.ry() += rect.height(); - } + item->setPos(pos); } setVisible(true); // If label changed from inside to outside, or vice-versa, the color might change. - highlight(false, bin_nr, binCount); + highlight(false, bin_nr, binCount, background); } -BarSeries::Item::Item(QGraphicsScene *scene, BarSeries *series, double lowerBound, double upperBound, +BarSeries::Item::Item(BarSeries *series, double lowerBound, double upperBound, std::vector<SubItem> subitemsIn, const QString &binName, const StatsOperationResults &res, int total, bool horizontal, bool stacked, int binCount) : @@ -186,10 +170,8 @@ BarSeries::Item::Item(QGraphicsScene *scene, BarSeries *series, double lowerBoun res(res), total(total) { - for (SubItem &item: subitems) { - item.item->setZValue(ZValues::series); + for (SubItem &item: subitems) item.highlight(false, binCount); - } updatePosition(series, horizontal, stacked, binCount); } @@ -202,15 +184,11 @@ void BarSeries::Item::highlight(int subitem, bool highlight, int binCount) void BarSeries::SubItem::highlight(bool highlight, int binCount) { - if (highlight) { - item->setBrush(QBrush(highlightedColor)); - item->setPen(QPen(highlightedBorderColor)); - } else { - item->setBrush(QBrush(binColor(bin_nr, binCount))); - item->setPen(QPen(::borderColor)); - } + fill = highlight ? highlightedColor : binColor(bin_nr, binCount); + QColor border = highlight ? highlightedBorderColor : ::borderColor; + item->setColor(fill, border); if (label) - label->highlight(highlight, bin_nr, binCount); + label->highlight(highlight, bin_nr, binCount, fill); } void BarSeries::Item::updatePosition(BarSeries *series, bool horizontal, bool stacked, int binCount) @@ -234,9 +212,9 @@ void BarSeries::Item::updatePosition(BarSeries *series, bool horizontal, bool st double center = (idx + 0.5) * fullSubWidth + from; item.updatePosition(series, horizontal, stacked, center - subWidth / 2.0, center + subWidth / 2.0, binCount); } - rect = subitems[0].item->rect(); + rect = subitems[0].item->getRect(); for (auto it = std::next(subitems.begin()); it != subitems.end(); ++it) - rect = rect.united(it->item->rect()); + rect = rect.united(it->item->getRect()); } void BarSeries::SubItem::updatePosition(BarSeries *series, bool horizontal, bool stacked, @@ -253,7 +231,7 @@ void BarSeries::SubItem::updatePosition(BarSeries *series, bool horizontal, bool QRectF rect(topLeft, bottomRight); item->setRect(rect); if (label) - label->updatePosition(horizontal, stacked, rect, bin_nr, binCount); + label->updatePosition(horizontal, stacked, rect, bin_nr, binCount, fill); } std::vector<BarSeries::SubItem> BarSeries::makeSubItems(const std::vector<std::pair<double, std::vector<QString>>> &values) const @@ -264,9 +242,10 @@ std::vector<BarSeries::SubItem> BarSeries::makeSubItems(const std::vector<std::p int bin_nr = 0; for (auto &[v, label]: values) { if (v > 0.0) { - res.push_back({ createItemPtr<QGraphicsRectItem>(scene), {}, from, from + v, bin_nr }); + res.push_back({ view.createChartItem<ChartBarItem>(ChartZValue::Series, barBorderWidth, horizontal), + {}, from, from + v, bin_nr }); if (!label.empty()) - res.back().label = std::make_unique<BarLabel>(scene, label, bin_nr, binCount()); + res.back().label = std::make_unique<BarLabel>(view, label, bin_nr, binCount()); } if (stacked) from += v; @@ -292,7 +271,7 @@ void BarSeries::add_item(double lowerBound, double upperBound, std::vector<SubIt // Don't add empty items, as that messes with the "find item under mouse" routine. if (subitems.empty()) return; - items.emplace_back(scene, this, lowerBound, upperBound, std::move(subitems), binName, res, + items.emplace_back(this, lowerBound, upperBound, std::move(subitems), binName, res, total, horizontal, stacked, binCount()); } @@ -322,10 +301,10 @@ int BarSeries::Item::getSubItemUnderMouse(const QPointF &point, bool horizontal, // Search the first item whose "end" position is greater than the cursor position. bool search_x = horizontal == stacked; auto it = search_x ? std::lower_bound(subitems.begin(), subitems.end(), point.x(), - [] (const SubItem &item, double x) { return item.item->rect().right() < x; }) + [] (const SubItem &item, double x) { return item.item->getRect().right() < x; }) : std::lower_bound(subitems.begin(), subitems.end(), point.y(), - [] (const SubItem &item, double y) { return item.item->rect().top() > y; }); - return it != subitems.end() && it->item->rect().contains(point) ? it - subitems.begin() : -1; + [] (const SubItem &item, double y) { return item.item->getRect().top() > y; }); + return it != subitems.end() && it->item->getRect().contains(point) ? it - subitems.begin() : -1; } // Format information in a count-based bar chart. @@ -403,10 +382,11 @@ bool BarSeries::hover(QPointF pos) Item &item = items[highlighted.bar]; item.highlight(index.subitem, true, binCount()); if (!information) - information = createItemPtr<InformationBox>(scene); + information = view.createChartItem<InformationBox>(); information->setText(makeInfo(item, highlighted.subitem), pos); + information->setVisible(true); } else { - information.reset(); + information->setVisible(false); } return highlighted.bar >= 0; diff --git a/stats/barseries.h b/stats/barseries.h index 0c8c34ffd..9f9586fe8 100644 --- a/stats/barseries.h +++ b/stats/barseries.h @@ -5,14 +5,17 @@ #ifndef BAR_SERIES_H #define BAR_SERIES_H +#include "statshelper.h" #include "statsseries.h" #include "statsvariables.h" #include <memory> #include <vector> -#include <QGraphicsRectItem> +#include <QColor> +#include <QRectF> -class QGraphicsScene; +class ChartBarItem; +class ChartTextItem; struct InformationBox; struct StatsVariable; @@ -47,13 +50,13 @@ public: // Note: this expects that all items are added with increasing pos // and that no bar is inside another bar, i.e. lowerBound and upperBound // are ordered identically. - BarSeries(QGraphicsScene *scene, StatsAxis *xAxis, StatsAxis *yAxis, + BarSeries(StatsView &view, StatsAxis *xAxis, StatsAxis *yAxis, bool horizontal, const QString &categoryName, const std::vector<CountItem> &items); - BarSeries(QGraphicsScene *scene, StatsAxis *xAxis, StatsAxis *yAxis, + BarSeries(StatsView &view, StatsAxis *xAxis, StatsAxis *yAxis, bool horizontal, const QString &categoryName, const StatsVariable *valueVariable, const std::vector<ValueItem> &items); - BarSeries(QGraphicsScene *scene, StatsAxis *xAxis, StatsAxis *yAxis, + BarSeries(StatsView &view, StatsAxis *xAxis, StatsAxis *yAxis, bool horizontal, bool stacked, const QString &categoryName, const StatsVariable *valueVariable, std::vector<QString> valueBinNames, const std::vector<MultiItem> &items); @@ -63,7 +66,7 @@ public: bool hover(QPointF pos) override; void unhighlight() override; private: - BarSeries(QGraphicsScene *scene, StatsAxis *xAxis, StatsAxis *yAxis, + BarSeries(StatsView &view, StatsAxis *xAxis, StatsAxis *yAxis, bool horizontal, bool stacked, const QString &categoryName, const StatsVariable *valueVariable, std::vector<QString> valueBinNames); @@ -80,21 +83,21 @@ private: // A label that is composed of multiple lines struct BarLabel { - std::vector<std::unique_ptr<QGraphicsSimpleTextItem>> items; - double totalWidth, totalHeight; // Size of the item + ChartItemPtr<ChartTextItem> item; bool isOutside; // Is shown outside of bar - BarLabel(QGraphicsScene *scene, const std::vector<QString> &labels, int bin_nr, int binCount); + BarLabel(StatsView &view, const std::vector<QString> &labels, int bin_nr, int binCount); void setVisible(bool visible); - void updatePosition(bool horizontal, bool center, const QRectF &rect, int bin_nr, int binCount); - void highlight(bool highlight, int bin_nr, int binCount); + void updatePosition(bool horizontal, bool center, const QRectF &rect, int bin_nr, int binCount, const QColor &background); + void highlight(bool highlight, int bin_nr, int binCount, const QColor &background); }; struct SubItem { - std::unique_ptr<QGraphicsRectItem> item; + ChartItemPtr<ChartBarItem> item; std::unique_ptr<BarLabel> label; double value_from; double value_to; int bin_nr; + QColor fill; void updatePosition(BarSeries *series, bool horizontal, bool stacked, double from, double to, int binCount); void highlight(bool highlight, int binCount); @@ -107,7 +110,7 @@ private: const QString binName; StatsOperationResults res; int total; - Item(QGraphicsScene *scene, BarSeries *series, double lowerBound, double upperBound, + Item(BarSeries *series, double lowerBound, double upperBound, std::vector<SubItem> subitems, const QString &binName, const StatsOperationResults &res, int total, bool horizontal, bool stacked, int binCount); @@ -116,9 +119,8 @@ private: int getSubItemUnderMouse(const QPointF &f, bool horizontal, bool stacked) const; }; - std::unique_ptr<InformationBox> information; + ChartItemPtr<InformationBox> information; std::vector<Item> items; - std::vector<BarLabel> barLabels; bool horizontal; bool stacked; QString categoryName; diff --git a/stats/boxseries.cpp b/stats/boxseries.cpp index 08a421205..c4f34dbb7 100644 --- a/stats/boxseries.cpp +++ b/stats/boxseries.cpp @@ -1,20 +1,22 @@ // SPDX-License-Identifier: GPL-2.0 #include "boxseries.h" #include "informationbox.h" +#include "statsaxis.h" #include "statscolors.h" #include "statshelper.h" #include "statstranslations.h" +#include "statsview.h" #include "zvalues.h" #include <QLocale> // Constants that control the bar layout static const double boxWidth = 0.8; // 1.0 = full width of category -static const int boxBorderWidth = 2; +static const int boxBorderWidth = 2.0; -BoxSeries::BoxSeries(QGraphicsScene *scene, StatsAxis *xAxis, StatsAxis *yAxis, +BoxSeries::BoxSeries(StatsView &view, StatsAxis *xAxis, StatsAxis *yAxis, const QString &variable, const QString &unit, int decimals) : - StatsSeries(scene, xAxis, yAxis), + StatsSeries(view, xAxis, yAxis), variable(variable), unit(unit), decimals(decimals), highlighted(-1) { } @@ -23,23 +25,12 @@ BoxSeries::~BoxSeries() { } -BoxSeries::Item::Item(QGraphicsScene *scene, BoxSeries *series, double lowerBound, double upperBound, +BoxSeries::Item::Item(StatsView &view, BoxSeries *series, double lowerBound, double upperBound, const StatsQuartiles &q, const QString &binName) : lowerBound(lowerBound), upperBound(upperBound), q(q), binName(binName) { - box.setZValue(ZValues::series); - topWhisker.setZValue(ZValues::series); - bottomWhisker.setZValue(ZValues::series); - topBar.setZValue(ZValues::series); - bottomBar.setZValue(ZValues::series); - center.setZValue(ZValues::series); - scene->addItem(&box); - scene->addItem(&topWhisker); - scene->addItem(&bottomWhisker); - scene->addItem(&topBar); - scene->addItem(&bottomBar); - scene->addItem(¢er); + item = view.createChartItem<ChartBoxItem>(ChartZValue::Series, boxBorderWidth); highlight(false); updatePosition(series); } @@ -50,48 +41,34 @@ BoxSeries::Item::~Item() void BoxSeries::Item::highlight(bool highlight) { - QBrush brush = highlight ? QBrush(highlightedColor) : QBrush(fillColor); - QPen pen = highlight ? QPen(highlightedBorderColor, boxBorderWidth) : QPen(::borderColor, boxBorderWidth); - box.setBrush(brush); - box.setPen(pen); - topWhisker.setPen(pen); - bottomWhisker.setPen(pen); - topBar.setPen(pen); - bottomBar.setPen(pen); - center.setPen(pen); + if (highlight) + item->setColor(highlightedColor, highlightedBorderColor); + else + item->setColor(fillColor, ::borderColor); } void BoxSeries::Item::updatePosition(BoxSeries *series) { + StatsAxis *xAxis = series->xAxis; + StatsAxis *yAxis = series->yAxis; + if (!xAxis || !yAxis) + return; + double delta = (upperBound - lowerBound) * boxWidth; double from = (lowerBound + upperBound - delta) / 2.0; double to = (lowerBound + upperBound + delta) / 2.0; - double mid = (from + to) / 2.0; - QPointF topLeft, bottomRight; - QMarginsF margins(boxBorderWidth / 2.0, boxBorderWidth / 2.0, boxBorderWidth / 2.0, boxBorderWidth / 2.0); - topLeft = series->toScreen(QPointF(from, q.max)); - bottomRight = series->toScreen(QPointF(to, q.min)); - bounding = QRectF(topLeft, bottomRight).marginsAdded(margins); - double left = topLeft.x(); - double right = bottomRight.x(); - double width = right - left; - double top = topLeft.y(); - double bottom = bottomRight.y(); - QPointF q1 = series->toScreen(QPointF(mid, q.q1)); - QPointF q2 = series->toScreen(QPointF(mid, q.q2)); - QPointF q3 = series->toScreen(QPointF(mid, q.q3)); - box.setRect(left, q3.y(), width, q1.y() - q3.y()); - topWhisker.setLine(q3.x(), top, q3.x(), q3.y()); - bottomWhisker.setLine(q1.x(), q1.y(), q1.x(), bottom); - topBar.setLine(left, top, right, top); - bottomBar.setLine(left, bottom, right, bottom); - center.setLine(left, q2.y(), right, q2.y()); + double fromScreen = xAxis->toScreen(from); + double toScreen = xAxis->toScreen(to); + double q1 = yAxis->toScreen(q.q1); + double q3 = yAxis->toScreen(q.q3); + QRectF rect(fromScreen, q3, toScreen - fromScreen, q1 - q3); + item->setBox(rect, yAxis->toScreen(q.min), yAxis->toScreen(q.max), yAxis->toScreen(q.q2)); } void BoxSeries::append(double lowerBound, double upperBound, const StatsQuartiles &q, const QString &binName) { - items.emplace_back(new Item(scene, this, lowerBound, upperBound, q, binName)); + items.emplace_back(new Item(view, this, lowerBound, upperBound, q, binName)); } void BoxSeries::updatePositions() @@ -105,8 +82,8 @@ int BoxSeries::getItemUnderMouse(const QPointF &point) { // Search the first item whose "end" position is greater than the cursor position. auto it = std::lower_bound(items.begin(), items.end(), point.x(), - [] (const std::unique_ptr<Item> &item, double x) { return item->bounding.right() < x; }); - return it != items.end() && (*it)->bounding.contains(point) ? it - items.begin() : -1; + [] (const std::unique_ptr<Item> &item, double x) { return item->item->getRect().right() < x; }); + return it != items.end() && (*it)->item->getRect().contains(point) ? it - items.begin() : -1; } static QString infoItem(const QString &name, const QString &unit, int decimals, double value) @@ -149,10 +126,11 @@ bool BoxSeries::hover(QPointF pos) Item &item = *items[highlighted]; item.highlight(true); if (!information) - information = createItemPtr<InformationBox>(scene); + information = view.createChartItem<InformationBox>(); information->setText(formatInformation(item), pos); + information->setVisible(true); } else { - information.reset(); + information->setVisible(false); } return highlighted >= 0; } diff --git a/stats/boxseries.h b/stats/boxseries.h index dde9014f6..ce48397ea 100644 --- a/stats/boxseries.h +++ b/stats/boxseries.h @@ -5,20 +5,18 @@ #ifndef BOX_SERIES_H #define BOX_SERIES_H +#include "chartitem.h" #include "statsseries.h" #include "statsvariables.h" // for StatsQuartiles #include <memory> #include <vector> -#include <QGraphicsLineItem> -#include <QGraphicsRectItem> struct InformationBox; -class QGraphicsScene; class BoxSeries : public StatsSeries { public: - BoxSeries(QGraphicsScene *scene, StatsAxis *xAxis, StatsAxis *yAxis, + BoxSeries(StatsView &view, StatsAxis *xAxis, StatsAxis *yAxis, const QString &variable, const QString &unit, int decimals); ~BoxSeries(); @@ -36,16 +34,12 @@ private: int getItemUnderMouse(const QPointF &f); struct Item { - QGraphicsRectItem box; - QGraphicsLineItem topWhisker, bottomWhisker; - QGraphicsLineItem topBar, bottomBar; - QGraphicsLineItem center; - QRectF bounding; // bounding box in screen coordinates - ~Item(); + ChartItemPtr<ChartBoxItem> item; double lowerBound, upperBound; StatsQuartiles q; QString binName; - Item(QGraphicsScene *scene, BoxSeries *series, double lowerBound, double upperBound, const StatsQuartiles &q, const QString &binName); + Item(StatsView &view, BoxSeries *series, double lowerBound, double upperBound, const StatsQuartiles &q, const QString &binName); + ~Item(); void updatePosition(BoxSeries *series); void highlight(bool highlight); }; @@ -54,7 +48,7 @@ private: int decimals; std::vector<QString> formatInformation(const Item &item) const; - std::unique_ptr<InformationBox> information; + ChartItemPtr<InformationBox> information; std::vector<std::unique_ptr<Item>> items; int highlighted; // -1: no item highlighted }; diff --git a/stats/chartitem.cpp b/stats/chartitem.cpp new file mode 100644 index 000000000..c8bdd130e --- /dev/null +++ b/stats/chartitem.cpp @@ -0,0 +1,492 @@ +// SPDX-License-Identifier: GPL-2.0 +#include "chartitem.h" +#include "statscolors.h" +#include "statsview.h" + +#include <cmath> +#include <QQuickWindow> +#include <QSGFlatColorMaterial> +#include <QSGImageNode> +#include <QSGRectangleNode> +#include <QSGTexture> + +static int round_up(double f) +{ + return static_cast<int>(ceil(f)); +} + +ChartItem::ChartItem(StatsView &v, ChartZValue z) : + dirty(false), prev(nullptr), next(nullptr), + zValue(z), view(v) +{ + // Register before the derived constructors run, so that the + // derived classes can mark the item as dirty in the constructor. + v.registerChartItem(*this); +} + +ChartItem::~ChartItem() +{ +} + +QSizeF ChartItem::sceneSize() const +{ + return view.size(); +} + +void ChartItem::markDirty() +{ + view.registerDirtyChartItem(*this); +} + +ChartPixmapItem::ChartPixmapItem(StatsView &v, ChartZValue z) : HideableChartItem(v, z), + positionDirty(false), textureDirty(false) +{ +} + +ChartPixmapItem::~ChartPixmapItem() +{ + painter.reset(); // Make sure to destroy painter before image that is painted on +} + +void ChartPixmapItem::setTextureDirty() +{ + textureDirty = true; + markDirty(); +} + +void ChartPixmapItem::setPositionDirty() +{ + positionDirty = true; + markDirty(); +} + +void ChartPixmapItem::render() +{ + if (!node) { + createNode(view.w()->createImageNode()); + view.addQSGNode(node.get(), zValue); + } + updateVisible(); + + if (!img) { + resize(QSizeF(1,1)); + img->fill(Qt::transparent); + } + if (textureDirty) { + texture.reset(view.w()->createTextureFromImage(*img, QQuickWindow::TextureHasAlphaChannel)); + node->node->setTexture(texture.get()); + textureDirty = false; + } + if (positionDirty) { + node->node->setRect(rect); + positionDirty = false; + } +} + +void ChartPixmapItem::resize(QSizeF size) +{ + painter.reset(); + img.reset(new QImage(round_up(size.width()), round_up(size.height()), QImage::Format_ARGB32)); + painter.reset(new QPainter(img.get())); + painter->setRenderHint(QPainter::Antialiasing); + rect.setSize(size); + setTextureDirty(); +} + +void ChartPixmapItem::setPos(QPointF pos) +{ + rect.moveTopLeft(pos); + setPositionDirty(); +} + +QRectF ChartPixmapItem::getRect() const +{ + return rect; +} + +static const int scatterItemDiameter = 10; +static const int scatterItemBorder = 1; + +ChartScatterItem::ChartScatterItem(StatsView &v, ChartZValue z) : HideableChartItem(v, z), + positionDirty(false), textureDirty(false), highlighted(false) +{ + rect.setSize(QSizeF(static_cast<double>(scatterItemDiameter), static_cast<double>(scatterItemDiameter))); +} + +ChartScatterItem::~ChartScatterItem() +{ +} + +static QSGTexture *createScatterTexture(StatsView &view, const QColor &color, const QColor &borderColor) +{ + QImage img(scatterItemDiameter, scatterItemDiameter, QImage::Format_ARGB32); + img.fill(Qt::transparent); + QPainter painter(&img); + painter.setPen(Qt::NoPen); + painter.setRenderHint(QPainter::Antialiasing); + painter.setBrush(borderColor); + painter.drawEllipse(0, 0, scatterItemDiameter, scatterItemDiameter); + painter.setBrush(color); + painter.drawEllipse(scatterItemBorder, scatterItemBorder, + scatterItemDiameter - 2 * scatterItemBorder, + scatterItemDiameter - 2 * scatterItemBorder); + return view.w()->createTextureFromImage(img, QQuickWindow::TextureHasAlphaChannel); +} + +// Note: Originally these were std::unique_ptrs, which automatically +// freed the textures on exit. However, destroying textures after +// QApplication finished its thread leads to crashes. Therefore, these +// are now normal pointers and the texture objects are leaked. +static QSGTexture *scatterItemTexture = nullptr; +static QSGTexture *scatterItemHighlightedTexture = nullptr; + +void ChartScatterItem::render() +{ + if (!scatterItemTexture) { + scatterItemTexture = createScatterTexture(view, fillColor, borderColor); + scatterItemHighlightedTexture = createScatterTexture(view, highlightedColor, highlightedBorderColor); + } + if (!node) { + createNode(view.w()->createImageNode()); + view.addQSGNode(node.get(), zValue); + textureDirty = positionDirty = true; + } + updateVisible(); + if (textureDirty) { + node->node->setTexture(highlighted ? scatterItemHighlightedTexture : scatterItemTexture); + textureDirty = false; + } + if (positionDirty) { + node->node->setRect(rect); + positionDirty = false; + } +} + +void ChartScatterItem::setPos(QPointF pos) +{ + pos -= QPointF(scatterItemDiameter / 2.0, scatterItemDiameter / 2.0); + rect.moveTopLeft(pos); + positionDirty = true; + markDirty(); +} + +static double squareDist(const QPointF &p1, const QPointF &p2) +{ + QPointF diff = p1 - p2; + return QPointF::dotProduct(diff, diff); +} + +bool ChartScatterItem::contains(QPointF point) const +{ + return squareDist(point, rect.center()) <= (scatterItemDiameter / 2.0) * (scatterItemDiameter / 2.0); +} + +void ChartScatterItem::setHighlight(bool highlightedIn) +{ + if (highlighted == highlightedIn) + return; + highlighted = highlightedIn; + textureDirty = true; + markDirty(); +} + +QRectF ChartScatterItem::getRect() const +{ + return rect; +} + +ChartRectItem::ChartRectItem(StatsView &v, ChartZValue z, + const QPen &pen, const QBrush &brush, double radius) : ChartPixmapItem(v, z), + pen(pen), brush(brush), radius(radius) +{ +} + +ChartRectItem::~ChartRectItem() +{ +} + +void ChartRectItem::resize(QSizeF size) +{ + ChartPixmapItem::resize(size); + img->fill(Qt::transparent); + painter->setPen(pen); + painter->setBrush(brush); + QSize imgSize = img->size(); + int width = pen.width(); + QRect rect(width / 2, width / 2, imgSize.width() - width, imgSize.height() - width); + painter->drawRoundedRect(rect, radius, radius, Qt::AbsoluteSize); +} + +ChartTextItem::ChartTextItem(StatsView &v, ChartZValue z, const QFont &f, const std::vector<QString> &text, bool center) : + ChartPixmapItem(v, z), f(f), center(center) +{ + QFontMetrics fm(f); + double totalWidth = 1.0; + fontHeight = static_cast<double>(fm.height()); + double totalHeight = std::max(1.0, static_cast<double>(text.size()) * fontHeight); + + items.reserve(text.size()); + for (const QString &s: text) { + double w = fm.size(Qt::TextSingleLine, s).width(); + items.push_back({ s, w }); + if (w > totalWidth) + totalWidth = w; + } + resize(QSizeF(totalWidth, totalHeight)); +} + +ChartTextItem::ChartTextItem(StatsView &v, ChartZValue z, const QFont &f, const QString &text) : + ChartTextItem(v, z, f, std::vector<QString>({ text }), true) +{ +} + +void ChartTextItem::setColor(const QColor &c) +{ + setColor(c, Qt::transparent); +} + +void ChartTextItem::setColor(const QColor &c, const QColor &background) +{ + img->fill(background); + double y = 0.0; + painter->setPen(QPen(c)); + painter->setFont(f); + double totalWidth = getRect().width(); + for (const auto &[s, w]: items) { + double x = center ? round((totalWidth - w) / 2.0) : 0.0; + QRectF rect(x, y, w, fontHeight); + painter->drawText(rect, s); + y += fontHeight; + } + setTextureDirty(); +} + +ChartPieItem::ChartPieItem(StatsView &v, ChartZValue z, double borderWidth) : ChartPixmapItem(v, z), + borderWidth(borderWidth) +{ +} + +void ChartPieItem::drawSegment(double from, double to, QColor fill, QColor border) +{ + painter->setPen(QPen(border, borderWidth)); + painter->setBrush(QBrush(fill)); + // For whatever obscure reason, angles of pie pieces are given as 16th of a degree...? + // Angles increase CCW, whereas pie charts usually are read CW. Therfore, startAngle + // is dervied from "from" and subtracted from the origin angle at 12:00. + int startAngle = 90 * 16 - static_cast<int>(round(to * 360.0 * 16.0)); + int spanAngle = static_cast<int>(round((to - from) * 360.0 * 16.0)); + QRectF drawRect(QPointF(0.0, 0.0), rect.size()); + painter->drawPie(drawRect, startAngle, spanAngle); + setTextureDirty(); +} + +void ChartPieItem::resize(QSizeF size) +{ + ChartPixmapItem::resize(size); + img->fill(Qt::transparent); +} + +ChartLineItem::ChartLineItem(StatsView &v, ChartZValue z, QColor color, double width) : HideableChartItem(v, z), + color(color), width(width), positionDirty(false), materialDirty(false) +{ +} + +ChartLineItem::~ChartLineItem() +{ +} + +// Helper function to set points +void setPoint(QSGGeometry::Point2D &v, const QPointF &p) +{ + v.set(static_cast<float>(p.x()), static_cast<float>(p.y())); +} + +void ChartLineItem::render() +{ + if (!node) { + geometry.reset(new QSGGeometry(QSGGeometry::defaultAttributes_Point2D(), 2)); + geometry->setDrawingMode(QSGGeometry::DrawLines); + material.reset(new QSGFlatColorMaterial); + createNode(); + node->setGeometry(geometry.get()); + node->setMaterial(material.get()); + view.addQSGNode(node.get(), zValue); + positionDirty = materialDirty = true; + } + updateVisible(); + + if (positionDirty) { + // Attention: width is a geometry property and therefore handled by position dirty! + geometry->setLineWidth(static_cast<float>(width)); + auto vertices = geometry->vertexDataAsPoint2D(); + setPoint(vertices[0], from); + setPoint(vertices[1], to); + node->markDirty(QSGNode::DirtyGeometry); + } + + if (materialDirty) { + material->setColor(color); + node->markDirty(QSGNode::DirtyMaterial); + } + + positionDirty = materialDirty = false; +} + +void ChartLineItem::setLine(QPointF fromIn, QPointF toIn) +{ + from = fromIn; + to = toIn; + positionDirty = true; + markDirty(); +} + +ChartBarItem::ChartBarItem(StatsView &v, ChartZValue z, double borderWidth, bool horizontal) : HideableChartItem(v, z), + borderWidth(borderWidth), horizontal(horizontal), + positionDirty(false), colorDirty(false) +{ +} + +ChartBarItem::~ChartBarItem() +{ +} + +void ChartBarItem::render() +{ + if (!node) { + createNode(view.w()->createRectangleNode()); + + borderGeometry.reset(new QSGGeometry(QSGGeometry::defaultAttributes_Point2D(), 4)); + borderGeometry->setDrawingMode(QSGGeometry::DrawLineLoop); + borderGeometry->setLineWidth(static_cast<float>(borderWidth)); + borderMaterial.reset(new QSGFlatColorMaterial); + borderNode.reset(new QSGGeometryNode); + borderNode->setGeometry(borderGeometry.get()); + borderNode->setMaterial(borderMaterial.get()); + + node->node->appendChildNode(borderNode.get()); + view.addQSGNode(node.get(), zValue); + positionDirty = colorDirty = true; + } + updateVisible(); + + if (colorDirty) { + node->node->setColor(color); + borderMaterial->setColor(borderColor); + node->node->markDirty(QSGNode::DirtyMaterial); + borderNode->markDirty(QSGNode::DirtyMaterial); + } + + if (positionDirty) { + node->node->setRect(rect); + auto vertices = borderGeometry->vertexDataAsPoint2D(); + if (horizontal) { + setPoint(vertices[0], rect.topLeft()); + setPoint(vertices[1], rect.topRight()); + setPoint(vertices[2], rect.bottomRight()); + setPoint(vertices[3], rect.bottomLeft()); + } else { + setPoint(vertices[0], rect.bottomLeft()); + setPoint(vertices[1], rect.topLeft()); + setPoint(vertices[2], rect.topRight()); + setPoint(vertices[3], rect.bottomRight()); + } + node->node->markDirty(QSGNode::DirtyGeometry); + borderNode->markDirty(QSGNode::DirtyGeometry); + } + + positionDirty = colorDirty = false; +} + +void ChartBarItem::setColor(QColor colorIn, QColor borderColorIn) +{ + color = colorIn; + borderColor = borderColorIn; + colorDirty = true; + markDirty(); +} + +void ChartBarItem::setRect(const QRectF &rectIn) +{ + rect = rectIn; + positionDirty = true; + markDirty(); +} + +QRectF ChartBarItem::getRect() const +{ + return rect; +} + +ChartBoxItem::ChartBoxItem(StatsView &v, ChartZValue z, double borderWidth) : + ChartBarItem(v, z, borderWidth, false) // Only support for vertical boxes +{ +} + +ChartBoxItem::~ChartBoxItem() +{ +} + +void ChartBoxItem::render() +{ + // Remember old dirty values, since ChartBarItem::render() will clear them + bool oldPositionDirty = positionDirty; + bool oldColorDirty = colorDirty; + ChartBarItem::render(); // This will create the base node, so no need to check for that. + if (!whiskersNode) { + whiskersGeometry.reset(new QSGGeometry(QSGGeometry::defaultAttributes_Point2D(), 10)); + whiskersGeometry->setDrawingMode(QSGGeometry::DrawLines); + whiskersGeometry->setLineWidth(static_cast<float>(borderWidth)); + whiskersMaterial.reset(new QSGFlatColorMaterial); + whiskersNode.reset(new QSGGeometryNode); + whiskersNode->setGeometry(whiskersGeometry.get()); + whiskersNode->setMaterial(whiskersMaterial.get()); + + node->node->appendChildNode(whiskersNode.get()); + // If this is the first time, make sure to update the geometry. + oldPositionDirty = oldColorDirty = true; + } + + if (oldColorDirty) { + whiskersMaterial->setColor(borderColor); + whiskersNode->markDirty(QSGNode::DirtyMaterial); + } + + if (oldPositionDirty) { + auto vertices = whiskersGeometry->vertexDataAsPoint2D(); + double left = rect.left(); + double right = rect.right(); + double mid = (left + right) / 2.0; + // top bar + setPoint(vertices[0], QPointF(left, max)); + setPoint(vertices[1], QPointF(right, max)); + // top whisker + setPoint(vertices[2], QPointF(mid, max)); + setPoint(vertices[3], QPointF(mid, rect.top())); + // bottom bar + setPoint(vertices[4], QPointF(left, min)); + setPoint(vertices[5], QPointF(right, min)); + // bottom whisker + setPoint(vertices[6], QPointF(mid, min)); + setPoint(vertices[7], QPointF(mid, rect.bottom())); + // median indicator + setPoint(vertices[8], QPointF(left, median)); + setPoint(vertices[9], QPointF(right, median)); + whiskersNode->markDirty(QSGNode::DirtyGeometry); + } +} + +void ChartBoxItem::setBox(const QRectF &rect, double minIn, double maxIn, double medianIn) +{ + min = minIn; + max = maxIn; + median = medianIn; + setRect(rect); +} + +QRectF ChartBoxItem::getRect() const +{ + QRectF res = rect; + res.setTop(min); + res.setBottom(max); + return rect; +} diff --git a/stats/chartitem.h b/stats/chartitem.h new file mode 100644 index 000000000..cf20f55a8 --- /dev/null +++ b/stats/chartitem.h @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: GPL-2.0 +// Wrappers around QSGImageNode that allow painting onto an image +// and then turning that into a texture to be displayed in a QQuickItem. +#ifndef CHART_ITEM_H +#define CHART_ITEM_H + +#include "statshelper.h" + +#include <memory> +#include <QPainter> + +class QSGGeometry; +class QSGGeometryNode; +class QSGFlatColorMaterial; +class QSGImageNode; +class QSGRectangleNode; +class QSGTexture; +class StatsView; +enum class ChartZValue : int; + +class ChartItem { +public: + virtual void render() = 0; // Only call on render thread! + bool dirty; // If true, call render() when rebuilding the scene + ChartItem *prev, *next; // Double linked list of items + const ChartZValue zValue; + virtual ~ChartItem(); // Attention: must only be called by render thread. +protected: + ChartItem(StatsView &v, ChartZValue z); + QSizeF sceneSize() const; + StatsView &view; + void markDirty(); +}; + +template <typename Node> +class HideableChartItem : public ChartItem { +protected: + HideableChartItem(StatsView &v, ChartZValue z); + std::unique_ptr<Node> node; + bool visible; + bool visibleChanged; + template<class... Args> + void createNode(Args&&... args); // Call to create node with visibility flag. + void updateVisible(); // Must be called by child class to update visibility flag! +public: + void setVisible(bool visible); +}; + +// A shortcut for ChartItems based on a hideable proxy item +template <typename Node> +using HideableChartProxyItem = HideableChartItem<HideableQSGNode<QSGProxyNode<Node>>>; + +// A chart item that blits a precalculated pixmap onto the scene. +class ChartPixmapItem : public HideableChartProxyItem<QSGImageNode> { +public: + ChartPixmapItem(StatsView &v, ChartZValue z); + ~ChartPixmapItem(); + + void setPos(QPointF pos); + void render() override; // Only call on render thread! + QRectF getRect() const; +protected: + void resize(QSizeF size); // Resets the canvas. Attention: image is *unitialized*. + std::unique_ptr<QPainter> painter; + std::unique_ptr<QImage> img; + void setTextureDirty(); + void setPositionDirty(); + QRectF rect; +private: + bool positionDirty; // true if the position changed since last render + bool textureDirty; // true if the pixmap changed since last render + std::unique_ptr<QSGTexture> texture; +}; + +// Draw a rectangular background after resize. Children are responsible for calling update(). +class ChartRectItem : public ChartPixmapItem { +public: + ChartRectItem(StatsView &v, ChartZValue z, const QPen &pen, const QBrush &brush, double radius); + ~ChartRectItem(); + void resize(QSizeF size); +private: + QPen pen; + QBrush brush; + double radius; +}; + +// Attention: text is only drawn after calling setColor()! +class ChartTextItem : public ChartPixmapItem { +public: + ChartTextItem(StatsView &v, ChartZValue z, const QFont &f, const std::vector<QString> &text, bool center); + ChartTextItem(StatsView &v, ChartZValue z, const QFont &f, const QString &text); + void setColor(const QColor &color); // Draw on transparent background + void setColor(const QColor &color, const QColor &background); // Fill rectangle with given background color +private: + QFont f; + double fontHeight; + bool center; + struct Item { + QString s; + double width; + }; + std::vector<Item> items; +}; + +// A pie chart item: draws disk segments onto a pixmap. +class ChartPieItem : public ChartPixmapItem { +public: + ChartPieItem(StatsView &v, ChartZValue z, double borderWidth); + void drawSegment(double from, double to, QColor fill, QColor border); // from and to are relative (0-1 is full disk). + void resize(QSizeF size); // As in base class, but clears the canvas +private: + double borderWidth; +}; + +class ChartLineItem : public HideableChartItem<HideableQSGNode<QSGGeometryNode>> { +public: + ChartLineItem(StatsView &v, ChartZValue z, QColor color, double width); + ~ChartLineItem(); + void setLine(QPointF from, QPointF to); + void render() override; // Only call on render thread! +private: + QPointF from, to; + QColor color; + double width; + bool horizontal; + bool positionDirty; + bool materialDirty; + std::unique_ptr<QSGFlatColorMaterial> material; + std::unique_ptr<QSGGeometry> geometry; +}; + +// A bar in a bar chart: a rectangle bordered by lines. +class ChartBarItem : public HideableChartProxyItem<QSGRectangleNode> { +public: + ChartBarItem(StatsView &v, ChartZValue z, double borderWidth, bool horizontal); + ~ChartBarItem(); + void setColor(QColor color, QColor borderColor); + void setRect(const QRectF &rect); + QRectF getRect() const; + void render() override; // Only call on render thread! +protected: + QColor color, borderColor; + double borderWidth; + QRectF rect; + bool horizontal; + bool positionDirty; + bool colorDirty; + std::unique_ptr<QSGGeometryNode> borderNode; + std::unique_ptr<QSGFlatColorMaterial> borderMaterial; + std::unique_ptr<QSGGeometry> borderGeometry; +}; + +// A box-and-whiskers item. This is a bit lazy: derive from the bar item and add whiskers. +class ChartBoxItem : public ChartBarItem { +public: + ChartBoxItem(StatsView &v, ChartZValue z, double borderWidth); + ~ChartBoxItem(); + void setBox(const QRectF &rect, double min, double max, double median); // The rect describes Q1, Q3. + QRectF getRect() const; // Note: this extends the center rectangle to include the whiskers. + void render() override; // Only call on render thread! +private: + double min, max, median; + std::unique_ptr<QSGGeometryNode> whiskersNode; + std::unique_ptr<QSGFlatColorMaterial> whiskersMaterial; + std::unique_ptr<QSGGeometry> whiskersGeometry; +}; + +// An item in a scatter chart. This is not simply a normal pixmap item, +// because we want that all items share the *same* texture for memory +// efficiency. It is somewhat questionable to define the form of the +// scatter item here, but so it is for now. +class ChartScatterItem : public HideableChartProxyItem<QSGImageNode> { +public: + ChartScatterItem(StatsView &v, ChartZValue z); + ~ChartScatterItem(); + + void setPos(QPointF pos); // Specifies the *center* of the item. + void setHighlight(bool highlight); // In the future, support different kinds of scatter items. + void render() override; // Only call on render thread! + QRectF getRect() const; + bool contains(QPointF point) const; +private: + QRectF rect; + QSizeF textureSize; + bool positionDirty, textureDirty; + bool highlighted; +}; + +// Implementation detail of templates - move to serparate header file +template <typename Node> +void HideableChartItem<Node>::setVisible(bool visibleIn) +{ + if (visible == visibleIn) + return; + visible = visibleIn; + visibleChanged = true; + markDirty(); +} + +template <typename Node> +template<class... Args> +void HideableChartItem<Node>::createNode(Args&&... args) +{ + node.reset(new Node(visible, std::forward<Args>(args)...)); + visibleChanged = false; +} + +template <typename Node> +HideableChartItem<Node>::HideableChartItem(StatsView &v, ChartZValue z) : ChartItem(v, z), + visible(true), visibleChanged(false) +{ +} + +template <typename Node> +void HideableChartItem<Node>::updateVisible() +{ + if (visibleChanged) + node->setVisible(visible); + visibleChanged = false; +} + +#endif diff --git a/stats/histogrammarker.cpp b/stats/histogrammarker.cpp new file mode 100644 index 000000000..e7b2512e3 --- /dev/null +++ b/stats/histogrammarker.cpp @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: GPL-2.0 +#include "histogrammarker.h" +#include "statsaxis.h" +#include "zvalues.h" + +static const double histogramMarkerWidth = 2.0; + +HistogramMarker::HistogramMarker(StatsView &view, double val, bool horizontal, + QColor color, StatsAxis *xAxis, StatsAxis *yAxis) : + ChartLineItem(view, ChartZValue::ChartFeatures, color, histogramMarkerWidth), + xAxis(xAxis), yAxis(yAxis), + val(val), horizontal(horizontal) +{ +} + +void HistogramMarker::updatePosition() +{ + if (!xAxis || !yAxis) + return; + if (horizontal) { + double y = yAxis->toScreen(val); + auto [x1, x2] = xAxis->minMaxScreen(); + setLine(QPointF(x1, y), QPointF(x2, y)); + } else { + double x = xAxis->toScreen(val); + auto [y1, y2] = yAxis->minMaxScreen(); + setLine(QPointF(x, y1), QPointF(x, y2)); + } +} diff --git a/stats/histogrammarker.h b/stats/histogrammarker.h new file mode 100644 index 000000000..14d6410bd --- /dev/null +++ b/stats/histogrammarker.h @@ -0,0 +1,21 @@ +// A line to show median an mean in histograms +#ifndef HISTOGRAM_MARKER_H +#define HISTOGRAM_MARKER_H + +#include "chartitem.h" + +class StatsAxis; +class StatsView; + +// A line marking median or mean in histograms +class HistogramMarker : public ChartLineItem { +public: + HistogramMarker(StatsView &view, double val, bool horizontal, QColor color, StatsAxis *xAxis, StatsAxis *yAxis); + void updatePosition(); +private: + StatsAxis *xAxis, *yAxis; + double val; + bool horizontal; +}; + +#endif diff --git a/stats/informationbox.cpp b/stats/informationbox.cpp index 189fd1ab1..29731acc6 100644 --- a/stats/informationbox.cpp +++ b/stats/informationbox.cpp @@ -1,84 +1,74 @@ #include "informationbox.h" #include "statscolors.h" +#include "statsview.h" #include "zvalues.h" #include <QFontMetrics> -#include <QGraphicsScene> -static const QColor informationBorderColor(Qt::black); -static const QColor informationColor(0xff, 0xff, 0x00, 192); // Note: fourth argument is opacity static const int informationBorder = 2; static const double informationBorderRadius = 4.0; // Radius of rounded corners static const int distanceFromPointer = 10; // Distance to place box from mouse pointer or scatter item -InformationBox::InformationBox() : RoundRectItem(informationBorderRadius, nullptr) +InformationBox::InformationBox(StatsView &v) : + ChartRectItem(v, ChartZValue::InformationBox, + QPen(informationBorderColor, informationBorder), + QBrush(informationColor), informationBorderRadius) { - setPen(QPen(informationBorderColor, informationBorder)); - setBrush(informationColor); - setZValue(ZValues::informationBox); } void InformationBox::setText(const std::vector<QString> &text, QPointF pos) { - width = height = 0.0; - textItems.clear(); + QFontMetrics fm(font); + double fontHeight = fm.height(); + std::vector<double> widths; + widths.reserve(text.size()); + width = 0.0; for (const QString &s: text) { - if (!s.isEmpty()) - addLine(s); + widths.push_back(static_cast<double>(fm.size(Qt::TextSingleLine, s).width())); + width = std::max(width, widths.back()); } width += 4.0 * informationBorder; - height += 4.0 * informationBorder; + height = widths.size() * fontHeight + 4.0 * informationBorder; + + ChartRectItem::resize(QSizeF(width, height)); - // Setting the position will also set the proper size - setPos(pos); + painter->setPen(QPen(darkLabelColor)); // QPainter uses QPen to set text color! + double y = 2.0 * informationBorder; + for (size_t i = 0; i < widths.size(); ++i) { + QRectF rect(2.0 * informationBorder, y, widths[i], fontHeight); + painter->drawText(rect, text[i]); + y += fontHeight; + } } void InformationBox::setPos(QPointF pos) { - QRectF plotArea = scene()->sceneRect(); + QSizeF size = sceneSize(); double x = pos.x() + distanceFromPointer; - if (x + width >= plotArea.right()) { - if (pos.x() - width >= plotArea.x()) + if (x + width >= size.width()) { + if (pos.x() - width >= 0.0) x = pos.x() - width; else x = pos.x() - width / 2.0; } double y = pos.y() + distanceFromPointer; - if (y + height >= plotArea.bottom()) { - if (pos.y() - height >= plotArea.y()) + if (y + height >= size.height()) { + if (pos.y() - height >= 0.0) y = pos.y() - height; else y = pos.y() - height / 2.0; } - setRect(x, y, width, height); - double actY = y + 2.0 * informationBorder; - for (auto &item: textItems) { - item->setPos(QPointF(x + 2.0 * informationBorder, actY)); - actY += item->boundingRect().height(); - } -} - -void InformationBox::addLine(const QString &s) -{ - textItems.emplace_back(new QGraphicsSimpleTextItem(s, this)); - QGraphicsSimpleTextItem &item = *textItems.back(); - item.setBrush(QBrush(darkLabelColor)); - item.setPos(QPointF(0.0, height)); - item.setFont(font); - item.setZValue(ZValues::informationBox); - QRectF rect = item.boundingRect(); - width = std::max(width, rect.width()); - height += rect.height(); + ChartRectItem::setPos(QPointF(x, y)); } // Try to stay within three-thirds of the chart height int InformationBox::recommendedMaxLines() const { QFontMetrics fm(font); - int maxHeight = static_cast<int>(scene()->sceneRect().height()); + int maxHeight = static_cast<int>(sceneSize().height()); return maxHeight * 2 / fm.height() / 3; } diff --git a/stats/informationbox.h b/stats/informationbox.h index 741df537f..6ff2bb43e 100644 --- a/stats/informationbox.h +++ b/stats/informationbox.h @@ -4,26 +4,24 @@ #ifndef INFORMATION_BOX_H #define INFORMATION_BOX_H -#include "backend-shared/roundrectitem.h" +#include "chartitem.h" #include <vector> #include <memory> #include <QFont> struct dive; -class QGraphicsScene; +class StatsView; // Information window showing data of highlighted dive -struct InformationBox : RoundRectItem { - InformationBox(); +struct InformationBox : ChartRectItem { + InformationBox(StatsView &); void setText(const std::vector<QString> &text, QPointF pos); void setPos(QPointF pos); int recommendedMaxLines() const; private: QFont font; // For future specialization. double width, height; - void addLine(const QString &s); - std::vector<std::unique_ptr<QGraphicsSimpleTextItem>> textItems; }; #endif diff --git a/stats/legend.cpp b/stats/legend.cpp index 27607fb51..fc8656828 100644 --- a/stats/legend.cpp +++ b/stats/legend.cpp @@ -3,9 +3,8 @@ #include "statscolors.h" #include "zvalues.h" +#include <cmath> #include <QFontMetrics> -#include <QGraphicsScene> -#include <QGraphicsSceneMouseEvent> #include <QPen> static const double legendBorderSize = 2.0; @@ -13,54 +12,33 @@ static const double legendBoxBorderSize = 1.0; static const double legendBoxBorderRadius = 4.0; // radius of rounded corners static const double legendBoxScale = 0.8; // 1.0: text-height of the used font static const double legendInternalBorderSize = 2.0; -static const QColor legendColor(0x00, 0x8e, 0xcc, 192); // Note: fourth argument is opacity -static const QColor legendBorderColor(Qt::black); -Legend::Legend(const std::vector<QString> &names) : - RoundRectItem(legendBoxBorderRadius), - displayedItems(0), width(0.0), height(0.0) +Legend::Legend(StatsView &view, const std::vector<QString> &names) : + ChartRectItem(view, ChartZValue::Legend, + QPen(legendBorderColor, legendBorderSize), QBrush(legendColor), legendBoxBorderRadius), + displayedItems(0), width(0.0), height(0.0), + font(QFont()), // Make configurable + posInitialized(false) { - setZValue(ZValues::legend); entries.reserve(names.size()); + QFontMetrics fm(font); + fontHeight = fm.height(); int idx = 0; for (const QString &name: names) - entries.emplace_back(name, idx++, (int)names.size(), this); - - // Calculate the height and width of the elements - if (!entries.empty()) { - QFontMetrics fm(entries[0].text->font()); - fontHeight = fm.height(); - for (Entry &e: entries) - e.width = fontHeight + 2.0 * legendBoxBorderSize + - fm.size(Qt::TextSingleLine, e.text->text()).width(); - } else { - // Set to an arbitrary non-zero value, because Coverity doesn't understand - // that we don't use the value as divisor below if entries is empty. - fontHeight = 10.0; - } - setPen(QPen(legendBorderColor, legendBorderSize)); - setBrush(QBrush(legendColor)); + entries.emplace_back(name, idx++, (int)names.size(), fm); } -Legend::Entry::Entry(const QString &name, int idx, int numBins, QGraphicsItem *parent) : - rect(new QGraphicsRectItem(parent)), - text(new QGraphicsSimpleTextItem(name, parent)), - width(0) +Legend::Entry::Entry(const QString &name, int idx, int numBins, const QFontMetrics &fm) : + name(name), + rectBrush(QBrush(binColor(idx, numBins))) { - rect->setZValue(ZValues::legend); - rect->setPen(QPen(legendBorderColor, legendBoxBorderSize)); - rect->setBrush(QBrush(binColor(idx, numBins))); - text->setZValue(ZValues::legend); - text->setBrush(QBrush(darkLabelColor)); + width = fm.height() + 2.0 * legendBoxBorderSize + fm.size(Qt::TextSingleLine, name).width(); } void Legend::hide() { - for (Entry &e: entries) { - e.rect->hide(); - e.text->hide(); - } - QGraphicsRectItem::hide(); + ChartRectItem::resize(QSizeF(1,1)); + img->fill(Qt::transparent); } void Legend::resize() @@ -68,7 +46,7 @@ void Legend::resize() if (entries.empty()) return hide(); - QSizeF size = scene()->sceneRect().size(); + QSizeF size = sceneSize(); // Silly heuristics: make the legend at most half as high and half as wide as the chart. // Not sure if that makes sense - this might need some optimization. @@ -100,31 +78,63 @@ void Legend::resize() } width += legendInternalBorderSize; height = 2 * legendInternalBorderSize + numRows * fontHeight; - updatePosition(); -} -void Legend::updatePosition() -{ - if (displayedItems <= 0) - return hide(); - // For now, place the legend in the top right corner. - QPointF pos(scene()->sceneRect().width() - width - 10.0, 10.0); - setRect(QRectF(pos, QSizeF(width, height))); + ChartRectItem::resize(QSizeF(width, height)); + + // Paint rectangles + painter->setPen(QPen(legendBorderColor, legendBoxBorderSize)); for (int i = 0; i < displayedItems; ++i) { - QPointF itemPos = pos + entries[i].pos; + QPointF itemPos = entries[i].pos; + painter->setBrush(entries[i].rectBrush); QRectF rect(itemPos, QSizeF(fontHeight, fontHeight)); // Decrease box size by legendBoxScale factor double delta = fontHeight * (1.0 - legendBoxScale) / 2.0; rect = rect.adjusted(delta, delta, -delta, -delta); - entries[i].rect->setRect(rect); + painter->drawRect(rect); + } + + // Paint labels + painter->setPen(darkLabelColor); // QPainter uses pen not brush for text! + painter->setFont(font); + for (int i = 0; i < displayedItems; ++i) { + QPointF itemPos = entries[i].pos; itemPos.rx() += fontHeight + 2.0 * legendBoxBorderSize; - entries[i].text->setPos(itemPos); - entries[i].rect->show(); - entries[i].text->show(); + QRectF rect(itemPos, QSizeF(entries[i].width, fontHeight)); + painter->drawText(rect, entries[i].name); } - for (int i = displayedItems; i < (int)entries.size(); ++i) { - entries[i].rect->hide(); - entries[i].text->hide(); + + if (!posInitialized) { + // At first, place in top right corner + setPos(QPointF(size.width() - width - 10.0, 10.0)); + posInitialized = true; + } else { + // Try to keep relative position with what it was before + setPos(QPointF(size.width() * centerPos.x() - width / 2.0, + size.height() * centerPos.y() - height / 2.0)); } - show(); +} + +void Legend::setPos(QPointF newPos) +{ + // Round the position to integers or horrible artifacts appear (at least on desktop) + QPointF posInt(round(newPos.x()), round(newPos.y())); + + // Make sure that the center is inside the drawing area, + // so that the user can't lose the legend. + QSizeF size = sceneSize(); + if (size.width() < 1.0 || size.height() < 1.0) + return; + double widthHalf = floor(width / 2.0); + double heightHalf = floor(height / 2.0); + QPointF sanitizedPos(std::clamp(posInt.x(), -widthHalf, size.width() - widthHalf - 1.0), + std::clamp(posInt.y(), -heightHalf, size.height() - heightHalf - 1.0)); + + // Set position + ChartRectItem::setPos(sanitizedPos); + + // Remember relative position of center for next time + QPointF centerPosAbsolute(sanitizedPos.x() + width / 2.0, + sanitizedPos.y() + height / 2.0); + centerPos = QPointF(centerPosAbsolute.x() / size.width(), + centerPosAbsolute.y() / size.height()); } diff --git a/stats/legend.h b/stats/legend.h index c643a41f3..fb88920bd 100644 --- a/stats/legend.h +++ b/stats/legend.h @@ -3,34 +3,38 @@ #ifndef STATS_LEGEND_H #define STATS_LEGEND_H -#include "backend-shared/roundrectitem.h" +#include "chartitem.h" #include <memory> #include <vector> +#include <QFont> -class QGraphicsScene; -class QGraphicsSceneMouseEvent; +class QFontMetrics; -class Legend : public RoundRectItem { +class Legend : public ChartRectItem { public: - Legend(const std::vector<QString> &names); - void hover(QPointF pos); + Legend(StatsView &view, const std::vector<QString> &names); void resize(); // called when the chart size changes. + void setPos(QPointF pos); // Attention: not virtual - always call on this class. private: // Each entry is a text besides a rectangle showing the color struct Entry { - std::unique_ptr<QGraphicsRectItem> rect; - std::unique_ptr<QGraphicsSimpleTextItem> text; + QString name; + QBrush rectBrush; QPointF pos; double width; - Entry(const QString &name, int idx, int numBins, QGraphicsItem *parent); + Entry(const QString &name, int idx, int numBins, const QFontMetrics &fm); }; int displayedItems; double width; double height; + QFont font; + // The position is specified with respect to the center and in relative terms + // with respect to the canvas. + QPointF centerPos; + bool posInitialized; int fontHeight; std::vector<Entry> entries; - void updatePosition(); void hide(); }; diff --git a/stats/pieseries.cpp b/stats/pieseries.cpp index eeecac36c..8db3bdbe3 100644 --- a/stats/pieseries.cpp +++ b/stats/pieseries.cpp @@ -4,11 +4,11 @@ #include "statscolors.h" #include "statshelper.h" #include "statstranslations.h" +#include "statsview.h" #include "zvalues.h" #include <numeric> #include <math.h> -#include <QGraphicsEllipseItem> #include <QLocale> static const double pieSize = 0.9; // 1.0 = occupy full width of chart @@ -16,49 +16,39 @@ static const double pieBorderWidth = 1.0; static const double innerLabelRadius = 0.75; // 1.0 = at outer border of pie static const double outerLabelRadius = 1.01; // 1.0 = at outer border of pie -PieSeries::Item::Item(QGraphicsScene *scene, const QString &name, int from, int count, int totalCount, - int bin_nr, int numBins, bool labels) : - item(createItemPtr<QGraphicsEllipseItem>(scene)), +PieSeries::Item::Item(StatsView &view, const QString &name, int from, int count, int totalCount, + int bin_nr, int numBins) : name(name), count(count) { + QFont f; // make configurable QLocale loc; - // For whatever obscure reason, angles in QGraphicsEllipseItem are given as 16th of a degree...? - // Angles increase CCW, whereas pie charts usually are read CW. - item->setStartAngle(90 * 16 - (from + count) * 360 * 16 / totalCount); - item->setSpanAngle(count * 360 * 16 / totalCount); - item->setPen(QPen(::borderColor)); - item->setZValue(ZValues::series); + angleFrom = static_cast<double>(from) / totalCount; angleTo = static_cast<double>(from + count) / totalCount; double meanAngle = M_PI / 2.0 - (from + count / 2.0) / totalCount * M_PI * 2.0; // Note: "-" because we go CW. innerLabelPos = QPointF(cos(meanAngle) * innerLabelRadius, -sin(meanAngle) * innerLabelRadius); outerLabelPos = QPointF(cos(meanAngle) * outerLabelRadius, -sin(meanAngle) * outerLabelRadius); - if (labels) { - double percentage = count * 100.0 / totalCount; - QString innerLabelText = QStringLiteral("%1\%").arg(loc.toString(percentage, 'f', 1)); - innerLabel = createItemPtr<QGraphicsSimpleTextItem>(scene, innerLabelText); - innerLabel->setZValue(ZValues::seriesLabels); + double percentage = count * 100.0 / totalCount; + QString innerLabelText = QStringLiteral("%1\%").arg(loc.toString(percentage, 'f', 1)); + innerLabel = view.createChartItem<ChartTextItem>(ChartZValue::SeriesLabels, f, innerLabelText); - outerLabel = createItemPtr<QGraphicsSimpleTextItem>(scene, name); - outerLabel->setBrush(QBrush(darkLabelColor)); - outerLabel->setZValue(ZValues::seriesLabels); - } - - highlight(bin_nr, false, numBins); + outerLabel = view.createChartItem<ChartTextItem>(ChartZValue::SeriesLabels, f, name); + outerLabel->setColor(darkLabelColor); } -void PieSeries::Item::updatePositions(const QRectF &rect, const QPointF ¢er, double radius) +void PieSeries::Item::updatePositions(const QPointF ¢er, double radius) { - item->setRect(rect); + // Note: the positions in this functions are rounded to integer values, + // because half-integer values gives horrible aliasing artifacts. if (innerLabel) { - QRectF labelRect = innerLabel->boundingRect(); - innerLabel->setPos(center.x() + innerLabelPos.x() * radius - labelRect.width() / 2.0, - center.y() + innerLabelPos.y() * radius - labelRect.height() / 2.0); + QRectF labelRect = innerLabel->getRect(); + innerLabel->setPos(QPointF(round(center.x() + innerLabelPos.x() * radius - labelRect.width() / 2.0), + round(center.y() + innerLabelPos.y() * radius - labelRect.height() / 2.0))); } if (outerLabel) { - QRectF labelRect = outerLabel->boundingRect(); + QRectF labelRect = outerLabel->getRect(); QPointF pos(center.x() + outerLabelPos.x() * radius, center.y() + outerLabelPos.y() * radius); if (outerLabelPos.x() < 0.0) { if (outerLabelPos.y() < 0.0) @@ -69,25 +59,23 @@ void PieSeries::Item::updatePositions(const QRectF &rect, const QPointF ¢er, pos.ry() -= labelRect.height(); } - outerLabel->setPos(pos); + outerLabel->setPos(QPointF(round(pos.x()), round(pos.y()))); } } -void PieSeries::Item::highlight(int bin_nr, bool highlight, int numBins) +void PieSeries::Item::highlight(ChartPieItem &item, int bin_nr, bool highlight, int numBins) { - QBrush brush(highlight ? highlightedColor : binColor(bin_nr, numBins)); - QPen pen(highlight ? highlightedBorderColor : ::borderColor, pieBorderWidth); - item->setBrush(brush); - item->setPen(pen); - if (innerLabel) { - QBrush labelBrush(highlight ? darkLabelColor : labelColor(bin_nr, numBins)); - innerLabel->setBrush(labelBrush); - } + QColor fill = highlight ? highlightedColor : binColor(bin_nr, numBins); + QColor border = highlight ? highlightedBorderColor : ::borderColor; + if (innerLabel) + innerLabel->setColor(highlight ? darkLabelColor : labelColor(bin_nr, numBins), fill); + item.drawSegment(angleFrom, angleTo, fill, border); } -PieSeries::PieSeries(QGraphicsScene *scene, StatsAxis *xAxis, StatsAxis *yAxis, const QString &categoryName, - const std::vector<std::pair<QString, int>> &data, bool keepOrder, bool labels) : - StatsSeries(scene, xAxis, yAxis), +PieSeries::PieSeries(StatsView &view, StatsAxis *xAxis, StatsAxis *yAxis, const QString &categoryName, + const std::vector<std::pair<QString, int>> &data, bool keepOrder) : + StatsSeries(view, xAxis, yAxis), + item(view.createChartItem<ChartPieItem>(ChartZValue::Series, pieBorderWidth)), categoryName(categoryName), highlighted(-1) { @@ -147,7 +135,7 @@ PieSeries::PieSeries(QGraphicsScene *scene, StatsAxis *xAxis, StatsAxis *yAxis, int act = 0; for (auto it2 = sorted.begin(); it2 != it; ++it2) { int count = data[*it2].second; - items.emplace_back(scene, data[*it2].first, act, count, totalCount, (int)items.size(), numBins, labels); + items.emplace_back(view, data[*it2].first, act, count, totalCount, (int)items.size(), numBins); act += count; } @@ -157,7 +145,7 @@ PieSeries::PieSeries(QGraphicsScene *scene, StatsAxis *xAxis, StatsAxis *yAxis, for (auto it2 = it; it2 != sorted.end(); ++it2) other.push_back({ data[*it2].first, data[*it2].second }); QString name = StatsTranslations::tr("other (%1 items)").arg(other.size()); - items.emplace_back(scene, name, act, totalCount - act, totalCount, (int)items.size(), numBins, labels); + items.emplace_back(view, name, act, totalCount - act, totalCount, (int)items.size(), numBins); } } @@ -167,12 +155,18 @@ PieSeries::~PieSeries() void PieSeries::updatePositions() { - QRectF plotRect = scene->sceneRect(); + QRectF plotRect = view.plotArea(); center = plotRect.center(); - radius = std::min(plotRect.width(), plotRect.height()) * pieSize / 2.0; - QRectF rect(center.x() - radius, center.y() - radius, 2.0 * radius, 2.0 * radius); - for (Item &item: items) - item.updatePositions(rect, center, radius); + radius = ceil(std::min(plotRect.width(), plotRect.height()) * pieSize / 2.0); + QRectF rect(round(center.x() - radius), round(center.y() - radius), ceil(2.0 * radius), ceil(2.0 * radius)); + item->resize(rect.size()); + item->setPos(rect.topLeft()); + int i = 0; + for (Item &segment: items) { + segment.updatePositions(center, radius); + segment.highlight(*item, i, i == highlighted, (int)items.size()); // Draw segment + ++i; + } } std::vector<QString> PieSeries::binNames() @@ -244,12 +238,13 @@ bool PieSeries::hover(QPointF pos) // Highlight new item (if any) if (highlighted >= 0 && highlighted < (int)items.size()) { - items[highlighted].highlight(highlighted, true, (int)items.size()); + items[highlighted].highlight(*item, highlighted, true, (int)items.size()); if (!information) - information = createItemPtr<InformationBox>(scene); + information = view.createChartItem<InformationBox>(); information->setText(makeInfo(highlighted), pos); + information->setVisible(true); } else { - information.reset(); + information->setVisible(false); } return highlighted >= 0; } @@ -257,6 +252,6 @@ bool PieSeries::hover(QPointF pos) void PieSeries::unhighlight() { if (highlighted >= 0 && highlighted < (int)items.size()) - items[highlighted].highlight(highlighted, false, (int)items.size()); + items[highlighted].highlight(*item, highlighted, false, (int)items.size()); highlighted = -1; } diff --git a/stats/pieseries.h b/stats/pieseries.h index 646c4bfbe..0cb5e12cb 100644 --- a/stats/pieseries.h +++ b/stats/pieseries.h @@ -3,6 +3,7 @@ #ifndef PIE_SERIES_H #define PIE_SERIES_H +#include "statshelper.h" #include "statsseries.h" #include <memory> @@ -10,9 +11,8 @@ #include <QString> struct InformationBox; -class QGraphicsEllipseItem; -class QGraphicsScene; -class QGraphicsSimpleTextItem; +struct ChartPieItem; +struct ChartTextItem; class QRectF; class PieSeries : public StatsSeries { @@ -20,8 +20,8 @@ public: // The pie series is initialized with (name, count) pairs. // If keepOrder is false, bins will be sorted by size, otherwise the sorting // of the shown bins will be retained. Small bins are omitted for clarity. - PieSeries(QGraphicsScene *scene, StatsAxis *xAxis, StatsAxis *yAxis, const QString &categoryName, - const std::vector<std::pair<QString, int>> &data, bool keepOrder, bool labels); + PieSeries(StatsView &view, StatsAxis *xAxis, StatsAxis *yAxis, const QString &categoryName, + const std::vector<std::pair<QString, int>> &data, bool keepOrder); ~PieSeries(); void updatePositions() override; @@ -34,20 +34,20 @@ private: // Get item under mouse pointer, or -1 if none int getItemUnderMouse(const QPointF &f) const; + ChartItemPtr<ChartPieItem> item; QString categoryName; std::vector<QString> makeInfo(int idx) const; struct Item { - std::unique_ptr<QGraphicsEllipseItem> item; - std::unique_ptr<QGraphicsSimpleTextItem> innerLabel, outerLabel; + ChartItemPtr<ChartTextItem> innerLabel, outerLabel; QString name; - double angleTo; // In fraction of total + double angleFrom, angleTo; // In fraction of total int count; QPointF innerLabelPos, outerLabelPos; // With respect to a (-1, -1)-(1, 1) rectangle. - Item(QGraphicsScene *scene, const QString &name, int from, int count, int totalCount, - int bin_nr, int numBins, bool labels); - void updatePositions(const QRectF &rect, const QPointF ¢er, double radius); - void highlight(int bin_nr, bool highlight, int numBins); + Item(StatsView &view, const QString &name, int from, int count, int totalCount, + int bin_nr, int numBins); + void updatePositions(const QPointF ¢er, double radius); + void highlight(ChartPieItem &item, int bin_nr, bool highlight, int numBins); }; std::vector<Item> items; int totalCount; @@ -59,7 +59,7 @@ private: }; std::vector<OtherItem> other; - std::unique_ptr<InformationBox> information; + ChartItemPtr<InformationBox> information; QPointF center; // center of drawing area double radius; // radius of pie int highlighted; diff --git a/stats/quartilemarker.cpp b/stats/quartilemarker.cpp new file mode 100644 index 000000000..611737d76 --- /dev/null +++ b/stats/quartilemarker.cpp @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-2.0 +#include "quartilemarker.h" +#include "statscolors.h" +#include "statsaxis.h" +#include "zvalues.h" + +static const double quartileMarkerSize = 15.0; + +QuartileMarker::QuartileMarker(StatsView &view, double pos, double value, StatsAxis *xAxis, StatsAxis *yAxis) : + ChartLineItem(view, ChartZValue::ChartFeatures, quartileMarkerColor, 2.0), + xAxis(xAxis), yAxis(yAxis), + pos(pos), + value(value) +{ + updatePosition(); +} + +QuartileMarker::~QuartileMarker() +{ +} + +void QuartileMarker::updatePosition() +{ + if (!xAxis || !yAxis) + return; + double x = xAxis->toScreen(pos); + double y = yAxis->toScreen(value); + setLine(QPointF(x - quartileMarkerSize / 2.0, y), + QPointF(x + quartileMarkerSize / 2.0, y)); +} diff --git a/stats/quartilemarker.h b/stats/quartilemarker.h new file mode 100644 index 000000000..2e754248d --- /dev/null +++ b/stats/quartilemarker.h @@ -0,0 +1,20 @@ +// A short line used to mark quartiles +#ifndef QUARTILE_MARKER_H +#define QUARTILE_MARKER_H + +#include "chartitem.h" + +class StatsAxis; +class StatsView; + +class QuartileMarker : public ChartLineItem { +public: + QuartileMarker(StatsView &view, double pos, double value, StatsAxis *xAxis, StatsAxis *yAxis); + ~QuartileMarker(); + void updatePosition(); +private: + StatsAxis *xAxis, *yAxis; + double pos, value; +}; + +#endif diff --git a/stats/regressionitem.cpp b/stats/regressionitem.cpp new file mode 100644 index 000000000..9f176c6ae --- /dev/null +++ b/stats/regressionitem.cpp @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: GPL-2.0 +#include "regressionitem.h" +#include "statsaxis.h" +#include "statscolors.h" +#include "zvalues.h" + +#include <cmath> + +static const double regressionLineWidth = 2.0; + +RegressionItem::RegressionItem(StatsView &view, regression_data reg, + StatsAxis *xAxis, StatsAxis *yAxis) : + ChartPixmapItem(view, ChartZValue::ChartFeatures), + xAxis(xAxis), yAxis(yAxis), reg(reg), + regression(true), confidence(true) +{ +} + +RegressionItem::~RegressionItem() +{ +} + +void RegressionItem::setFeatures(bool regressionIn, bool confidenceIn) +{ + if (regressionIn == regression && confidenceIn == confidence) + return; + regression = regressionIn; + confidence = confidenceIn; + updatePosition(); +} + +// Note: this calculates the confidence area, even if it isn't shown. Might want to optimize this. +void RegressionItem::updatePosition() +{ + if (!xAxis || !yAxis) + return; + auto [minX, maxX] = xAxis->minMax(); + auto [minY, maxY] = yAxis->minMax(); + auto [screenMinX, screenMaxX] = xAxis->minMaxScreen(); + + // Draw the confidence interval according to http://www2.stat.duke.edu/~tjl13/s101/slides/unit6lec3H.pdf p.5 with t*=2 for 95% confidence + QPolygonF poly; + const int num_samples = 101; + poly.reserve(num_samples * 2); + for (int i = 0; i < num_samples; ++i) { + double x = (maxX - minX) / (num_samples - 1) * static_cast<double>(i) + minX; + poly << QPointF(xAxis->toScreen(x), + yAxis->toScreen(reg.a * x + reg.b + 1.960 * sqrt(reg.res2 / (reg.n - 2) * (1.0 / reg.n + (x - reg.xavg) * (x - reg.xavg) / (reg.n - 1) * (reg.n -2) / reg.sx2)))); + } + for (int i = num_samples - 1; i >= 0; --i) { + double x = (maxX - minX) / (num_samples - 1) * static_cast<double>(i) + minX; + poly << QPointF(xAxis->toScreen(x), + yAxis->toScreen(reg.a * x + reg.b - 1.960 * sqrt(reg.res2 / (reg.n - 2) * (1.0 / reg.n + (x - reg.xavg) * (x - reg.xavg) / (reg.n - 1) * (reg.n -2) / reg.sx2)))); + } + QPolygonF linePolygon; + linePolygon.reserve(2); + linePolygon << QPointF(screenMinX, yAxis->toScreen(reg.a * minX + reg.b)); + linePolygon << QPointF(screenMaxX, yAxis->toScreen(reg.a * maxX + reg.b)); + + QRectF box(QPointF(screenMinX, yAxis->toScreen(minY)), QPointF(screenMaxX, yAxis->toScreen(maxY))); + + poly = poly.intersected(box); + linePolygon = linePolygon.intersected(box); + if (poly.size() < 2 || linePolygon.size() < 2) + return; + + // Find lowest and highest point on screen. In principle, we need + // only check half of the polygon, but let's not optimize without reason. + double screenMinY = std::numeric_limits<double>::max(); + double screenMaxY = std::numeric_limits<double>::lowest(); + for (const QPointF &point: poly) { + double y = point.y(); + if (y < screenMinY) + screenMinY = y; + if (y > screenMaxY) + screenMaxY = y; + } + screenMinY = floor(screenMinY - 1.0); + screenMaxY = ceil(screenMaxY + 1.0); + QPointF offset(screenMinX, screenMinY); + for (QPointF &point: poly) + point -= offset; + for (QPointF &point: linePolygon) + point -= offset; + ChartPixmapItem::resize(QSizeF(screenMaxX - screenMinX, screenMaxY - screenMinY)); + + img->fill(Qt::transparent); + if (confidence) { + QColor col(regressionItemColor); + col.setAlphaF(reg.r2); + painter->setPen(Qt::NoPen); + painter->setBrush(QBrush(col)); + painter->drawPolygon(poly); + } + + if (regression) { + painter->setPen(QPen(regressionItemColor, regressionLineWidth)); + painter->drawLine(QPointF(linePolygon[0]), QPointF(linePolygon[1])); + } + + ChartPixmapItem::setPos(offset); +} diff --git a/stats/regressionitem.h b/stats/regressionitem.h new file mode 100644 index 000000000..24141122c --- /dev/null +++ b/stats/regressionitem.h @@ -0,0 +1,28 @@ +// A regression line and confidence area +#ifndef REGRESSION_H +#define REGRESSION_H + +#include "chartitem.h" + +class StatsAxis; +class StatsView; + +struct regression_data { + double a,b; + double res2, r2, sx2, xavg; + int n; +}; + +class RegressionItem : public ChartPixmapItem { +public: + RegressionItem(StatsView &view, regression_data data, StatsAxis *xAxis, StatsAxis *yAxis); + ~RegressionItem(); + void updatePosition(); + void setFeatures(bool regression, bool confidence); +private: + StatsAxis *xAxis, *yAxis; + regression_data reg; + bool regression, confidence; +}; + +#endif diff --git a/stats/scatterseries.cpp b/stats/scatterseries.cpp index 8e2399008..791bb81ca 100644 --- a/stats/scatterseries.cpp +++ b/stats/scatterseries.cpp @@ -1,24 +1,20 @@ // SPDX-License-Identifier: GPL-2.0 #include "scatterseries.h" +#include "chartitem.h" #include "informationbox.h" #include "statscolors.h" #include "statshelper.h" #include "statstranslations.h" #include "statsvariables.h" +#include "statsview.h" #include "zvalues.h" #include "core/dive.h" #include "core/divelist.h" #include "core/qthelper.h" -#include <QGraphicsPixmapItem> -#include <QPainter> - -static const int scatterItemDiameter = 10; -static const int scatterItemBorder = 1; - -ScatterSeries::ScatterSeries(QGraphicsScene *scene, StatsAxis *xAxis, StatsAxis *yAxis, +ScatterSeries::ScatterSeries(StatsView &view, StatsAxis *xAxis, StatsAxis *yAxis, const StatsVariable &varX, const StatsVariable &varY) : - StatsSeries(scene, xAxis, yAxis), + StatsSeries(view, xAxis, yAxis), varX(varX), varY(varY) { } @@ -27,62 +23,28 @@ ScatterSeries::~ScatterSeries() { } -static QPixmap createScatterPixmap(const QColor &color, const QColor &borderColor) -{ - QPixmap res(scatterItemDiameter, scatterItemDiameter); - res.fill(Qt::transparent); - QPainter painter(&res); - painter.setPen(Qt::NoPen); - painter.setRenderHint(QPainter::Antialiasing); - painter.setBrush(borderColor); - painter.drawEllipse(0, 0, scatterItemDiameter, scatterItemDiameter); - painter.setBrush(color); - painter.drawEllipse(scatterItemBorder, scatterItemBorder, - scatterItemDiameter - 2 * scatterItemBorder, - scatterItemDiameter - 2 * scatterItemBorder); - return res; -} - -// Annoying: we can create a QPixmap only after the application was initialized. -// Therefore, do this as a on-demand initialized pointer. A function local static -// variable does unnecesssary (in this case) thread synchronization. -static std::unique_ptr<QPixmap> scatterPixmapPtr; -static std::unique_ptr<QPixmap> scatterPixmapHighlightedPtr; - -static const QPixmap &scatterPixmap(bool highlight) -{ - if (!scatterPixmapPtr) { - scatterPixmapPtr.reset(new QPixmap(createScatterPixmap(fillColor, ::borderColor))); - scatterPixmapHighlightedPtr.reset(new QPixmap(createScatterPixmap(highlightedColor, highlightedBorderColor))); - } - return highlight ? *scatterPixmapHighlightedPtr : *scatterPixmapPtr; -} - -ScatterSeries::Item::Item(QGraphicsScene *scene, ScatterSeries *series, dive *d, double pos, double value) : - item(createItemPtr<QGraphicsPixmapItem>(scene, scatterPixmap(false))), +ScatterSeries::Item::Item(StatsView &view, ScatterSeries *series, dive *d, double pos, double value) : + item(view.createChartItem<ChartScatterItem>(ChartZValue::Series)), d(d), pos(pos), value(value) { - item->setZValue(ZValues::series); updatePosition(series); } void ScatterSeries::Item::updatePosition(ScatterSeries *series) { - QPointF center = series->toScreen(QPointF(pos, value)); - item->setPos(center.x() - scatterItemDiameter / 2.0, - center.y() - scatterItemDiameter / 2.0); + item->setPos(series->toScreen(QPointF(pos, value))); } void ScatterSeries::Item::highlight(bool highlight) { - item->setPixmap(scatterPixmap(highlight)); + item->setHighlight(highlight); } void ScatterSeries::append(dive *d, double pos, double value) { - items.emplace_back(scene, this, d, pos, value); + items.emplace_back(view, this, d, pos, value); } void ScatterSeries::updatePositions() @@ -91,35 +53,20 @@ void ScatterSeries::updatePositions() item.updatePosition(this); } -static double sq(double f) -{ - return f * f; -} - -static double squareDist(const QPointF &p1, const QPointF &p2) -{ - QPointF diff = p1 - p2; - return QPointF::dotProduct(diff, diff); -} - std::vector<int> ScatterSeries::getItemsUnderMouse(const QPointF &point) const { std::vector<int> res; double x = point.x(); - auto low = std::lower_bound(items.begin(), items.end(), x - scatterItemDiameter, - [] (const Item &item, double x) { return item.item->pos().x() < x; }); - auto high = std::upper_bound(low, items.end(), x + scatterItemDiameter, - [] (double x, const Item &item) { return x < item.item->pos().x(); }); + auto low = std::lower_bound(items.begin(), items.end(), x, + [] (const Item &item, double x) { return item.item->getRect().right() < x; }); + auto high = std::upper_bound(low, items.end(), x, + [] (double x, const Item &item) { return x < item.item->getRect().left(); }); // Hopefully that narrows it down enough. For discrete scatter plots, we could also partition // by equal x and do a binary search in these partitions. But that's probably not worth it. res.reserve(high - low); - double minSquare = sq(scatterItemDiameter / 2.0 + scatterItemBorder); for (auto it = low; it < high; ++it) { - QPointF pos = it->item->pos(); - pos.rx() += scatterItemDiameter / 2.0 + scatterItemBorder; - pos.ry() += scatterItemDiameter / 2.0 + scatterItemBorder; - if (squareDist(pos, point) <= minSquare) + if (it->item->contains(point)) res.push_back(it - items.begin()); } return res; @@ -169,11 +116,11 @@ bool ScatterSeries::hover(QPointF pos) highlighted = std::move(newHighlighted); if (highlighted.empty()) { - information.reset(); + information->setVisible(false); return false; } else { if (!information) - information = createItemPtr<InformationBox>(scene); + information = view.createChartItem<InformationBox>(); std::vector<QString> text; text.reserve(highlighted.size() * 5); @@ -201,6 +148,7 @@ bool ScatterSeries::hover(QPointF pos) } information->setText(text, pos); + information->setVisible(true); return true; } } diff --git a/stats/scatterseries.h b/stats/scatterseries.h index 5f8b4b2e6..e1642f4c6 100644 --- a/stats/scatterseries.h +++ b/stats/scatterseries.h @@ -4,21 +4,20 @@ #ifndef SCATTER_SERIES_H #define SCATTER_SERIES_H +#include "statshelper.h" #include "statsseries.h" #include <memory> #include <vector> -#include <QGraphicsRectItem> -class QGraphicsPixmapItem; -class QGraphicsScene; +class ChartScatterItem; struct InformationBox; struct StatsVariable; struct dive; class ScatterSeries : public StatsSeries { public: - ScatterSeries(QGraphicsScene *scene, StatsAxis *xAxis, StatsAxis *yAxis, + ScatterSeries(StatsView &view, StatsAxis *xAxis, StatsAxis *yAxis, const StatsVariable &varX, const StatsVariable &varY); ~ScatterSeries(); @@ -34,15 +33,15 @@ private: std::vector<int> getItemsUnderMouse(const QPointF &f) const; struct Item { - std::unique_ptr<QGraphicsPixmapItem> item; + ChartItemPtr<ChartScatterItem> item; dive *d; double pos, value; - Item(QGraphicsScene *scene, ScatterSeries *series, dive *d, double pos, double value); + Item(StatsView &view, ScatterSeries *series, dive *d, double pos, double value); void updatePosition(ScatterSeries *series); void highlight(bool highlight); }; - std::unique_ptr<InformationBox> information; + ChartItemPtr<InformationBox> information; std::vector<Item> items; std::vector<int> highlighted; const StatsVariable &varX; diff --git a/stats/statsaxis.cpp b/stats/statsaxis.cpp index 2c5a5d961..92c972e0e 100644 --- a/stats/statsaxis.cpp +++ b/stats/statsaxis.cpp @@ -4,6 +4,7 @@ #include "statshelper.h" #include "statstranslations.h" #include "statsvariables.h" +#include "statsview.h" #include "zvalues.h" #include "core/pref.h" #include "core/subsurface-time.h" @@ -23,23 +24,19 @@ static const double axisLabelSpaceVertical = 2.0; // Space between axis or ticks static const double axisTitleSpaceHorizontal = 2.0; // Space between labels and title static const double axisTitleSpaceVertical = 2.0; // Space between labels and title -StatsAxis::StatsAxis(const QString &titleIn, bool horizontal, bool labelsBetweenTicks) : - horizontal(horizontal), labelsBetweenTicks(labelsBetweenTicks), +StatsAxis::StatsAxis(StatsView &view, const QString &title, bool horizontal, bool labelsBetweenTicks) : + ChartPixmapItem(view, ChartZValue::Axes), + line(view.createChartItem<ChartLineItem>(ChartZValue::Axes, axisColor, axisWidth)), + title(title), horizontal(horizontal), labelsBetweenTicks(labelsBetweenTicks), size(1.0), zeroOnScreen(0.0), min(0.0), max(1.0), labelWidth(0.0) { - // use a Light version of the application fond for both labels and title + // use a Light version of the application font for both labels and title labelFont = QFont(); labelFont.setWeight(QFont::Light); titleFont = labelFont; - setPen(QPen(axisColor, axisWidth)); - setZValue(ZValues::axes); - if (!titleIn.isEmpty()) { - title.reset(new QGraphicsSimpleTextItem(titleIn, this)); - title->setFont(titleFont); - title->setBrush(darkLabelColor); - if (!horizontal) - title->setRotation(-90.0); - } + QFontMetrics fm(titleFont); + titleWidth = title.isEmpty() ? 0.0 + : static_cast<double>(fm.size(Qt::TextSingleLine, title).width()); } StatsAxis::~StatsAxis() @@ -101,7 +98,7 @@ int StatsAxis::guessNumTicks(const std::vector<QString> &strings) const double StatsAxis::titleSpace() const { - if (!title) + if (title.isEmpty()) return 0.0; return horizontal ? QFontMetrics(titleFont).height() + axisTitleSpaceHorizontal : QFontMetrics(titleFont).height() + axisTitleSpaceVertical; @@ -124,31 +121,14 @@ double StatsAxis::height() const (labelsBetweenTicks ? 0.0 : axisTickSizeHorizontal); } -StatsAxis::Label::Label(const QString &name, double pos, QGraphicsScene *scene, const QFont &font) : - label(createItem<QGraphicsSimpleTextItem>(scene, name)), - pos(pos) -{ - label->setBrush(QBrush(darkLabelColor)); - label->setFont(font); - label->setZValue(ZValues::axes); -} - -void StatsAxis::addLabel(const QString &label, double pos) -{ - labels.emplace_back(label, pos, scene(), labelFont); -} - -StatsAxis::Tick::Tick(double pos, QGraphicsScene *scene) : - item(createItemPtr<QGraphicsLineItem>(scene)), - pos(pos) +void StatsAxis::addLabel(const QFontMetrics &fm, const QString &label, double pos) { - item->setPen(QPen(axisColor, axisTickWidth)); - item->setZValue(ZValues::axes); + labels.push_back({ label, fm.size(Qt::TextSingleLine, label).width(), pos }); } void StatsAxis::addTick(double pos) { - ticks.emplace_back(pos, scene()); + ticks.push_back({ view.createChartItem<ChartLineItem>(ChartZValue::Axes, axisColor, axisTickWidth), pos }); } std::vector<double> StatsAxis::ticksPositions() const @@ -178,60 +158,108 @@ double StatsAxis::toValue(double pos) const void StatsAxis::setSize(double sizeIn) { size = sizeIn; + + // Ticks (and labels) should probably be reused. For now, clear them. + for (Tick &tick: ticks) + view.deleteChartItem(tick.item); + labels.clear(); + ticks.clear(); updateLabels(); + labelWidth = 0.0; for (const Label &label: labels) { - double w = label.label->boundingRect().width(); - if (w > labelWidth) - labelWidth = w; + if (label.width > labelWidth) + labelWidth = label.width; + } + + QFontMetrics fm(labelFont); + int fontHeight = fm.height(); + if (horizontal) { + double pixmapWidth = size; + double offsetX = 0.0; + if (!labels.empty() && !labelsBetweenTicks) { + pixmapWidth += labels.front().width / 2.0 + labels.back().width / 2.0; + offsetX += labels.front().width / 2.0; + } + + double pixmapHeight = fontHeight + titleSpace(); + double offsetY = -axisWidth / 2.0 - axisLabelSpaceHorizontal - + (labelsBetweenTicks ? 0.0 : axisTickSizeHorizontal); + + ChartPixmapItem::resize(QSizeF(pixmapWidth, pixmapHeight)); // Note: this rounds the dimensions up + offset = QPointF(round(offsetX), round(offsetY)); + img->fill(Qt::transparent); + + painter->setPen(QPen(darkLabelColor)); + painter->setFont(labelFont); + for (const Label &label: labels) { + double x = (label.pos - min) / (max - min) * size + offset.x() - round(label.width / 2.0); + QRectF rect(x, 0.0, label.width, fontHeight); + painter->drawText(rect, label.label); + } + if (!title.isEmpty()) { + QRectF rect(offset.x() + round((size - titleWidth) / 2.0), + fontHeight + axisTitleSpaceHorizontal, + titleWidth, fontHeight); + painter->setFont(titleFont); + painter->drawText(rect, title); + } + } else { + double pixmapWidth = labelWidth + titleSpace(); + double offsetX = pixmapWidth + axisLabelSpaceVertical + (labelsBetweenTicks ? 0.0 : axisTickSizeVertical); + + double pixmapHeight = ceil(size + axisTickWidth); + double offsetY = size; + if (!labels.empty() && !labelsBetweenTicks) { + pixmapHeight += fontHeight; + offsetY += fontHeight / 2.0; + } + + ChartPixmapItem::resize(QSizeF(pixmapWidth, pixmapHeight)); // Note: this rounds the dimensions up + offset = QPointF(round(offsetX), round(offsetY)); + img->fill(Qt::transparent); + + painter->setPen(QPen(darkLabelColor)); + painter->setFont(labelFont); + for (const Label &label: labels) { + double y = (min - label.pos) / (max - min) * size + offset.y() - round(fontHeight / 2.0); + QRectF rect(pixmapWidth - label.width, y, label.width, fontHeight); + painter->drawText(rect, label.label); + } + if (!title.isEmpty()) { + painter->rotate(-90.0); + QRectF rect(round(-(offsetY + titleWidth) / 2.0), 0.0, titleWidth, fontHeight); + painter->setFont(titleFont); + painter->drawText(rect, title); + painter->resetTransform(); + } } } void StatsAxis::setPos(QPointF pos) { + zeroOnScreen = horizontal ? pos.x() : pos.y(); + ChartPixmapItem::setPos(pos - offset); + if (horizontal) { - zeroOnScreen = pos.x(); - double labelY = pos.y() + axisLabelSpaceHorizontal + - (labelsBetweenTicks ? 0.0 : axisTickSizeHorizontal); double y = pos.y(); - for (Label &label: labels) { - double x = toScreen(label.pos) - label.label->boundingRect().width() / 2.0; - label.label->setPos(QPointF(x, labelY)); - } - for (Tick &tick: ticks) { + for (const Tick &tick: ticks) { double x = toScreen(tick.pos); - tick.item->setLine(x, y, x, y + axisTickSizeHorizontal); + tick.item->setLine(QPointF(x, y), QPointF(x, y + axisTickSizeHorizontal)); } - setLine(zeroOnScreen, y, zeroOnScreen + size, y); - if (title) - title->setPos(zeroOnScreen + (size - title->boundingRect().width()) / 2.0, - labelY + QFontMetrics(labelFont).height() + axisTitleSpaceHorizontal); + line->setLine(QPointF(zeroOnScreen, y), QPointF(zeroOnScreen + size, y)); } else { - double fontHeight = QFontMetrics(labelFont).height(); - zeroOnScreen = pos.y(); double x = pos.x(); - double labelX = x - axisLabelSpaceVertical - - (labelsBetweenTicks ? 0.0 : axisTickSizeVertical); - for (Label &label: labels) { - double y = toScreen(label.pos) - fontHeight / 2.0; - label.label->setPos(QPointF(labelX - label.label->boundingRect().width(), y)); - } - for (Tick &tick: ticks) { + for (const Tick &tick: ticks) { double y = toScreen(tick.pos); - tick.item->setLine(x, y, x - axisTickSizeVertical, y); + tick.item->setLine(QPointF(x, y), QPointF(x - axisTickSizeVertical, y)); } - // This is very confusing: even though we need the height of the title, the correct - // size is stored in boundingRect().width(). Presumably because the item is rotated - // by -90°. Apparently, the boundingRect is in item-local coordinates? - if (title) - title->setPos(labelX - labelWidth - QFontMetrics(labelFont).height() - axisTitleSpaceVertical, - zeroOnScreen - (size - title->boundingRect().width()) / 2.0); - setLine(x, zeroOnScreen, x, zeroOnScreen - size); + line->setLine(QPointF(x, zeroOnScreen), QPointF(x, zeroOnScreen - size)); } } -ValueAxis::ValueAxis(const QString &title, double min, double max, int decimals, bool horizontal) : - StatsAxis(title, horizontal, false), +ValueAxis::ValueAxis(StatsView &view, const QString &title, double min, double max, int decimals, bool horizontal) : + StatsAxis(view, title, horizontal, false), min(min), max(max), decimals(decimals) { // Avoid degenerate cases @@ -251,9 +279,6 @@ std::pair<QString, QString> ValueAxis::getFirstLastLabel() const void ValueAxis::updateLabels() { - labels.clear(); - ticks.clear(); - QLocale loc; auto [minString, maxString] = getFirstLastLabel(); int numTicks = guessNumTicks({ minString, maxString}); @@ -283,15 +308,16 @@ void ValueAxis::updateLabels() double act = actMin; labels.reserve(num + 1); ticks.reserve(num + 1); + QFontMetrics fm(labelFont); for (int i = 0; i <= num; ++i) { - addLabel(loc.toString(act, 'f', decimals), act); + addLabel(fm, loc.toString(act, 'f', decimals), act); addTick(act); act += actStep; } } -CountAxis::CountAxis(const QString &title, int count, bool horizontal) : - ValueAxis(title, 0.0, (double)count, 0, horizontal), +CountAxis::CountAxis(StatsView &view, const QString &title, int count, bool horizontal) : + ValueAxis(view, title, 0.0, (double)count, 0, horizontal), count(count) { } @@ -306,9 +332,6 @@ std::pair<QString, QString> CountAxis::getFirstLastLabel() const void CountAxis::updateLabels() { - labels.clear(); - ticks.clear(); - QLocale loc; QString countString = loc.toString(count); int numTicks = guessNumTicks({ countString }); @@ -345,17 +368,21 @@ void CountAxis::updateLabels() labels.reserve(max + 1); ticks.reserve(max + 1); + QFontMetrics fm(labelFont); for (int i = 0; i <= max; i += step) { - addLabel(loc.toString(i), static_cast<double>(i)); + addLabel(fm, loc.toString(i), static_cast<double>(i)); addTick(static_cast<double>(i)); } } -CategoryAxis::CategoryAxis(const QString &title, const std::vector<QString> &labels, bool horizontal) : - StatsAxis(title, horizontal, true), +CategoryAxis::CategoryAxis(StatsView &view, const QString &title, const std::vector<QString> &labels, bool horizontal) : + StatsAxis(view, title, horizontal, true), labelsText(labels) { - setRange(-0.5, static_cast<double>(labels.size()) + 0.5); + if (!labels.empty()) + setRange(-0.5, static_cast<double>(labels.size()) - 0.5); + else + setRange(-1.0, 1.0); } // No implementation because the labels are inside ticks and this @@ -392,8 +419,6 @@ void CategoryAxis::updateLabels() QString ellipsis = horizontal ? getEllipsis(fm, size_per_label) : QString(); - labels.clear(); - ticks.clear(); labels.reserve(labelsText.size()); ticks.reserve(labelsText.size() + 1); double pos = 0.0; @@ -401,18 +426,18 @@ void CategoryAxis::updateLabels() for (const QString &s: labelsText) { if (horizontal) { double width = static_cast<double>(fm.size(Qt::TextSingleLine, s).width()); - addLabel(width < size_per_label ? s : ellipsis, pos); + addLabel(fm, width < size_per_label ? s : ellipsis, pos); } else { if (fontHeight < size_per_label) - addLabel(s, pos); + addLabel(fm, s, pos); } addTick(pos + 0.5); pos += 1.0; } } -HistogramAxis::HistogramAxis(const QString &title, std::vector<HistogramAxisEntry> bins, bool horizontal) : - StatsAxis(title, horizontal, false), +HistogramAxis::HistogramAxis(StatsView &view, const QString &title, std::vector<HistogramAxisEntry> bins, bool horizontal) : + StatsAxis(view, title, horizontal, false), bin_values(std::move(bins)) { if (bin_values.size() < 2) // Less than two makes no sense -> there must be at least one category @@ -446,9 +471,6 @@ std::pair<QString, QString> HistogramAxis::getFirstLastLabel() const // There, we obviously want to show the years and not the quarters. void HistogramAxis::updateLabels() { - labels.clear(); - ticks.clear(); - if (bin_values.size() < 2) // Less than two makes no sense -> there must be at least one category return; @@ -488,9 +510,10 @@ void HistogramAxis::updateLabels() if (first != 0) addTick(bin_values.front().value); int last = first; + QFontMetrics fm(labelFont); for (int i = first; i < (int)bin_values.size(); i += step) { const auto &[name, value, recommended] = bin_values[i]; - addLabel(name, value); + addLabel(fm, name, value); addTick(value); last = i; } @@ -617,7 +640,7 @@ static std::vector<HistogramAxisEntry> timeRangeToBins(double from, double to) return res; } -DateAxis::DateAxis(const QString &title, double from, double to, bool horizontal) : - HistogramAxis(title, timeRangeToBins(from, to), horizontal) +DateAxis::DateAxis(StatsView &view, const QString &title, double from, double to, bool horizontal) : + HistogramAxis(view, title, timeRangeToBins(from, to), horizontal) { } diff --git a/stats/statsaxis.h b/stats/statsaxis.h index 9d46f753a..02662ddd9 100644 --- a/stats/statsaxis.h +++ b/stats/statsaxis.h @@ -2,15 +2,20 @@ #ifndef STATS_AXIS_H #define STATS_AXIS_H +#include "chartitem.h" +#include "statshelper.h" + #include <memory> #include <vector> #include <QFont> -#include <QGraphicsSimpleTextItem> -#include <QGraphicsLineItem> -class QGraphicsScene; +class StatsView; +class ChartLineItem; +class QFontMetrics; -class StatsAxis : public QGraphicsLineItem { +// The labels and the title of the axis are rendered into a pixmap. +// The ticks and the baseline are realized as individual ChartLineItems. +class StatsAxis : public ChartPixmapItem { public: virtual ~StatsAxis(); // Returns minimum and maximum of shown range, not of data points. @@ -30,22 +35,25 @@ public: std::vector<double> ticksPositions() const; // Positions in screen coordinates protected: - StatsAxis(const QString &title, bool horizontal, bool labelsBetweenTicks); + StatsAxis(StatsView &view, const QString &title, bool horizontal, bool labelsBetweenTicks); + + ChartItemPtr<ChartLineItem> line; + QString title; + double titleWidth; struct Label { - std::unique_ptr<QGraphicsSimpleTextItem> label; + QString label; + int width; double pos; - Label(const QString &name, double pos, QGraphicsScene *scene, const QFont &font); }; std::vector<Label> labels; - void addLabel(const QString &label, double pos); + void addLabel(const QFontMetrics &fm, const QString &label, double pos); virtual void updateLabels() = 0; virtual std::pair<QString, QString> getFirstLastLabel() const = 0; struct Tick { - std::unique_ptr<QGraphicsLineItem> item; + ChartItemPtr<ChartLineItem> item; double pos; - Tick(double pos, QGraphicsScene *scene); }; std::vector<Tick> ticks; void addTick(double pos); @@ -55,9 +63,9 @@ protected: bool labelsBetweenTicks; // When labels are between ticks, they can be moved closer to the axis QFont labelFont, titleFont; - std::unique_ptr<QGraphicsSimpleTextItem> title; double size; // width for horizontal, height for vertical double zeroOnScreen; + QPointF offset; // Offset of the label and title pixmap with respect to the (0,0) position. double min, max; double labelWidth; // Maximum width of labels private: @@ -66,7 +74,7 @@ private: class ValueAxis : public StatsAxis { public: - ValueAxis(const QString &title, double min, double max, int decimals, bool horizontal); + ValueAxis(StatsView &view, const QString &title, double min, double max, int decimals, bool horizontal); private: double min, max; int decimals; @@ -76,7 +84,7 @@ private: class CountAxis : public ValueAxis { public: - CountAxis(const QString &title, int count, bool horizontal); + CountAxis(StatsView &view, const QString &title, int count, bool horizontal); private: int count; void updateLabels() override; @@ -85,7 +93,7 @@ private: class CategoryAxis : public StatsAxis { public: - CategoryAxis(const QString &title, const std::vector<QString> &labels, bool horizontal); + CategoryAxis(StatsView &view, const QString &title, const std::vector<QString> &labels, bool horizontal); private: std::vector<QString> labelsText; void updateLabels(); @@ -100,7 +108,7 @@ struct HistogramAxisEntry { class HistogramAxis : public StatsAxis { public: - HistogramAxis(const QString &title, std::vector<HistogramAxisEntry> bin_values, bool horizontal); + HistogramAxis(StatsView &view, const QString &title, std::vector<HistogramAxisEntry> bin_values, bool horizontal); private: void updateLabels() override; std::pair<QString, QString> getFirstLastLabel() const override; @@ -110,7 +118,7 @@ private: class DateAxis : public HistogramAxis { public: - DateAxis(const QString &title, double from, double to, bool horizontal); + DateAxis(StatsView &view, const QString &title, double from, double to, bool horizontal); }; #endif diff --git a/stats/statscolors.h b/stats/statscolors.h index 050b8a3ab..e1800b550 100644 --- a/stats/statscolors.h +++ b/stats/statscolors.h @@ -14,6 +14,14 @@ inline const QColor darkLabelColor(Qt::black); inline const QColor lightLabelColor(Qt::white); inline const QColor axisColor(Qt::black); inline const QColor gridColor(0xcc, 0xcc, 0xcc); +inline const QColor informationBorderColor(Qt::black); +inline const QColor informationColor(0xff, 0xff, 0x00, 192); // Note: fourth argument is opacity +inline const QColor legendColor(0x00, 0x8e, 0xcc, 192); // Note: fourth argument is opacity +inline const QColor legendBorderColor(Qt::black); +inline const QColor quartileMarkerColor(Qt::red); +inline const QColor regressionItemColor(Qt::red); +inline const QColor meanMarkerColor(Qt::green); +inline const QColor medianMarkerColor(Qt::red); QColor binColor(int bin, int numBins); QColor labelColor(int bin, size_t numBins); diff --git a/stats/statsgrid.cpp b/stats/statsgrid.cpp index 66c720c33..f29069341 100644 --- a/stats/statsgrid.cpp +++ b/stats/statsgrid.cpp @@ -1,17 +1,15 @@ // SPDX-License-Identifier: GPL-2.0 #include "statsgrid.h" +#include "chartitem.h" #include "statsaxis.h" #include "statscolors.h" -#include "statshelper.h" +#include "statsview.h" #include "zvalues.h" -#include <QGraphicsLineItem> - static const double gridWidth = 1.0; -static const Qt::PenStyle gridStyle = Qt::SolidLine; -StatsGrid::StatsGrid(QGraphicsScene *scene, const StatsAxis &xAxis, const StatsAxis &yAxis) - : scene(scene), xAxis(xAxis), yAxis(yAxis) +StatsGrid::StatsGrid(StatsView &view, const StatsAxis &xAxis, const StatsAxis &yAxis) + : view(view), xAxis(xAxis), yAxis(yAxis) { } @@ -19,18 +17,22 @@ void StatsGrid::updatePositions() { std::vector<double> xtics = xAxis.ticksPositions(); std::vector<double> ytics = yAxis.ticksPositions(); + + // We probably should be smarter and reuse existing lines. + // For now, this does it. + for (auto &line: lines) + view.deleteChartItem(line); lines.clear(); + if (xtics.empty() || ytics.empty()) return; for (double x: xtics) { - lines.emplace_back(createItem<QGraphicsLineItem>(scene, x, ytics.front(), x, ytics.back())); - lines.back()->setPen(QPen(gridColor, gridWidth, gridStyle)); - lines.back()->setZValue(ZValues::grid); + lines.push_back(view.createChartItem<ChartLineItem>(ChartZValue::Grid, gridColor, gridWidth)); + lines.back()->setLine(QPointF(x, ytics.front()), QPointF(x, ytics.back())); } for (double y: ytics) { - lines.emplace_back(createItem<QGraphicsLineItem>(scene, xtics.front(), y, xtics.back(), y)); - lines.back()->setPen(QPen(gridColor, gridWidth, gridStyle)); - lines.back()->setZValue(ZValues::grid); + lines.push_back(view.createChartItem<ChartLineItem>(ChartZValue::Grid, gridColor, gridWidth)); + lines.back()->setLine(QPointF(xtics.front(), y), QPointF(xtics.back(), y)); } } diff --git a/stats/statsgrid.h b/stats/statsgrid.h index 47b48b3ac..696341c0b 100644 --- a/stats/statsgrid.h +++ b/stats/statsgrid.h @@ -1,20 +1,21 @@ // SPDX-License-Identifier: GPL-2.0 // The background grid of a chart +#include "statshelper.h" + #include <memory> #include <vector> -#include <QVector> -#include <QGraphicsLineItem> class StatsAxis; -class QGraphicsScene; +class StatsView; +class ChartLineItem; class StatsGrid { public: - StatsGrid(QGraphicsScene *scene, const StatsAxis &xAxis, const StatsAxis &yAxis); + StatsGrid(StatsView &view, const StatsAxis &xAxis, const StatsAxis &yAxis); void updatePositions(); private: - QGraphicsScene *scene; + StatsView &view; const StatsAxis &xAxis, &yAxis; - std::vector<std::unique_ptr<QGraphicsLineItem>> lines; + std::vector<ChartItemPtr<ChartLineItem>> lines; }; diff --git a/stats/statshelper.h b/stats/statshelper.h index 0ea39763d..6b4a30ab5 100644 --- a/stats/statshelper.h +++ b/stats/statshelper.h @@ -1,25 +1,134 @@ // SPDX-License-Identifier: GPL-2.0 -// Helper functions to render the stats. Currently only -// contains a small template to create scene-items. This -// is for historical reasons to ease transition from QtCharts -// and might be removed. +// Helper functions to render the stats. Currently contains +// QSGNode template jugglery to overcome API flaws. #ifndef STATSHELPER_H +#define STATSHELPER_H #include <memory> -#include <QGraphicsScene> +#include <QSGNode> -template <typename T, class... Args> -T *createItem(QGraphicsScene *scene, Args&&... args) +// A stupid pointer class that initializes to null and can be copy +// assigned. This is for historical reasons: unique_ptrs to ChartItems +// were replaced by plain pointers. Instead of nulling the plain pointers +// in the constructors, use this. Ultimately, we might think about making +// this thing smarter, once removal of individual ChartItems is implemented. +template <typename T> +class ChartItemPtr { + friend class StatsView; // Only the stats view can create these pointers + T *ptr; + ChartItemPtr(T *ptr) : ptr(ptr) + { + } +public: + ChartItemPtr() : ptr(nullptr) + { + } + ChartItemPtr(const ChartItemPtr &p) : ptr(p.ptr) + { + } + void reset() + { + ptr = nullptr; + } + ChartItemPtr &operator=(const ChartItemPtr &p) + { + ptr = p.ptr; + return *this; + } + operator bool() const + { + return !!ptr; + } + bool operator!() const + { + return !ptr; + } + T &operator*() const + { + return *ptr; + } + T *operator->() const + { + return ptr; + } +}; + +// In general, we want chart items to be hideable. For example to show/hide +// labels on demand. Very sadly, the QSG API is absolutely terrible with +// respect to temporarily disabling. Instead of simply having a flag, +// a QSGNode is queried using the "isSubtreeBlocked()" virtual function(!). +// +// Not only is this a slow operation performed on every single node, it +// also is often not possible to override this function: For improved +// performance, the documentation recommends to create QSG nodes via +// QQuickWindow. This provides nodes optimized for the actual hardware. +// However, this obviously means that these nodes cannot be derived from! +// +// In that case, there are two possibilities: Add a proxy node with an +// overridden "isSubtreeBlocked()" function or remove the node from the +// scene. The former was chosen here, because it is less complex. +// +// The following slightly cryptic templates are used to unify the two +// cases: The QSGNode is generated by our own code or the QSGNode is +// obtained from QQuickWindow. +// +// The "HideableQSGNode<Node>" template augments the QSGNode "Node" +// by a "setVisible()" function and overrides "isSubtreeBlocked()" +// +// The "QSGProxyNode<Node>" template is a QSGNode with a single +// child of type "Node". +// +// Thus, if the node can be created, use: +// HideableQSGNode<NodeTypeThatCanBeCreated> node +// and if the node can only be obtained from QQuickWindow, use: +// HideableQSGNode<QSGProxyNode<NodeThatCantBeCreated>> node +// The latter should obviously be typedef-ed. +// +// Yes, that's all horrible, but if nothing else it teaches us about +// composition. +template <typename Node> +class HideableQSGNode : public Node { + bool hidden; + bool isSubtreeBlocked() const override final; +public: + template<class... Args> + HideableQSGNode(bool visible, Args&&... args); + void setVisible(bool visible); +}; + +template <typename Node> +class QSGProxyNode : public QSGNode { +public: + std::unique_ptr<Node> node; + QSGProxyNode(Node *node); +}; + +// Implementation detail of templates - move to serparate header file +template <typename Node> +QSGProxyNode<Node>::QSGProxyNode(Node *node) : node(node) +{ + appendChildNode(node); +} + +template <typename Node> +bool HideableQSGNode<Node>::isSubtreeBlocked() const +{ + return hidden; +} + +template <typename Node> +template<class... Args> +HideableQSGNode<Node>::HideableQSGNode(bool visible, Args&&... args) : + Node(std::forward<Args>(args)...), + hidden(!visible) { - T *res = new T(std::forward<Args>(args)...); - scene->addItem(res); - return res; } -template <typename T, class... Args> -std::unique_ptr<T> createItemPtr(QGraphicsScene *scene, Args&&... args) +template <typename Node> +void HideableQSGNode<Node>::setVisible(bool visible) { - return std::unique_ptr<T>(createItem<T>(scene, std::forward<Args>(args)...)); + hidden = !visible; + Node::markDirty(QSGNode::DirtySubtreeBlocked); } #endif diff --git a/stats/statsicons.qrc b/stats/statsicons.qrc new file mode 100644 index 000000000..18a79c921 --- /dev/null +++ b/stats/statsicons.qrc @@ -0,0 +1,15 @@ +<RCC> + <qresource prefix="/"> + <file alias="chart-bar-grouped-horizontal-icon">../icons/chart_bar_grouped_horizontal.svg</file> + <file alias="chart-bar-grouped-vertical-icon">../icons/chart_bar_grouped_vertical.svg</file> + <file alias="chart-bar-stacked-horizontal-icon">../icons/chart_bar_stacked_horizontal.svg</file> + <file alias="chart-bar-stacked-vertical-icon">../icons/chart_bar_stacked_vertical.svg</file> + <file alias="chart-bar-horizontal-icon">../icons/chart_bar_horizontal.svg</file> + <file alias="chart-bar-vertical-icon">../icons/chart_bar_vertical.svg</file> + <file alias="chart-box-icon">../icons/chart_box.svg</file> + <file alias="chart-pie-icon">../icons/chart_pie.svg</file> + <file alias="chart-points-icon">../icons/chart_points.svg</file> + <file alias="chart-warning-icon">../icons/warning-icon.svg</file> + </qresource> +</RCC> + diff --git a/stats/statsseries.cpp b/stats/statsseries.cpp index 2b7a5adea..60e54e127 100644 --- a/stats/statsseries.cpp +++ b/stats/statsseries.cpp @@ -2,8 +2,8 @@ #include "statsseries.h" #include "statsaxis.h" -StatsSeries::StatsSeries(QGraphicsScene *scene, StatsAxis *xAxis, StatsAxis *yAxis) : - scene(scene), xAxis(xAxis), yAxis(yAxis) +StatsSeries::StatsSeries(StatsView &view, StatsAxis *xAxis, StatsAxis *yAxis) : + view(view), xAxis(xAxis), yAxis(yAxis) { } diff --git a/stats/statsseries.h b/stats/statsseries.h index 2494569e6..360396601 100644 --- a/stats/statsseries.h +++ b/stats/statsseries.h @@ -6,18 +6,18 @@ #include <QPointF> -class QGraphicsScene; class StatsAxis; +class StatsView; class StatsSeries { public: - StatsSeries(QGraphicsScene *scene, StatsAxis *xAxis, StatsAxis *yAxis); + StatsSeries(StatsView &view, StatsAxis *xAxis, StatsAxis *yAxis); virtual ~StatsSeries(); virtual void updatePositions() = 0; // Called if chart geometry changes. virtual bool hover(QPointF pos) = 0; // Called on mouse movement. Return true if an item of this series is highlighted. virtual void unhighlight() = 0; // Unhighlight any highlighted item. protected: - QGraphicsScene *scene; + StatsView &view; StatsAxis *xAxis, *yAxis; // May be zero for charts without axes (pie charts). QPointF toScreen(QPointF p); }; diff --git a/stats/statsstate.cpp b/stats/statsstate.cpp index 130a0e5a8..65d5c8656 100644 --- a/stats/statsstate.cpp +++ b/stats/statsstate.cpp @@ -23,11 +23,13 @@ enum class SupportedVariable { Numeric }; -static const int ChartFeatureLabels = 1 << 0; -static const int ChartFeatureLegend = 1 << 1; -static const int ChartFeatureMedian = 1 << 2; -static const int ChartFeatureMean = 1 << 3; -static const int ChartFeatureQuartiles = 1 << 4; +static const int ChartFeatureLabels = 1 << 0; +static const int ChartFeatureLegend = 1 << 1; +static const int ChartFeatureMedian = 1 << 2; +static const int ChartFeatureMean = 1 << 3; +static const int ChartFeatureQuartiles = 1 << 4; +static const int ChartFeatureRegression = 1 << 5; +static const int ChartFeatureConfidence = 1 << 6; static const struct ChartTypeDesc { ChartType id; @@ -45,7 +47,7 @@ static const struct ChartTypeDesc { SupportedVariable::Numeric, false, false, false, { ChartSubType::Dots }, - 0 + ChartFeatureRegression | ChartFeatureConfidence }, { ChartType::HistogramCount, @@ -161,6 +163,8 @@ StatsState::StatsState() : median(false), mean(false), quartiles(true), + regression(true), + confidence(true), var1Binner(nullptr), var2Binner(nullptr), var2Operation(StatsOperation::Invalid) @@ -353,19 +357,23 @@ static StatsState::VariableList createOperationsList(const StatsVariable *var, S return res; } -static std::vector<StatsState::Feature> createFeaturesList(int chartFeatures, bool labels, bool legend, bool median, bool mean, bool quartiles) +static std::vector<StatsState::Feature> createFeaturesList(int chartFeatures, const StatsState &state) { std::vector<StatsState::Feature> res; if (chartFeatures & ChartFeatureLabels) - res.push_back({ StatsTranslations::tr("labels"), ChartFeatureLabels, labels }); + res.push_back({ StatsTranslations::tr("labels"), ChartFeatureLabels, state.labels }); if (chartFeatures & ChartFeatureLegend) - res.push_back({ StatsTranslations::tr("legend"), ChartFeatureLegend, legend }); + res.push_back({ StatsTranslations::tr("legend"), ChartFeatureLegend, state.legend }); if (chartFeatures & ChartFeatureMedian) - res.push_back({ StatsTranslations::tr("median"), ChartFeatureMedian, median }); + res.push_back({ StatsTranslations::tr("median"), ChartFeatureMedian, state.median }); if (chartFeatures & ChartFeatureMean) - res.push_back({ StatsTranslations::tr("mean"), ChartFeatureMean, mean }); + res.push_back({ StatsTranslations::tr("mean"), ChartFeatureMean, state.mean }); if (chartFeatures & ChartFeatureQuartiles) - res.push_back({ StatsTranslations::tr("quartiles"), ChartFeatureQuartiles, quartiles }); + res.push_back({ StatsTranslations::tr("quartiles"), ChartFeatureQuartiles, state.quartiles }); + if (chartFeatures & ChartFeatureRegression) + res.push_back({ StatsTranslations::tr("linear regression"), ChartFeatureRegression, state.regression }); + if (chartFeatures & ChartFeatureConfidence) + res.push_back({ StatsTranslations::tr("95% confidence area"), ChartFeatureConfidence, state.confidence }); return res; } @@ -381,7 +389,7 @@ StatsState::UIState StatsState::getUIState() const // Second variable can only be binned if first variable is binned. res.binners2 = createBinnerList(var2, var2Binner, var1Binner != nullptr, true); res.operations2 = createOperationsList(var2, var2Operation, var1Binner); - res.features = createFeaturesList(chartFeatures, labels, legend, median, mean, quartiles); + res.features = createFeaturesList(chartFeatures, *this); return res; } @@ -471,6 +479,10 @@ void StatsState::featureChanged(int id, bool state) mean = state; else if (id == ChartFeatureQuartiles) quartiles = state; + else if (id == ChartFeatureRegression) + regression = state; + else if (id == ChartFeatureConfidence) + confidence = state; } // Creates the new chart-type from the current chart-type and a list of possible chart types. diff --git a/stats/statsstate.h b/stats/statsstate.h index 1d8fe0b05..8fa6bb176 100644 --- a/stats/statsstate.h +++ b/stats/statsstate.h @@ -108,6 +108,8 @@ public: bool median; bool mean; bool quartiles; + bool regression; + bool confidence; const StatsBinner *var1Binner; // nullptr: undefined const StatsBinner *var2Binner; // nullptr: undefined StatsOperation var2Operation; diff --git a/stats/statsview.cpp b/stats/statsview.cpp index be296cf44..6560360cc 100644 --- a/stats/statsview.cpp +++ b/stats/statsview.cpp @@ -2,8 +2,11 @@ #include "statsview.h" #include "barseries.h" #include "boxseries.h" +#include "histogrammarker.h" #include "legend.h" #include "pieseries.h" +#include "quartilemarker.h" +#include "regressionitem.h" #include "scatterseries.h" #include "statsaxis.h" #include "statscolors.h" @@ -17,77 +20,220 @@ #include "core/subsurface-qt/divelistnotifier.h" #include <cmath> -#include <QGraphicsScene> -#include <QGraphicsSceneHoverEvent> -#include <QGraphicsSimpleTextItem> #include <QQuickItem> #include <QQuickWindow> #include <QSGImageNode> +#include <QSGRectangleNode> #include <QSGTexture> +// Constants that control the graph layouts +static const double sceneBorder = 5.0; // Border between scene edges and statitistics view +static const double titleBorder = 2.0; // Border between title and chart + +StatsView::StatsView(QQuickItem *parent) : QQuickItem(parent), + highlightedSeries(nullptr), + xAxis(nullptr), + yAxis(nullptr), + draggedItem(nullptr), + rootNode(nullptr) +{ + setFlag(ItemHasContents, true); + + connect(&diveListNotifier, &DiveListNotifier::numShownChanged, this, &StatsView::replotIfVisible); + + setAcceptHoverEvents(true); + setAcceptedMouseButtons(Qt::LeftButton); + + QFont font; + titleFont = QFont(font.family(), font.pointSize(), QFont::Light); // Make configurable +} + +StatsView::StatsView() : StatsView(nullptr) +{ +} + +StatsView::~StatsView() +{ +} + +void StatsView::mousePressEvent(QMouseEvent *event) +{ + // Currently, we only support dragging of the legend. If other objects + // should be made draggable, this needs to be generalized. + if (legend) { + QPointF pos = event->localPos(); + QRectF rect = legend->getRect(); + if (legend->getRect().contains(pos)) { + dragStartMouse = pos; + dragStartItem = rect.topLeft(); + draggedItem = &*legend; + grabMouse(); + setKeepMouseGrab(true); // don't allow Qt to steal the grab + } + } +} + +void StatsView::mouseReleaseEvent(QMouseEvent *) +{ + if (draggedItem) { + draggedItem = nullptr; + ungrabMouse(); + } +} + +// Define a hideable dummy QSG node that is used as a parent node to make +// all objects of a z-level visible / invisible. +using ZNode = HideableQSGNode<QSGNode>; + +class RootNode : public QSGNode +{ +public: + RootNode(QQuickWindow *w); + std::unique_ptr<QSGRectangleNode> backgroundNode; // solid background + // We entertain one node per Z-level. + std::array<std::unique_ptr<ZNode>, (size_t)ChartZValue::Count> zNodes; +}; + +RootNode::RootNode(QQuickWindow *w) +{ + // Add a background rectangle with a solid color. This could + // also be done on the widget level, but would have to be done + // separately for desktop and mobile, so do it here. + backgroundNode.reset(w->createRectangleNode()); + backgroundNode->setColor(backgroundColor); + appendChildNode(backgroundNode.get()); + + for (auto &zNode: zNodes) { + zNode.reset(new ZNode(true)); + appendChildNode(zNode.get()); + } +} + QSGNode *StatsView::updatePaintNode(QSGNode *oldNode, QQuickItem::UpdatePaintNodeData *) { // The QtQuick drawing interface is utterly bizzare with a distinct 1980ies-style memory management. // This is just a copy of what is found in Qt's documentation. - QSGImageNode *n = static_cast<QSGImageNode *>(oldNode); + RootNode *n = static_cast<RootNode *>(oldNode); if (!n) - n = window()->createImageNode(); + n = rootNode = new RootNode(window()); + + // Delete all chart items that are marked for deletion. + ChartItem *nextitem; + for (ChartItem *item = deletedItems.first; item; item = nextitem) { + nextitem = item->next; + delete item; + } + deletedItems.clear(); QRectF rect = boundingRect(); if (plotRect != rect) { plotRect = rect; + rootNode->backgroundNode->setRect(rect); plotAreaChanged(plotRect.size()); } - img->fill(backgroundColor); - scene.render(painter.get()); - texture.reset(window()->createTextureFromImage(*img, QQuickWindow::TextureIsOpaque)); - n->setTexture(texture.get()); - n->setRect(rect); + for (ChartItem *item = dirtyItems.first; item; item = item->next) { + item->render(); + item->dirty = false; + } + dirtyItems.splice(cleanItems); + return n; } -// Constants that control the graph layouts -static const QColor quartileMarkerColor(Qt::red); -static const double quartileMarkerSize = 15.0; -static const double sceneBorder = 5.0; // Border between scene edges and statitistics view -static const double titleBorder = 2.0; // Border between title and chart +void StatsView::addQSGNode(QSGNode *node, ChartZValue z) +{ + int idx = std::clamp((int)z, 0, (int)ChartZValue::Count - 1); + rootNode->zNodes[idx]->appendChildNode(node); +} -StatsView::StatsView(QQuickItem *parent) : QQuickItem(parent), - highlightedSeries(nullptr), - xAxis(nullptr), - yAxis(nullptr) +void StatsView::registerChartItem(ChartItem &item) { - setFlag(ItemHasContents, true); + cleanItems.append(item); +} - connect(&diveListNotifier, &DiveListNotifier::numShownChanged, this, &StatsView::replotIfVisible); +void StatsView::registerDirtyChartItem(ChartItem &item) +{ + if (item.dirty) + return; + cleanItems.remove(item); + dirtyItems.append(item); + item.dirty = true; +} - setAcceptHoverEvents(true); +void StatsView::deleteChartItemInternal(ChartItem &item) +{ + if (item.dirty) + dirtyItems.remove(item); + else + cleanItems.remove(item); + deletedItems.append(item); +} - QFont font; - titleFont = QFont(font.family(), font.pointSize(), QFont::Light); // Make configurable +StatsView::ChartItemList::ChartItemList() : first(nullptr), last(nullptr) +{ } -StatsView::StatsView() : StatsView(nullptr) +void StatsView::ChartItemList::clear() { + first = last = nullptr; } -StatsView::~StatsView() +void StatsView::ChartItemList::remove(ChartItem &item) { + if (item.next) + item.next->prev = item.prev; + else + last = item.prev; + if (item.prev) + item.prev->next = item.next; + else + first = item.next; + item.prev = item.next = nullptr; } -void StatsView::plotAreaChanged(const QSizeF &s) +void StatsView::ChartItemList::append(ChartItem &item) { - // Make sure that image is at least one pixel wide / high, otherwise - // the painter starts acting up. - int w = std::max(1, static_cast<int>(floor(s.width()))); - int h = std::max(1, static_cast<int>(floor(s.height()))); - scene.setSceneRect(QRectF(0, 0, static_cast<double>(w), static_cast<double>(h))); - painter.reset(); - img.reset(new QImage(w, h, QImage::Format_RGB32)); - painter.reset(new QPainter(img.get())); - painter->setRenderHint(QPainter::Antialiasing); + if (!first) { + first = &item; + } else { + item.prev = last; + last->next = &item; + } + last = &item; +} +void StatsView::ChartItemList::splice(ChartItemList &l2) +{ + if (!first) // if list is empty -> nothing to do. + return; + if (!l2.first) { + l2 = *this; + } else { + l2.last->next = first; + first->prev = l2.last; + l2.last = last; + } + clear(); +} + +QQuickWindow *StatsView::w() const +{ + return window(); +} + +QSizeF StatsView::size() const +{ + return boundingRect().size(); +} + +QRectF StatsView::plotArea() const +{ + return plotRect; +} + +void StatsView::plotAreaChanged(const QSizeF &s) +{ double left = sceneBorder; double top = sceneBorder; double right = s.width() - sceneBorder; @@ -95,7 +241,7 @@ void StatsView::plotAreaChanged(const QSizeF &s) const double minSize = 30.0; if (title) - top += title->boundingRect().height() + titleBorder; + top += title->getRect().height() + titleBorder; // Currently, we only have either none, or an x- and a y-axis std::pair<double,double> horizontalSpace{ 0.0, 0.0 }; if (xAxis) { @@ -123,12 +269,14 @@ void StatsView::plotAreaChanged(const QSizeF &s) grid->updatePositions(); for (auto &series: series) series->updatePositions(); - for (QuartileMarker &marker: quartileMarkers) - marker.updatePosition(); - for (RegressionLine &line: regressionLines) - line.updatePosition(); - for (HistogramMarker &marker: histogramMarkers) - marker.updatePosition(); + for (auto &marker: quartileMarkers) + marker->updatePosition(); + if (regressionItem) + regressionItem->updatePosition(); + if (meanMarker) + meanMarker->updatePosition(); + if (medianMarker) + medianMarker->updatePosition(); if (legend) legend->resize(); updateTitlePos(); @@ -140,13 +288,26 @@ void StatsView::replotIfVisible() plot(state); } +void StatsView::mouseMoveEvent(QMouseEvent *event) +{ + if (!draggedItem) + return; + + QSizeF sceneSize = size(); + if (sceneSize.width() <= 1.0 || sceneSize.height() <= 1.0) + return; + draggedItem->setPos(event->pos() - dragStartMouse + dragStartItem); + update(); +} + void StatsView::hoverEnterEvent(QHoverEvent *) { } void StatsView::hoverMoveEvent(QHoverEvent *event) { - QPointF pos(event->pos()); + QPointF pos = event->pos(); + for (auto &series: series) { if (series->hover(pos)) { if (series.get() != highlightedSeries) { @@ -169,7 +330,7 @@ void StatsView::hoverMoveEvent(QHoverEvent *event) template <typename T, class... Args> T *StatsView::createSeries(Args&&... args) { - T *res = new T(&scene, xAxis, yAxis, std::forward<Args>(args)...); + T *res = new T(*this, xAxis, yAxis, std::forward<Args>(args)...); series.emplace_back(res); series.back()->updatePositions(); return res; @@ -177,29 +338,26 @@ T *StatsView::createSeries(Args&&... args) void StatsView::setTitle(const QString &s) { - if (s.isEmpty()) { - title.reset(); + if (title) { + // Ooops. Currently we do not support setting the title twice. return; } - title = createItemPtr<QGraphicsSimpleTextItem>(&scene, s); - title->setFont(titleFont); + title = createChartItem<ChartTextItem>(ChartZValue::Legend, titleFont, s); + title->setColor(darkLabelColor); } void StatsView::updateTitlePos() { if (!title) return; - QRectF rect = scene.sceneRect(); - title->setPos(sceneBorder + (rect.width() - title->boundingRect().width()) / 2.0, - sceneBorder); + title->setPos(QPointF(round(sceneBorder + (boundingRect().width() - title->getRect().width()) / 2.0), + round(sceneBorder))); } template <typename T, class... Args> T *StatsView::createAxis(const QString &title, Args&&... args) { - T *res = createItem<T>(&scene, title, std::forward<Args>(args)...); - axes.emplace_back(res); - return res; + return &*createChartItem<T>(title, std::forward<Args>(args)...); } void StatsView::setAxes(StatsAxis *x, StatsAxis *y) @@ -207,28 +365,42 @@ void StatsView::setAxes(StatsAxis *x, StatsAxis *y) xAxis = x; yAxis = y; if (x && y) - grid = std::make_unique<StatsGrid>(&scene, *x, *y); + grid = std::make_unique<StatsGrid>(*this, *x, *y); } void StatsView::reset() { highlightedSeries = nullptr; xAxis = yAxis = nullptr; + draggedItem = nullptr; + title.reset(); legend.reset(); + regressionItem.reset(); + meanMarker.reset(); + medianMarker.reset(); + + // Mark clean and dirty chart items for deletion + cleanItems.splice(deletedItems); + dirtyItems.splice(deletedItems); + series.clear(); quartileMarkers.clear(); - regressionLines.clear(); - histogramMarkers.clear(); grid.reset(); - axes.clear(); - title.reset(); } void StatsView::plot(const StatsState &stateIn) { state = stateIn; plotChart(); - plotAreaChanged(scene.sceneRect().size()); + updateFeatures(); // Show / hide chart features, such as legend, etc. + plotAreaChanged(boundingRect().size()); + update(); +} + +void StatsView::updateFeatures(const StatsState &stateIn) +{ + state = stateIn; + updateFeatures(); update(); } @@ -242,27 +414,26 @@ void StatsView::plotChart() switch (state.type) { case ChartType::DiscreteBar: return plotBarChart(dives, state.subtype, state.var1, state.var1Binner, state.var2, - state.var2Binner, state.labels, state.legend); + state.var2Binner); case ChartType::DiscreteValue: return plotValueChart(dives, state.subtype, state.var1, state.var1Binner, state.var2, - state.var2Operation, state.labels); + state.var2Operation); case ChartType::DiscreteCount: - return plotDiscreteCountChart(dives, state.subtype, state.var1, state.var1Binner, state.labels); + return plotDiscreteCountChart(dives, state.subtype, state.var1, state.var1Binner); case ChartType::Pie: - return plotPieChart(dives, state.var1, state.var1Binner, state.labels, state.legend); + return plotPieChart(dives, state.var1, state.var1Binner); case ChartType::DiscreteBox: return plotDiscreteBoxChart(dives, state.var1, state.var1Binner, state.var2); case ChartType::DiscreteScatter: - return plotDiscreteScatter(dives, state.var1, state.var1Binner, state.var2, state.quartiles); + return plotDiscreteScatter(dives, state.var1, state.var1Binner, state.var2); case ChartType::HistogramCount: - return plotHistogramCountChart(dives, state.subtype, state.var1, state.var1Binner, - state.labels, state.median, state.mean); + return plotHistogramCountChart(dives, state.subtype, state.var1, state.var1Binner); case ChartType::HistogramValue: return plotHistogramValueChart(dives, state.subtype, state.var1, state.var1Binner, state.var2, - state.var2Operation, state.labels); + state.var2Operation); case ChartType::HistogramStacked: return plotHistogramStackedChart(dives, state.subtype, state.var1, state.var1Binner, - state.var2, state.var2Binner, state.labels, state.legend); + state.var2, state.var2Binner); case ChartType::HistogramBox: return plotHistogramBoxChart(dives, state.var1, state.var1Binner, state.var2); case ChartType::ScatterPlot: @@ -275,6 +446,30 @@ void StatsView::plotChart() } } +void StatsView::updateFeatures() +{ + if (legend) + legend->setVisible(state.legend); + + // For labels, we are brutal: simply show/hide the whole z-level with the labels + if (rootNode) + rootNode->zNodes[(int)ChartZValue::SeriesLabels]->setVisible(state.labels); + + if (meanMarker) + meanMarker->setVisible(state.mean); + + if (medianMarker) + medianMarker->setVisible(state.median); + + if (regressionItem) { + regressionItem->setVisible(state.regression || state.confidence); + if (state.regression || state.confidence) + regressionItem->setFeatures(state.regression, state.confidence); + } + for (ChartItemPtr<QuartileMarker> &marker: quartileMarkers) + marker->setVisible(state.quartiles); +} + template<typename T> CategoryAxis *StatsView::createCategoryAxis(const QString &name, const StatsBinner &binner, const std::vector<T> &bins, bool isHorizontal) @@ -361,14 +556,12 @@ static std::vector<QString> makePercentageLabels(int count, int total, bool isHo // From a list of counts, make (count, label) pairs, where the label // formats the total number and the percentage of dives. -static std::vector<std::pair<int, std::vector<QString>>> makeCountLabels(const std::vector<int> &counts, int total, - bool labels, bool isHorizontal) +static std::vector<std::pair<int, std::vector<QString>>> makeCountLabels(const std::vector<int> &counts, int total, bool isHorizontal) { std::vector<std::pair<int, std::vector<QString>>> count_labels; count_labels.reserve(counts.size()); for (int count: counts) { - std::vector<QString> label = labels ? makePercentageLabels(count, total, isHorizontal) - : std::vector<QString>(); + std::vector<QString> label = makePercentageLabels(count, total, isHorizontal); count_labels.push_back(std::make_pair(count, label)); } return count_labels; @@ -377,7 +570,7 @@ static std::vector<std::pair<int, std::vector<QString>>> makeCountLabels(const s void StatsView::plotBarChart(const std::vector<dive *> &dives, ChartSubType subType, const StatsVariable *categoryVariable, const StatsBinner *categoryBinner, - const StatsVariable *valueVariable, const StatsBinner *valueBinner, bool labels, bool showLegend) + const StatsVariable *valueVariable, const StatsBinner *valueBinner) { if (!categoryBinner || !valueBinner) return; @@ -405,14 +598,13 @@ void StatsView::plotBarChart(const std::vector<dive *> &dives, setAxes(catAxis, valAxis); // Paint legend first, because the bin-names will be moved away from. - if (showLegend) - legend = createItemPtr<Legend>(&scene, data.vbinNames); + legend = createChartItem<Legend>(data.vbinNames); std::vector<BarSeries::MultiItem> items; items.reserve(data.hbin_counts.size()); double pos = 0.0; for (auto &[hbin, counts, total]: data.hbin_counts) { - items.push_back({ pos - 0.5, pos + 0.5, makeCountLabels(counts, total, labels, isHorizontal), + items.push_back({ pos - 0.5, pos + 0.5, makeCountLabels(counts, total, isHorizontal), categoryBinner->formatWithUnit(*hbin) }); pos += 1.0; } @@ -489,8 +681,7 @@ static std::pair<double, double> getMinMaxValue(const std::vector<StatsBinOp> &b void StatsView::plotValueChart(const std::vector<dive *> &dives, ChartSubType subType, const StatsVariable *categoryVariable, const StatsBinner *categoryBinner, - const StatsVariable *valueVariable, StatsOperation valueAxisOperation, - bool labels) + const StatsVariable *valueVariable, StatsOperation valueAxisOperation) { if (!categoryBinner) return; @@ -525,8 +716,7 @@ void StatsView::plotValueChart(const std::vector<dive *> &dives, if (res.isValid()) { double height = res.get(valueAxisOperation); QString value = QString("%L1").arg(height, 0, 'f', decimals); - std::vector<QString> label = labels ? std::vector<QString> { value } - : std::vector<QString>(); + std::vector<QString> label = std::vector<QString> { value }; items.push_back({ pos - 0.5, pos + 0.5, height, label, categoryBinner->formatWithUnit(*bin), res }); } @@ -557,8 +747,7 @@ static int getMaxCount(const std::vector<T> &bins) void StatsView::plotDiscreteCountChart(const std::vector<dive *> &dives, ChartSubType subType, - const StatsVariable *categoryVariable, const StatsBinner *categoryBinner, - bool labels) + const StatsVariable *categoryVariable, const StatsBinner *categoryBinner) { if (!categoryBinner) return; @@ -589,8 +778,7 @@ void StatsView::plotDiscreteCountChart(const std::vector<dive *> &dives, items.reserve(categoryBins.size()); double pos = 0.0; for (auto const &[bin, count]: categoryBins) { - std::vector<QString> label = labels ? makePercentageLabels(count, total, isHorizontal) - : std::vector<QString>(); + std::vector<QString> label = makePercentageLabels(count, total, isHorizontal); items.push_back({ pos - 0.5, pos + 0.5, count, label, categoryBinner->formatWithUnit(*bin), total }); pos += 1.0; @@ -600,8 +788,7 @@ void StatsView::plotDiscreteCountChart(const std::vector<dive *> &dives, } void StatsView::plotPieChart(const std::vector<dive *> &dives, - const StatsVariable *categoryVariable, const StatsBinner *categoryBinner, - bool labels, bool showLegend) + const StatsVariable *categoryVariable, const StatsBinner *categoryBinner) { if (!categoryBinner) return; @@ -620,10 +807,9 @@ void StatsView::plotPieChart(const std::vector<dive *> &dives, data.emplace_back(categoryBinner->formatWithUnit(*bin), count); bool keepOrder = categoryVariable->type() != StatsVariable::Type::Discrete; - PieSeries *series = createSeries<PieSeries>(categoryVariable->name(), data, keepOrder, labels); + PieSeries *series = createSeries<PieSeries>(categoryVariable->name(), data, keepOrder); - if (showLegend) - legend = createItemPtr<Legend>(&scene, series->binNames()); + legend = createChartItem<Legend>(series->binNames()); } void StatsView::plotDiscreteBoxChart(const std::vector<dive *> &dives, @@ -662,7 +848,7 @@ void StatsView::plotDiscreteBoxChart(const std::vector<dive *> &dives, void StatsView::plotDiscreteScatter(const std::vector<dive *> &dives, const StatsVariable *categoryVariable, const StatsBinner *categoryBinner, - const StatsVariable *valueVariable, bool quartiles) + const StatsVariable *valueVariable) { if (!categoryBinner) return; @@ -690,118 +876,19 @@ void StatsView::plotDiscreteScatter(const std::vector<dive *> &dives, for (const auto &[bin, array]: categoryBins) { for (auto [v, d]: array) series->append(d, x, v); - if (quartiles) { - StatsQuartiles quartiles = StatsVariable::quartiles(array); - if (quartiles.isValid()) { - quartileMarkers.emplace_back(x, quartiles.q1, &scene, catAxis, valAxis); - quartileMarkers.emplace_back(x, quartiles.q2, &scene, catAxis, valAxis); - quartileMarkers.emplace_back(x, quartiles.q3, &scene, catAxis, valAxis); - } + StatsQuartiles quartiles = StatsVariable::quartiles(array); + if (quartiles.isValid()) { + quartileMarkers.push_back(createChartItem<QuartileMarker>( + x, quartiles.q1, catAxis, valAxis)); + quartileMarkers.push_back(createChartItem<QuartileMarker>( + x, quartiles.q2, catAxis, valAxis)); + quartileMarkers.push_back(createChartItem<QuartileMarker>( + x, quartiles.q3, catAxis, valAxis)); } x += 1.0; } } -StatsView::QuartileMarker::QuartileMarker(double pos, double value, QGraphicsScene *scene, StatsAxis *xAxis, StatsAxis *yAxis) : - item(createItemPtr<QGraphicsLineItem>(scene)), - xAxis(xAxis), yAxis(yAxis), - pos(pos), - value(value) -{ - item->setZValue(ZValues::chartFeatures); - item->setPen(QPen(quartileMarkerColor, 2.0)); - updatePosition(); -} - -void StatsView::QuartileMarker::updatePosition() -{ - if (!xAxis || !yAxis) - return; - double x = xAxis->toScreen(pos); - double y = yAxis->toScreen(value); - item->setLine(x - quartileMarkerSize / 2.0, y, - x + quartileMarkerSize / 2.0, y); -} - -StatsView::RegressionLine::RegressionLine(const struct regression_data reg, QBrush brush, QGraphicsScene *scene, StatsAxis *xAxis, StatsAxis *yAxis) : - item(createItemPtr<QGraphicsPolygonItem>(scene)), - central(createItemPtr<QGraphicsPolygonItem>(scene)), - xAxis(xAxis), yAxis(yAxis), - reg(reg) -{ - item->setZValue(ZValues::chartFeatures); - item->setPen(Qt::NoPen); - item->setBrush(brush); - - central->setZValue(ZValues::chartFeatures+1); - central->setPen(QPen(Qt::red)); -} - -void StatsView::RegressionLine::updatePosition() -{ - if (!xAxis || !yAxis) - return; - auto [minX, maxX] = xAxis->minMax(); - auto [minY, maxY] = yAxis->minMax(); - - QPolygonF line; - line << QPoint(xAxis->toScreen(minX), yAxis->toScreen(reg.a * minX + reg.b)) - << QPoint(xAxis->toScreen(maxX), yAxis->toScreen(reg.a * maxX + reg.b)); - - // Draw the confidence interval according to http://www2.stat.duke.edu/~tjl13/s101/slides/unit6lec3H.pdf p.5 with t*=2 for 95% confidence - QPolygonF poly; - for (double x = minX; x <= maxX + 1; x += (maxX - minX) / 100) - poly << QPointF(xAxis->toScreen(x), - yAxis->toScreen(reg.a * x + reg.b + 1.960 * sqrt(reg.res2 / (reg.n - 2) * (1.0 / reg.n + (x - reg.xavg) * (x - reg.xavg) / (reg.n - 1) * (reg.n -2) / reg.sx2)))); - for (double x = maxX; x >= minX - 1; x -= (maxX - minX) / 100) - poly << QPointF(xAxis->toScreen(x), - yAxis->toScreen(reg.a * x + reg.b - 1.960 * sqrt(reg.res2 / (reg.n - 2) * (1.0 / reg.n + (x - reg.xavg) * (x - reg.xavg) / (reg.n - 1) * (reg.n -2) / reg.sx2)))); - QRectF box(QPoint(xAxis->toScreen(minX), yAxis->toScreen(minY)), QPoint(xAxis->toScreen(maxX), yAxis->toScreen(maxY))); - - item->setPolygon(poly.intersected(box)); - central->setPolygon(line.intersected(box)); -} - -StatsView::HistogramMarker::HistogramMarker(double val, bool horizontal, QPen pen, QGraphicsScene *scene, StatsAxis *xAxis, StatsAxis *yAxis) : - item(createItemPtr<QGraphicsLineItem>(scene)), - xAxis(xAxis), yAxis(yAxis), - val(val), horizontal(horizontal) -{ - item->setZValue(ZValues::chartFeatures); - item->setPen(pen); -} - -void StatsView::HistogramMarker::updatePosition() -{ - if (!xAxis || !yAxis) - return; - if (horizontal) { - double y = yAxis->toScreen(val); - auto [x1, x2] = xAxis->minMaxScreen(); - item->setLine(x1, y, x2, y); - } else { - double x = xAxis->toScreen(val); - auto [y1, y2] = yAxis->minMaxScreen(); - item->setLine(x, y1, x, y2); - } -} - -void StatsView::addHistogramMarker(double pos, const QPen &pen, bool isHorizontal, StatsAxis *xAxis, StatsAxis *yAxis) -{ - histogramMarkers.emplace_back(pos, isHorizontal, pen, &scene, xAxis, yAxis); -} - -void StatsView::addLinearRegression(const struct regression_data reg, StatsAxis *xAxis, StatsAxis *yAxis) -{ - QColor red = QColor(Qt::red); - red.setAlphaF(reg.r2); - QPen pen(red); - QBrush brush(red); - brush.setStyle(Qt::SolidPattern); - - regressionLines.emplace_back(reg, brush, &scene, xAxis, yAxis); -} - // Yikes, we get our data in different kinds of (bin, value) pairs. // To create a category axis from this, we have to templatify the function. template<typename T> @@ -826,8 +913,7 @@ HistogramAxis *StatsView::createHistogramAxis(const QString &name, const StatsBi void StatsView::plotHistogramCountChart(const std::vector<dive *> &dives, ChartSubType subType, - const StatsVariable *categoryVariable, const StatsBinner *categoryBinner, - bool labels, bool showMedian, bool showMean) + const StatsVariable *categoryVariable, const StatsBinner *categoryBinner) { if (!categoryBinner) return; @@ -860,8 +946,7 @@ void StatsView::plotHistogramCountChart(const std::vector<dive *> &dives, for (auto const &[bin, count]: categoryBins) { double lowerBound = categoryBinner->lowerBoundToFloat(*bin); double upperBound = categoryBinner->upperBoundToFloat(*bin); - std::vector<QString> label = labels ? makePercentageLabels(count, total, isHorizontal) - : std::vector<QString>(); + std::vector<QString> label = makePercentageLabels(count, total, isHorizontal); items.push_back({ lowerBound, upperBound, count, label, categoryBinner->formatWithUnit(*bin), total }); @@ -870,28 +955,19 @@ void StatsView::plotHistogramCountChart(const std::vector<dive *> &dives, createSeries<BarSeries>(isHorizontal, categoryVariable->name(), items); if (categoryVariable->type() == StatsVariable::Type::Numeric) { - if (showMean) { - double mean = categoryVariable->mean(dives); - QPen meanPen(Qt::green); - meanPen.setWidth(2); - if (!std::isnan(mean)) - addHistogramMarker(mean, meanPen, isHorizontal, xAxis, yAxis); - } - if (showMedian) { - double median = categoryVariable->quartiles(dives).q2; - QPen medianPen(Qt::red); - medianPen.setWidth(2); - if (!std::isnan(median)) - addHistogramMarker(median, medianPen, isHorizontal, xAxis, yAxis); - } + double mean = categoryVariable->mean(dives); + if (!std::isnan(mean)) + meanMarker = createChartItem<HistogramMarker>(mean, isHorizontal, meanMarkerColor, xAxis, yAxis); + double median = categoryVariable->quartiles(dives).q2; + if (!std::isnan(median)) + medianMarker = createChartItem<HistogramMarker>(median, isHorizontal, medianMarkerColor, xAxis, yAxis); } } void StatsView::plotHistogramValueChart(const std::vector<dive *> &dives, ChartSubType subType, const StatsVariable *categoryVariable, const StatsBinner *categoryBinner, - const StatsVariable *valueVariable, StatsOperation valueAxisOperation, - bool labels) + const StatsVariable *valueVariable, StatsOperation valueAxisOperation) { if (!categoryBinner) return; @@ -930,8 +1006,7 @@ void StatsView::plotHistogramValueChart(const std::vector<dive *> &dives, double lowerBound = categoryBinner->lowerBoundToFloat(*bin); double upperBound = categoryBinner->upperBoundToFloat(*bin); QString value = QString("%L1").arg(height, 0, 'f', decimals); - std::vector<QString> label = labels ? std::vector<QString> { value } - : std::vector<QString>(); + std::vector<QString> label = std::vector<QString> { value }; items.push_back({ lowerBound, upperBound, height, label, categoryBinner->formatWithUnit(*bin), res }); } @@ -942,7 +1017,7 @@ void StatsView::plotHistogramValueChart(const std::vector<dive *> &dives, void StatsView::plotHistogramStackedChart(const std::vector<dive *> &dives, ChartSubType subType, const StatsVariable *categoryVariable, const StatsBinner *categoryBinner, - const StatsVariable *valueVariable, const StatsBinner *valueBinner, bool labels, bool showLegend) + const StatsVariable *valueVariable, const StatsBinner *valueBinner) { if (!categoryBinner || !valueBinner) return; @@ -958,8 +1033,7 @@ void StatsView::plotHistogramStackedChart(const std::vector<dive *> &dives, *categoryBinner, categoryBins, !isHorizontal); BarPlotData data(categoryBins, *valueBinner); - if (showLegend) - legend = createItemPtr<Legend>(&scene, data.vbinNames); + legend = createChartItem<Legend>(data.vbinNames); CountAxis *valAxis = createCountAxis(data.maxCategoryCount, isHorizontal); @@ -974,7 +1048,7 @@ void StatsView::plotHistogramStackedChart(const std::vector<dive *> &dives, for (auto &[hbin, counts, total]: data.hbin_counts) { double lowerBound = categoryBinner->lowerBoundToFloat(*hbin); double upperBound = categoryBinner->upperBoundToFloat(*hbin); - items.push_back({ lowerBound, upperBound, makeCountLabels(counts, total, labels, isHorizontal), + items.push_back({ lowerBound, upperBound, makeCountLabels(counts, total, isHorizontal), categoryBinner->formatWithUnit(*hbin) }); } @@ -1110,5 +1184,5 @@ void StatsView::plotScatter(const std::vector<dive *> &dives, const StatsVariabl // y = ax + b struct regression_data reg = linear_regression(points); if (!std::isnan(reg.a)) - addLinearRegression(reg, xAxis, yAxis); + regressionItem = createChartItem<RegressionItem>(reg, xAxis, yAxis); } diff --git a/stats/statsview.h b/stats/statsview.h index b1e178565..0af91b382 100644 --- a/stats/statsview.h +++ b/stats/statsview.h @@ -3,13 +3,12 @@ #define STATS_VIEW_H #include "statsstate.h" +#include "statshelper.h" #include <memory> #include <QFont> -#include <QGraphicsScene> #include <QImage> #include <QPainter> #include <QQuickItem> -#include <QGraphicsPolygonItem> struct dive; struct StatsBinner; @@ -17,27 +16,25 @@ struct StatsBin; struct StatsState; struct StatsVariable; -class QGraphicsLineItem; -class QGraphicsSimpleTextItem; class StatsSeries; class CategoryAxis; +class ChartItem; +class ChartTextItem; class CountAxis; class HistogramAxis; +class HistogramMarker; +class QuartileMarker; +class RegressionItem; class StatsAxis; class StatsGrid; class Legend; class QSGTexture; +class RootNode; // Internal implementation detail enum class ChartSubType : int; +enum class ChartZValue : int; enum class StatsOperation : int; -struct regression_data { - double a,b; - double res2, r2, sx2, xavg; - int n; -}; - - class StatsView : public QQuickItem { Q_OBJECT public: @@ -46,16 +43,32 @@ public: ~StatsView(); void plot(const StatsState &state); + void updateFeatures(const StatsState &state); // Updates the visibility of chart features, such as legend, regression, etc. + QQuickWindow *w() const; // Make window available to items + QSizeF size() const; + QRectF plotArea() const; + void addQSGNode(QSGNode *node, ChartZValue z); // Must only be called in render thread! + void registerChartItem(ChartItem &item); + void registerDirtyChartItem(ChartItem &item); + + // Create a chart item and add it to the scene. + // The item must not be deleted by the caller, but can be + // scheduled for deletion using deleteChartItem() below. + // Most items can be made invisible, which is preferred over deletion. + // All items on the scene will be deleted once the chart is reset. + template <typename T, class... Args> + ChartItemPtr<T> createChartItem(Args&&... args); + + template <typename T> + void deleteChartItem(ChartItemPtr<T> &item); private slots: void replotIfVisible(); private: + bool resetChart; + // QtQuick related things QRectF plotRect; QSGNode *updatePaintNode(QSGNode *oldNode, QQuickItem::UpdatePaintNodeData *updatePaintNodeData) override; - std::unique_ptr<QImage> img; - std::unique_ptr<QPainter> painter; - QGraphicsScene scene; - std::unique_ptr<QSGTexture> texture; void plotAreaChanged(const QSizeF &size); void reset(); // clears all series and axes @@ -63,39 +76,39 @@ private: void plotBarChart(const std::vector<dive *> &dives, ChartSubType subType, const StatsVariable *categoryVariable, const StatsBinner *categoryBinner, - const StatsVariable *valueVariable, const StatsBinner *valueBinner, bool labels, bool legend); + const StatsVariable *valueVariable, const StatsBinner *valueBinner); void plotValueChart(const std::vector<dive *> &dives, ChartSubType subType, const StatsVariable *categoryVariable, const StatsBinner *categoryBinner, - const StatsVariable *valueVariable, StatsOperation valueAxisOperation, bool labels); + const StatsVariable *valueVariable, StatsOperation valueAxisOperation); void plotDiscreteCountChart(const std::vector<dive *> &dives, ChartSubType subType, - const StatsVariable *categoryVariable, const StatsBinner *categoryBinner, bool labels); + const StatsVariable *categoryVariable, const StatsBinner *categoryBinner); void plotPieChart(const std::vector<dive *> &dives, - const StatsVariable *categoryVariable, const StatsBinner *categoryBinner, bool labels, bool legend); + const StatsVariable *categoryVariable, const StatsBinner *categoryBinner); void plotDiscreteBoxChart(const std::vector<dive *> &dives, const StatsVariable *categoryVariable, const StatsBinner *categoryBinner, const StatsVariable *valueVariable); void plotDiscreteScatter(const std::vector<dive *> &dives, const StatsVariable *categoryVariable, const StatsBinner *categoryBinner, - const StatsVariable *valueVariable, bool quartiles); + const StatsVariable *valueVariable); void plotHistogramCountChart(const std::vector<dive *> &dives, ChartSubType subType, - const StatsVariable *categoryVariable, const StatsBinner *categoryBinner, - bool labels, bool showMedian, bool showMean); + const StatsVariable *categoryVariable, const StatsBinner *categoryBinner); void plotHistogramValueChart(const std::vector<dive *> &dives, ChartSubType subType, const StatsVariable *categoryVariable, const StatsBinner *categoryBinner, - const StatsVariable *valueVariable, StatsOperation valueAxisOperation, bool labels); + const StatsVariable *valueVariable, StatsOperation valueAxisOperation); void plotHistogramStackedChart(const std::vector<dive *> &dives, ChartSubType subType, const StatsVariable *categoryVariable, const StatsBinner *categoryBinner, - const StatsVariable *valueVariable, const StatsBinner *valueBinner, bool labels, bool legend); + const StatsVariable *valueVariable, const StatsBinner *valueBinner); void plotHistogramBoxChart(const std::vector<dive *> &dives, const StatsVariable *categoryVariable, const StatsBinner *categoryBinner, const StatsVariable *valueVariable); void plotScatter(const std::vector<dive *> &dives, const StatsVariable *categoryVariable, const StatsVariable *valueVariable); void setTitle(const QString &); void updateTitlePos(); // After resizing, set title to correct position void plotChart(); + void updateFeatures(); // Updates the visibility of chart features, such as legend, regression, etc. template <typename T, class... Args> T *createSeries(Args&&... args); @@ -114,53 +127,55 @@ private: // Helper functions to add feature to the chart void addLineMarker(double pos, double low, double high, const QPen &pen, bool isHorizontal); - // A short line used to mark quartiles - struct QuartileMarker { - std::unique_ptr<QGraphicsLineItem> item; - StatsAxis *xAxis, *yAxis; - double pos, value; - QuartileMarker(double pos, double value, QGraphicsScene *scene, StatsAxis *xAxis, StatsAxis *yAxis); - void updatePosition(); - }; - - // A regression line - struct RegressionLine { - std::unique_ptr<QGraphicsPolygonItem> item; - std::unique_ptr<QGraphicsPolygonItem> central; - StatsAxis *xAxis, *yAxis; - const struct regression_data reg; - void updatePosition(); - RegressionLine(const struct regression_data reg, QBrush brush, QGraphicsScene *scene, StatsAxis *xAxis, StatsAxis *yAxis); - }; - - // A line marking median or mean in histograms - struct HistogramMarker { - std::unique_ptr<QGraphicsLineItem> item; - StatsAxis *xAxis, *yAxis; - double val; - bool horizontal; - void updatePosition(); - HistogramMarker(double val, bool horizontal, QPen pen, QGraphicsScene *scene, StatsAxis *xAxis, StatsAxis *yAxis); - }; - - void addLinearRegression(const struct regression_data reg, StatsAxis *xAxis, StatsAxis *yAxis); - void addHistogramMarker(double pos, const QPen &pen, bool isHorizontal, StatsAxis *xAxis, StatsAxis *yAxis); StatsState state; QFont titleFont; - std::vector<std::unique_ptr<StatsAxis>> axes; - std::unique_ptr<StatsGrid> grid; std::vector<std::unique_ptr<StatsSeries>> series; - std::unique_ptr<Legend> legend; - std::vector<QuartileMarker> quartileMarkers; - std::vector<RegressionLine> regressionLines; - std::vector<HistogramMarker> histogramMarkers; - std::unique_ptr<QGraphicsSimpleTextItem> title; + std::unique_ptr<StatsGrid> grid; + std::vector<ChartItemPtr<QuartileMarker>> quartileMarkers; + ChartItemPtr<HistogramMarker> medianMarker, meanMarker; StatsSeries *highlightedSeries; StatsAxis *xAxis, *yAxis; + ChartItemPtr<ChartTextItem> title; + ChartItemPtr<Legend> legend; + Legend *draggedItem; + ChartItemPtr<RegressionItem> regressionItem; + QPointF dragStartMouse, dragStartItem; void hoverEnterEvent(QHoverEvent *event) override; void hoverMoveEvent(QHoverEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + RootNode *rootNode; + + // There are three double linked lists of chart items: + // clean items, dirty items and items to be deleted. + struct ChartItemList { + ChartItemList(); + ChartItem *first, *last; + void append(ChartItem &item); + void remove(ChartItem &item); + void clear(); + void splice(ChartItemList &list); + }; + ChartItemList cleanItems, dirtyItems, deletedItems; + void deleteChartItemInternal(ChartItem &item); }; +// This implementation detail must be known to users of the class. +// Perhaps move it into a statsview_impl.h file. +template <typename T, class... Args> +ChartItemPtr<T> StatsView::createChartItem(Args&&... args) +{ + return ChartItemPtr(new T(*this, std::forward<Args>(args)...)); +} + +template <typename T> +void StatsView::deleteChartItem(ChartItemPtr<T> &item) +{ + deleteChartItemInternal(*item); + item.reset(); +} + #endif diff --git a/stats/zvalues.h b/stats/zvalues.h index 118c488c0..58222f5ab 100644 --- a/stats/zvalues.h +++ b/stats/zvalues.h @@ -2,17 +2,18 @@ // Defines the z-values of features in the chart view. // Objects with higher z-values are painted on top of objects // with smaller z-values. For the same z-value objects are -// drawn in order of addition to the graphics scene. +// drawn in order of addition to the scene. #ifndef ZVALUES_H -struct ZValues { - static constexpr double grid = -1.0; - static constexpr double series = 0.0; - static constexpr double axes = 1.0; - static constexpr double seriesLabels = 2.0; - static constexpr double chartFeatures = 3.0; // quartile markers and regression lines - static constexpr double informationBox = 4.0; - static constexpr double legend = 5.0; +enum class ChartZValue { + Grid = 0, + Series, + Axes, + SeriesLabels, + ChartFeatures, // quartile markers and regression lines + InformationBox, + Legend, + Count }; #endif diff --git a/subsurface-helper.cpp b/subsurface-helper.cpp index c3249f03f..11f471a8b 100644 --- a/subsurface-helper.cpp +++ b/subsurface-helper.cpp @@ -25,6 +25,7 @@ #include "profile-widget/qmlprofile.h" #include "core/downloadfromdcthread.h" #include "core/subsurfacestartup.h" // for testqml +#include "core/metrics.h" #include "qt-models/diveimportedmodel.h" #else #include "desktop-widgets/mainwindow.h" @@ -67,12 +68,14 @@ void exit_ui() free((void *)existing_filename); } -void run_ui() -{ #ifdef SUBSURFACE_MOBILE +void run_mobile_ui(double initial_font_size) +{ #if defined(Q_OS_ANDROID) // work around an odd interaction between the OnePlus flavor of Android and Qt font handling if (getAndroidHWInfo().contains("/OnePlus/")) { + QFontInfo qfi(defaultModelFont()); + double basePointSize = qfi.pointSize(); QFontDatabase db; int id = QFontDatabase::addApplicationFont(":/fonts/Roboto-Regular.ttf"); QStringList fontFamilies = QFontDatabase::applicationFontFamilies(id); @@ -80,6 +83,7 @@ void run_ui() QString family = fontFamilies.at(0); QFont newDefaultFont; newDefaultFont.setFamily(family); + newDefaultFont.setPointSize(basePointSize); (static_cast<QApplication *>(QCoreApplication::instance()))->setFont(newDefaultFont); qDebug() << "Detected OnePlus device, trying to force bundled font" << family; QFont defaultFont = (static_cast<QApplication *>(QCoreApplication::instance()))->font(); @@ -123,9 +127,13 @@ void run_ui() ctxt->setContextProperty("diveModel", MobileModels::instance()->listModel()); set_non_bt_addresses(); + // we need to setup the initial font size before the QML UI is instantiated + ThemeInterface *themeInterface = ThemeInterface::instance(); + themeInterface->setInitialFontSize(initial_font_size); + ctxt->setContextProperty("connectionListModel", &connectionListModel); ctxt->setContextProperty("logModel", MessageHandlerModel::self()); - ctxt->setContextProperty("subsurfaceTheme", ThemeInterface::instance()); + ctxt->setContextProperty("subsurfaceTheme", themeInterface); qmlRegisterUncreatableType<QMLManager>("org.subsurfacedivelog.mobile",1,0,"ExportType","Enum is not a type"); @@ -183,11 +191,16 @@ void run_ui() qml_window->setWidth(width); #endif // not Q_OS_ANDROID and not Q_OS_IOS qml_window->show(); -#else + qApp->exec(); +} +#else // SUBSURFACE_MOBILE +// just run the desktop UI +void run_ui() +{ MainWindow::instance()->show(); -#endif // SUBSURFACE_MOBILE qApp->exec(); } +#endif // SUBSURFACE_MOBILE Q_DECLARE_METATYPE(duration_t) static void register_meta_types() diff --git a/subsurface-mobile-main.cpp b/subsurface-mobile-main.cpp index 7ceebcc5f..454996fc7 100644 --- a/subsurface-mobile-main.cpp +++ b/subsurface-mobile-main.cpp @@ -19,6 +19,8 @@ #include "core/settings/qPrefCloudStorage.h" #include <QApplication> +#include <QFont> +#include <QFontMetrics> #include <QLocale> #include <QLoggingCategory> #include <QStringList> @@ -60,6 +62,17 @@ int main(int argc, char **argv) parse_xml_init(); taglist_init_global(); + + // grab the system font size before we overwrite this when we load preferences + double initial_font_size = QGuiApplication::font().pointSizeF(); + if (initial_font_size < 0.0) { + // The OS provides a default font in pixels, not points; doing some crude math + // to reverse engineer that information by measuring the height of a 10pt font in pixels + QFont testFont; + testFont.setPointSizeF(10.0); + QFontMetrics fm(testFont); + initial_font_size = QGuiApplication::font().pixelSize() * 10.0 / fm.height(); + } init_ui(); if (prefs.default_file_behavior == LOCAL_DEFAULT_FILE) set_filename(prefs.default_filename); @@ -76,7 +89,7 @@ int main(int argc, char **argv) init_proxy(); if (!quit) - run_ui(); + run_mobile_ui(initial_font_size); exit_ui(); taglist_free(g_tag_list); parse_xml_exit(); diff --git a/subsurface.qrc b/subsurface.qrc index 2a6f91428..462857b88 100644 --- a/subsurface.qrc +++ b/subsurface.qrc @@ -100,15 +100,5 @@ <file alias="gps_good_result-icon">icons/resultgreen.png</file> <file alias="gps_warning_result-icon">icons/resultyellow.png</file> <file alias="gps_bad_result-icon">icons/resultred.png</file> - <file alias="chart-bar-grouped-horizontal-icon">icons/chart_bar_grouped_horizontal.svg</file> - <file alias="chart-bar-grouped-vertical-icon">icons/chart_bar_grouped_vertical.svg</file> - <file alias="chart-bar-stacked-horizontal-icon">icons/chart_bar_stacked_horizontal.svg</file> - <file alias="chart-bar-stacked-vertical-icon">icons/chart_bar_stacked_vertical.svg</file> - <file alias="chart-bar-horizontal-icon">icons/chart_bar_horizontal.svg</file> - <file alias="chart-bar-vertical-icon">icons/chart_bar_vertical.svg</file> - <file alias="chart-box-icon">icons/chart_box.svg</file> - <file alias="chart-pie-icon">icons/chart_pie.svg</file> - <file alias="chart-points-icon">icons/chart_points.svg</file> - <file alias="chart-warning-icon">icons/warning-icon.svg</file> </qresource> </RCC> |