From b0bdef469ee142f09b3994eddc8dba0d5c6f79c3 Mon Sep 17 00:00:00 2001 From: Berthold Stoeger Date: Fri, 1 Jan 2021 22:23:29 +0100 Subject: statistics: implement a box-and-whisker series Implements a simple box-and-whisker series to display quartile based data. When hovering over a box-and-whiskers item the precise data of the quartiles is shown. Signed-off-by: Berthold Stoeger --- stats/CMakeLists.txt | 2 + stats/boxseries.cpp | 165 +++++++++++++++++++++++++++++++++++++++++++++++++++ stats/boxseries.h | 61 +++++++++++++++++++ 3 files changed, 228 insertions(+) create mode 100644 stats/boxseries.cpp create mode 100644 stats/boxseries.h (limited to 'stats') diff --git a/stats/CMakeLists.txt b/stats/CMakeLists.txt index 03ef73b28..b672e4a53 100644 --- a/stats/CMakeLists.txt +++ b/stats/CMakeLists.txt @@ -7,6 +7,8 @@ include_directories(. set(SUBSURFACE_STATS_SRCS barseries.h barseries.cpp + boxseries.h + boxseries.cpp informationbox.h informationbox.cpp legend.h diff --git a/stats/boxseries.cpp b/stats/boxseries.cpp new file mode 100644 index 000000000..99651345b --- /dev/null +++ b/stats/boxseries.cpp @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: GPL-2.0 +#include "boxseries.h" +#include "informationbox.h" +#include "statscolors.h" +#include "statstranslations.h" +#include "zvalues.h" + +#include +#include + +// Constants that control the bar layout +static const double boxWidth = 0.8; // 1.0 = full width of category +static const int boxBorderWidth = 2; + +BoxSeries::BoxSeries(QtCharts::QChart *chart, StatsAxis *xAxis, StatsAxis *yAxis, + const QString &variable, const QString &unit, int decimals) : + StatsSeries(chart, xAxis, yAxis), + variable(variable), unit(unit), decimals(decimals), highlighted(-1) +{ +} + +BoxSeries::~BoxSeries() +{ +} + +BoxSeries::Item::Item(QtCharts::QChart *chart, BoxSeries *series, double lowerBound, double upperBound, + const StatsQuartiles &q, const QString &binName) : + box(chart), + topWhisker(chart), bottomWhisker(chart), + topBar(chart), bottomBar(chart), + center(chart), + 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); + highlight(false); + updatePosition(chart, series); +} + +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); +} + +void BoxSeries::Item::updatePosition(QtCharts::QChart *chart, BoxSeries *series) +{ + 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 = chart->mapToPosition(QPointF(from, q.max), series); + bottomRight = chart->mapToPosition(QPointF(to, q.min), series); + 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 = chart->mapToPosition(QPointF(mid, q.q1), series); + QPointF q2 = chart->mapToPosition(QPointF(mid, q.q2), series); + QPointF q3 = chart->mapToPosition(QPointF(mid, q.q3), series); + 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()); +} + +void BoxSeries::append(double lowerBound, double upperBound, const StatsQuartiles &q, const QString &binName) +{ + QtCharts::QChart *c = chart(); + items.emplace_back(new Item(c, this, lowerBound, upperBound, q, binName)); +} + +void BoxSeries::updatePositions() +{ + QtCharts::QChart *c = chart(); + for (auto &item: items) + item->updatePosition(c, this); +} + +// Attention: this supposes that items are sorted by position and no box is inside another box! +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, double x) { return item->bounding.right() < x; }); + return it != items.end() && (*it)->bounding.contains(point) ? it - items.begin() : -1; +} + +static QString infoItem(const QString &name, const QString &unit, int decimals, double value) +{ + QLocale loc; + QString formattedValue = loc.toString(value, 'f', decimals); + return unit.isEmpty() ? QStringLiteral(" %1: %2").arg(name, formattedValue) + : QStringLiteral(" %1: %2 %3").arg(name, formattedValue, unit); +} + +std::vector BoxSeries::formatInformation(const Item &item) const +{ + QLocale loc; + return { + item.binName, + QStringLiteral("%1:").arg(variable), + infoItem(StatsTranslations::tr("min"), unit, decimals, item.q.min), + infoItem(StatsTranslations::tr("Q1"), unit, decimals, item.q.q1), + infoItem(StatsTranslations::tr("mean"), unit, decimals, item.q.q2), + infoItem(StatsTranslations::tr("Q3"), unit, decimals, item.q.q3), + infoItem(StatsTranslations::tr("max"), unit, decimals, item.q.max) + }; +} + +// Highlight item when hovering over item +bool BoxSeries::hover(QPointF pos) +{ + int index = getItemUnderMouse(pos); + if (index == highlighted) { + if (information) + information->setPos(pos); + return index >= 0; + } + + unhighlight(); + highlighted = index; + + // Highlight new item (if any) + if (highlighted >= 0 && highlighted < (int)items.size()) { + Item &item = *items[highlighted]; + item.highlight(true); + if (!information) + information.reset(new InformationBox(chart())); + information->setText(formatInformation(item), pos); + } else { + information.reset(); + } + return highlighted >= 0; +} + +void BoxSeries::unhighlight() +{ + if (highlighted >= 0 && highlighted < (int)items.size()) + items[highlighted]->highlight(false); + highlighted = -1; +} diff --git a/stats/boxseries.h b/stats/boxseries.h new file mode 100644 index 000000000..964e3aec1 --- /dev/null +++ b/stats/boxseries.h @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: GPL-2.0 +// A small custom box plot series, which displays quartiles. +// The QtCharts bar series were too inflexible with respect +// to placement of the bars. +#ifndef BOX_SERIES_H +#define BOX_SERIES_H + +#include "statsseries.h" +#include "statsvariables.h" // for StatsQuartiles + +#include +#include +#include +#include + +class InformationBox; + +class BoxSeries : public StatsSeries { +public: + BoxSeries(QtCharts::QChart *chart, StatsAxis *xAxis, StatsAxis *yAxis, + const QString &variable, const QString &unit, int decimals); + ~BoxSeries(); + + void updatePositions() override; + bool hover(QPointF pos) override; + void unhighlight() override; + + // 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. + void append(double lowerBound, double upperBound, const StatsQuartiles &q, const QString &binName); + +private: + // Get item under mouse pointer, or -1 if none + 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(); + double lowerBound, upperBound; + StatsQuartiles q; + QString binName; + Item(QtCharts::QChart *chart, BoxSeries *series, double lowerBound, double upperBound, const StatsQuartiles &q, const QString &binName); + void updatePosition(QtCharts::QChart *chart, BoxSeries *series); + void highlight(bool highlight); + }; + + QString variable, unit; + int decimals; + + std::vector formatInformation(const Item &item) const; + std::unique_ptr information; + std::vector> items; + int highlighted; // -1: no item highlighted +}; + +#endif -- cgit v1.2.3-70-g09d2