diff options
author | Berthold Stoeger <bstoeger@mail.tuwien.ac.at> | 2021-01-01 22:18:51 +0100 |
---|---|---|
committer | Dirk Hohndel <dirk@hohndel.org> | 2021-01-02 11:04:03 -0800 |
commit | ca572acb0d23aef77fda896d08f2b1af360c5e99 (patch) | |
tree | 9f8de5b0a31e9a293bac29a6fe4c4e58bad3e018 /stats | |
parent | 99f98ea6d482c7bb5526fad8246e12bb8714b252 (diff) | |
download | subsurface-ca572acb0d23aef77fda896d08f2b1af360c5e99.tar.gz |
statistics: implement a bar series
Implement a bar series, which can plot stacked, grouped and single
bar charts in horizontal or vertical ways. On hovering over a
bar, an information is shown. The shown information depends on
whether the chart is count or value based, or is a multi-bin
chart.
Signed-off-by: Berthold Stoeger <bstoeger@mail.tuwien.ac.at>
Diffstat (limited to 'stats')
-rw-r--r-- | stats/CMakeLists.txt | 2 | ||||
-rw-r--r-- | stats/barseries.cpp | 422 | ||||
-rw-r--r-- | stats/barseries.h | 140 |
3 files changed, 564 insertions, 0 deletions
diff --git a/stats/CMakeLists.txt b/stats/CMakeLists.txt index e8f49e35f..03ef73b28 100644 --- a/stats/CMakeLists.txt +++ b/stats/CMakeLists.txt @@ -5,6 +5,8 @@ include_directories(. ) set(SUBSURFACE_STATS_SRCS + barseries.h + barseries.cpp informationbox.h informationbox.cpp legend.h diff --git a/stats/barseries.cpp b/stats/barseries.cpp new file mode 100644 index 000000000..231990d02 --- /dev/null +++ b/stats/barseries.cpp @@ -0,0 +1,422 @@ +// SPDX-License-Identifier: GPL-2.0 +#include "barseries.h" +#include "informationbox.h" +#include "statscolors.h" +#include "statstranslations.h" +#include "zvalues.h" + +#include <math.h> // for lrint() +#include <QChart> +#include <QLocale> + +// 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 + +// Default constructor: invalid index. +BarSeries::Index::Index() : bar(-1), subitem(-1) +{ +} + +BarSeries::Index::Index(int bar, int subitem) : bar(bar), subitem(subitem) +{ +} + +bool BarSeries::Index::operator==(const Index &i2) const +{ + return std::tie(bar, subitem) == std::tie(i2.bar, i2.subitem); +} + +BarSeries::BarSeries(QtCharts::QChart *chart, StatsAxis *xAxis, StatsAxis *yAxis, + bool horizontal, bool stacked, const QString &categoryName, + const StatsVariable *valueVariable, std::vector<QString> valueBinNames) : + StatsSeries(chart, xAxis, yAxis), + horizontal(horizontal), stacked(stacked), categoryName(categoryName), + valueVariable(valueVariable), valueBinNames(std::move(valueBinNames)) +{ +} + +BarSeries::BarSeries(QtCharts::QChart *chart, StatsAxis *xAxis, StatsAxis *yAxis, + bool horizontal, const QString &categoryName, + const std::vector<CountItem> &items) : + BarSeries(chart, xAxis, yAxis, horizontal, false, categoryName, nullptr, std::vector<QString>()) +{ + for (const CountItem &item: items) { + StatsOperationResults res; + res.count = item.count; + double value = item.count; + add_item(item.lowerBound, item.upperBound, makeSubItems(value, item.label), + item.binName, res, item.total, horizontal, stacked); + } +} + +BarSeries::BarSeries(QtCharts::QChart *chart, StatsAxis *xAxis, StatsAxis *yAxis, + bool horizontal, const QString &categoryName, const StatsVariable *valueVariable, + const std::vector<ValueItem> &items) : + BarSeries(chart, 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), + item.binName, item.res, -1, horizontal, stacked); + } +} + +BarSeries::BarSeries(QtCharts::QChart *chart, StatsAxis *xAxis, StatsAxis *yAxis, + bool horizontal, bool stacked, const QString &categoryName, const StatsVariable *valueVariable, + std::vector<QString> valueBinNames, + const std::vector<MultiItem> &items) : + BarSeries(chart, xAxis, yAxis, horizontal, stacked, categoryName, valueVariable, std::move(valueBinNames)) +{ + for (const MultiItem &item: items) { + StatsOperationResults res; + std::vector<std::pair<double, std::vector<QString>>> valuesLabels; + valuesLabels.reserve(item.countLabels.size()); + int total = 0; + for (auto &[count, label]: item.countLabels) { + valuesLabels.push_back({ static_cast<double>(count), std::move(label) }); + total += count; + } + add_item(item.lowerBound, item.upperBound, makeSubItems(valuesLabels), + item.binName, res, total, horizontal, stacked); + } +} + +BarSeries::~BarSeries() +{ +} + +BarSeries::BarLabel::BarLabel(QtCharts::QChart *chart, const std::vector<QString> &labels, int bin_nr, int binCount) : + totalWidth(0.0), totalHeight(0.0), isOutside(false) +{ + items.reserve(labels.size()); + for (const QString &label: labels) { + items.emplace_back(new QGraphicsSimpleTextItem(chart)); + 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); +} + +void BarSeries::BarLabel::setVisible(bool visible) +{ + for (auto &item: items) + item->setVisible(visible); +} + +void BarSeries::BarLabel::highlight(bool highlight, int bin_nr, int binCount) +{ + QBrush brush(highlight || isOutside ? darkLabelColor : labelColor(bin_nr, binCount)); + for (auto &item: items) + item->setBrush(brush); +} + +void BarSeries::BarLabel::updatePosition(QtCharts::QChart *chart, QtCharts::QAbstractSeries *series, + bool horizontal, bool center, const QRectF &rect, + int bin_nr, int binCount) +{ + if (!horizontal) { + if (totalWidth > rect.width()) { + setVisible(false); + return; + } + QPointF pos = rect.center(); + + // 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; + if (isOutside) { + pos.ry() = rect.top() - (totalHeight + 2.0); // Leave two pixels(?) space + } else { + if (totalHeight > 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(); + } + } else { + if (totalHeight > rect.height()) { + setVisible(false); + return; + } + QPointF pos = rect.center(); + pos.ry() -= totalHeight / 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; + if (isOutside) { + pos.rx() = rect.right() + (totalWidth / 2.0 + 2.0); // Leave two pixels(?) space + } else { + if (totalWidth > 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(); + } + } + setVisible(true); + // If label changed from inside to outside, or vice-versa, the color might change. + highlight(false, bin_nr, binCount); +} + +BarSeries::Item::Item(QtCharts::QChart *chart, BarSeries *series, double lowerBound, double upperBound, + std::vector<SubItem> subitemsIn, + const QString &binName, const StatsOperationResults &res, int total, + bool horizontal, bool stacked, int binCount) : + lowerBound(lowerBound), + upperBound(upperBound), + subitems(std::move(subitemsIn)), + binName(binName), + res(res), + total(total) +{ + for (SubItem &item: subitems) { + item.item->setZValue(ZValues::seriesLabels); + item.highlight(false, binCount); + } + updatePosition(chart, series, horizontal, stacked, binCount); +} + +void BarSeries::Item::highlight(int subitem, bool highlight, int binCount) +{ + if (subitem < 0 || subitem >= (int)subitems.size()) + return; + subitems[subitem].highlight(highlight, 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)); + } + if (label) + label->highlight(highlight, bin_nr, binCount); +} + +void BarSeries::Item::updatePosition(QtCharts::QChart *chart, BarSeries *series, bool horizontal, bool stacked, int binCount) +{ + if (subitems.empty()) + return; + + int num = stacked ? 1 : binCount; + + // barWidth gives the total width of the rod or group of rods. + // subBarWidth gives the the width of each bar in the case of grouped bar charts. + // calculated the group width such that after applying the latter, the former is obtained. + double groupWidth = (upperBound - lowerBound) * barWidth * num / (num - 1 + subBarWidth); + double from = (lowerBound + upperBound - groupWidth) / 2.0; + double to = (lowerBound + upperBound + groupWidth) / 2.0; + + double fullSubWidth = (to - from) / num; // width including gap + double subWidth = fullSubWidth * subBarWidth; // width without gap + for (SubItem &item: subitems) { + int idx = stacked ? 0 : item.bin_nr; + double center = (idx + 0.5) * fullSubWidth + from; + item.updatePosition(chart, series, horizontal, stacked, center - subWidth / 2.0, center + subWidth / 2.0, binCount); + } + rect = subitems[0].item->rect(); + for (auto it = std::next(subitems.begin()); it != subitems.end(); ++it) + rect = rect.united(it->item->rect()); +} + +void BarSeries::SubItem::updatePosition(QtCharts::QChart *chart, BarSeries *series, bool horizontal, bool stacked, + double from, double to, int binCount) +{ + QPointF topLeft, bottomRight; + if (horizontal) { + topLeft = chart->mapToPosition(QPointF(value_from, to), series); + bottomRight = chart->mapToPosition(QPointF(value_to, from), series); + } else { + topLeft = chart->mapToPosition(QPointF(from, value_to), series); + bottomRight = chart->mapToPosition(QPointF(to, value_from), series); + } + QRectF rect(topLeft, bottomRight); + item->setRect(rect); + if (label) + label->updatePosition(chart, series, horizontal, stacked, rect, bin_nr, binCount); +} + +std::vector<BarSeries::SubItem> BarSeries::makeSubItems(const std::vector<std::pair<double, std::vector<QString>>> &values) const +{ + std::vector<SubItem> res; + res.reserve(values.size()); + double from = 0.0; + int bin_nr = 0; + for (auto &[v, label]: values) { + if (v > 0.0) { + res.push_back({ std::make_unique<QGraphicsRectItem>(chart()), {}, from, from + v, bin_nr }); + if (!label.empty()) + res.back().label = std::make_unique<BarLabel>(chart(), label, bin_nr, binCount()); + } + if (stacked) + from += v; + ++bin_nr; + } + return res; +} + +std::vector<BarSeries::SubItem> BarSeries::makeSubItems(double value, const std::vector<QString> &label) const +{ + return makeSubItems(std::vector<std::pair<double, std::vector<QString>>>{ { value, label } }); +} + +int BarSeries::binCount() const +{ + return std::max(1, (int)valueBinNames.size()); +} + +void BarSeries::add_item(double lowerBound, double upperBound, std::vector<SubItem> subitems, + const QString &binName, const StatsOperationResults &res, int total, bool horizontal, + bool stacked) +{ + // Don't add empty items, as that messes with the "find item under mouse" routine. + if (subitems.empty()) + return; + items.emplace_back(chart(), this, lowerBound, upperBound, std::move(subitems), binName, res, + total, horizontal, stacked, binCount()); +} + +void BarSeries::updatePositions() +{ + QtCharts::QChart *c = chart(); + for (Item &item: items) + item.updatePosition(c, this, horizontal, stacked, binCount()); +} + +// Attention: this supposes that items are sorted by position and no bar is inside another bar! +BarSeries::Index BarSeries::getItemUnderMouse(const QPointF &point) const +{ + // Search the first item whose "end" position is greater than the cursor position. + auto it = horizontal ? std::lower_bound(items.begin(), items.end(), point.y(), + [] (const Item &item, double y) { return item.rect.top() > y; }) + : std::lower_bound(items.begin(), items.end(), point.x(), + [] (const Item &item, double x) { return item.rect.right() < x; }); + if (it == items.end() || !it->rect.contains(point)) + return Index(); + int subitem = it->getSubItemUnderMouse(point, horizontal, stacked); + return subitem >= 0 ? Index{(int)(it - items.begin()), subitem} : Index(); +} + +// Attention: this supposes that sub items are sorted by position and no subitem is inside another bar! +int BarSeries::Item::getSubItemUnderMouse(const QPointF &point, bool horizontal, bool stacked) const +{ + // 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; }) + : 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; +} + +// Format information in a count-based bar chart. +// Essentially, the name of the bin and the number and percentages of dives. +static std::vector<QString> makeCountInfo(const QString &binName, const QString &axisName, + const QString &valueBinName, const QString &valueAxisName, + int count, int total) +{ + double percentage = count * 100.0 / total; + QString countString = QString("%L1").arg(count); + QString percentageString = QString("%L1%").arg(percentage, 0, 'f', 1); + QString totalString = QString("%L1").arg(total); + std::vector<QString> res; + res.reserve(3); + res.push_back(QStringLiteral("%1: %2").arg(axisName, binName)); + if (!valueAxisName.isEmpty()) + res.push_back(QStringLiteral("%1: %2").arg(valueAxisName, valueBinName)); + res.push_back(StatsTranslations::tr("%1 (%2 of %3) dives").arg(countString, percentageString, totalString)); + return res; +} + +// Format information in a value bar chart: the name of the bin and the value with unit. +static std::vector<QString> makeValueInfo(const QString &binName, const QString &axisName, + const StatsVariable &valueVariable, const StatsOperationResults &values) +{ + QLocale loc; + int decimals = valueVariable.decimals(); + QString unit = valueVariable.unitSymbol(); + std::vector<StatsOperation> operations = valueVariable.supportedOperations(); + std::vector<QString> res; + res.reserve(operations.size() + 3); + res.push_back(QStringLiteral("%1: %2").arg(axisName, binName)); + res.push_back(QStringLiteral("%1: %2").arg(StatsTranslations::tr("Count"), loc.toString(values.count))); + res.push_back(QStringLiteral("%1: ").arg(valueVariable.name())); + for (StatsOperation op: operations) { + QString valueFormatted = loc.toString(values.get(op), 'f', decimals); + res.push_back(QString(" %1: %2 %3").arg(StatsVariable::operationName(op), valueFormatted, unit)); + } + return res; +} + +std::vector<QString> BarSeries::makeInfo(const Item &item, int subitem_idx) const +{ + if (!valueBinNames.empty() && valueVariable) { + if (subitem_idx < 0 || subitem_idx >= (int)item.subitems.size()) + return {}; + const SubItem &subitem = item.subitems[subitem_idx]; + if (subitem.bin_nr < 0 || subitem.bin_nr >= (int)valueBinNames.size()) + return {}; + int count = (int)lrint(subitem.value_to - subitem.value_from); + return makeCountInfo(item.binName, categoryName, valueBinNames[subitem.bin_nr], + valueVariable->name(), count, item.total); + } else if (valueVariable) { + return makeValueInfo(item.binName, categoryName, *valueVariable, item.res); + } else { + return makeCountInfo(item.binName, categoryName, QString(), QString(), item.res.count, item.total); + } +} + +// Highlight item when hovering over item +bool BarSeries::hover(QPointF pos) +{ + Index index = getItemUnderMouse(pos); + if (index == highlighted) { + if (information) + information->setPos(pos); + return index.bar >= 0; + } + + unhighlight(); + highlighted = index; + + // Highlight new item (if any) + if (highlighted.bar >= 0 && highlighted.bar < (int)items.size()) { + Item &item = items[highlighted.bar]; + item.highlight(index.subitem, true, binCount()); + if (!information) + information.reset(new InformationBox(chart())); + information->setText(makeInfo(item, highlighted.subitem), pos); + } else { + information.reset(); + } + + return highlighted.bar >= 0; +} + +void BarSeries::unhighlight() +{ + if (highlighted.bar >= 0 && highlighted.bar < (int)items.size()) + items[highlighted.bar].highlight(highlighted.subitem, false, binCount()); + highlighted = Index(); +} diff --git a/stats/barseries.h b/stats/barseries.h new file mode 100644 index 000000000..09d008094 --- /dev/null +++ b/stats/barseries.h @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: GPL-2.0 +// A small custom bar series, which displays information +// when hovering over a bar. The QtCharts bar series were +// too inflexible with respect to placement of the bars. +#ifndef BAR_SERIES_H +#define BAR_SERIES_H + +#include "statsseries.h" +#include "statsvariables.h" + +#include <memory> +#include <vector> +#include <QGraphicsRectItem> + +namespace QtCharts { + class QAbstractAxis; +} +class InformationBox; +class StatsVariable; + +class BarSeries : public StatsSeries { +public: + // There are three versions of creating bar series: for value-based (mean, etc) charts, for counts + // based charts and for stacked bar charts with multiple items. + struct CountItem { + double lowerBound, upperBound; + int count; + std::vector<QString> label; + QString binName; + int total; + }; + struct ValueItem { + double lowerBound, upperBound; + double value; + std::vector<QString> label; + QString binName; + StatsOperationResults res; + }; + struct MultiItem { + double lowerBound, upperBound; + std::vector<std::pair<int, std::vector<QString>>> countLabels; + QString binName; + }; + + // If the horizontal flag is true, independent variable is plotted on the y-axis. + // A non-empty valueBinNames vector flags that this is a stacked bar chart. + // In that case, a valueVariable must also be provided. + // For count-based bar series in one variable, valueVariable is null. + // 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(QtCharts::QChart *chart, StatsAxis *xAxis, StatsAxis *yAxis, + bool horizontal, const QString &categoryName, + const std::vector<CountItem> &items); + BarSeries(QtCharts::QChart *chart, StatsAxis *xAxis, StatsAxis *yAxis, + bool horizontal, const QString &categoryName, const StatsVariable *valueVariable, + const std::vector<ValueItem> &items); + BarSeries(QtCharts::QChart *chart, StatsAxis *xAxis, StatsAxis *yAxis, + bool horizontal, bool stacked, const QString &categoryName, const StatsVariable *valueVariable, + std::vector<QString> valueBinNames, + const std::vector<MultiItem> &items); + ~BarSeries(); + + void updatePositions() override; + bool hover(QPointF pos) override; + void unhighlight() override; +private: + BarSeries(QtCharts::QChart *chart, StatsAxis *xAxis, StatsAxis *yAxis, + bool horizontal, bool stacked, const QString &categoryName, const StatsVariable *valueVariable, + std::vector<QString> valueBinNames); + + struct Index { + int bar; + int subitem; + Index(); + Index(int bar, int subitem); + bool operator==(const Index &i2) const; + }; + + // Get item under mouse pointer, or -1 if none + Index getItemUnderMouse(const QPointF &f) const; + + // A label that is composed of multiple lines + struct BarLabel { + std::vector<std::unique_ptr<QGraphicsSimpleTextItem>> items; + double totalWidth, totalHeight; // Size of the item + bool isOutside; // Is shown outside of bar + BarLabel(QtCharts::QChart *chart, const std::vector<QString> &labels, int bin_nr, int binCount); + void setVisible(bool visible); + void updatePosition(QtCharts::QChart *chart, QtCharts::QAbstractSeries *series, + bool horizontal, bool center, const QRectF &rect, int bin_nr, int binCount); + void highlight(bool highlight, int bin_nr, int binCount); + }; + + struct SubItem { + std::unique_ptr<QGraphicsRectItem> item; + std::unique_ptr<BarLabel> label; + double value_from; + double value_to; + int bin_nr; + void updatePosition(QtCharts::QChart *chart, BarSeries *series, bool horizontal, bool stacked, + double from, double to, int binCount); + void highlight(bool highlight, int binCount); + }; + + struct Item { + double lowerBound, upperBound; + std::vector<SubItem> subitems; + QRectF rect; + const QString binName; + StatsOperationResults res; + int total; + Item(QtCharts::QChart *chart, BarSeries *series, double lowerBound, double upperBound, + std::vector<SubItem> subitems, + const QString &binName, const StatsOperationResults &res, int total, bool horizontal, + bool stacked, int binCount); + void updatePosition(QtCharts::QChart *chart, BarSeries *series, bool horizontal, bool stacked, int binCount); + void highlight(int subitem, bool highlight, int binCount); + int getSubItemUnderMouse(const QPointF &f, bool horizontal, bool stacked) const; + }; + + std::unique_ptr<InformationBox> information; + std::vector<Item> items; + std::vector<BarLabel> barLabels; + bool horizontal; + bool stacked; + QString categoryName; + const StatsVariable *valueVariable; // null: this is count based + std::vector<QString> valueBinNames; + Index highlighted; + std::vector<SubItem> makeSubItems(double value, const std::vector<QString> &label) const; + std::vector<SubItem> makeSubItems(const std::vector<std::pair<double, std::vector<QString>>> &values) const; + void add_item(double lowerBound, double upperBound, std::vector<SubItem> subitems, + const QString &binName, const StatsOperationResults &res, int total, bool horizontal, + bool stacked); + std::vector<QString> makeInfo(const Item &item, int subitem) const; + int binCount() const; +}; + +#endif |