diff options
Diffstat (limited to 'stats')
35 files changed, 1982 insertions, 861 deletions
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) +{ + 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 { - // 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); + 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 |