diff --git a/Subsurface-mobile.pro b/Subsurface-mobile.pro index ea95cf551..e2a724633 100644 --- a/Subsurface-mobile.pro +++ b/Subsurface-mobile.pro @@ -131,6 +131,7 @@ SOURCES += subsurface-mobile-main.cpp \ stats/statsview.cpp \ stats/barseries.cpp \ stats/boxseries.cpp \ + stats/chartitem.cpp \ stats/chartlistmodel.cpp \ stats/informationbox.cpp \ stats/legend.cpp \ @@ -279,6 +280,7 @@ HEADERS += \ backend-shared/roundrectitem.h \ stats/barseries.h \ stats/boxseries.h \ + stats/chartitem.h \ stats/chartlistmodel.h \ stats/informationbox.h \ stats/legend.h \ diff --git a/stats/CMakeLists.txt b/stats/CMakeLists.txt index a084dd0b5..c0fbe3c51 100644 --- a/stats/CMakeLists.txt +++ b/stats/CMakeLists.txt @@ -9,6 +9,8 @@ set(SUBSURFACE_STATS_SRCS barseries.cpp boxseries.h boxseries.cpp + chartitem.h + chartitem.cpp chartlistmodel.h chartlistmodel.cpp informationbox.h diff --git a/stats/chartitem.cpp b/stats/chartitem.cpp new file mode 100644 index 000000000..c0fc50841 --- /dev/null +++ b/stats/chartitem.cpp @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: GPL-2.0 +#include "chartitem.h" +#include "statsview.h" + +#include +#include +#include +#include + +static int round_up(double f) +{ + return static_cast(ceil(f)); +} + +ChartItem::ChartItem(StatsView &v) : + dirty(false), view(v), positionDirty(false), textureDirty(false) +{ +} + +ChartItem::~ChartItem() +{ + painter.reset(); // Make sure to destroy painter before image that is painted on + view.unregisterChartItem(this); +} + +QSizeF ChartItem::sceneSize() const +{ + return view.size(); +} + +void ChartItem::setTextureDirty() +{ + textureDirty = true; + dirty = true; +} + +void ChartItem::setPositionDirty() +{ + positionDirty = true; + dirty = true; +} + +void ChartItem::render() +{ + if (!dirty) + return; + if (!node) { + node.reset(view.w()->createImageNode()); + view.addQSGNode(node.get(), 0); + } + if (!img) { + resize(QSizeF(1,1)); + img->fill(Qt::transparent); + } + if (textureDirty) { + texture.reset(view.w()->createTextureFromImage(*img, QQuickWindow::TextureHasAlphaChannel)); + node->setTexture(texture.get()); + textureDirty = false; + } + if (positionDirty) { + node->setRect(rect); + positionDirty = false; + } + dirty = false; +} + +void ChartItem::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 ChartItem::setPos(QPointF pos) +{ + rect.moveTopLeft(pos); + setPositionDirty(); +} + +QRectF ChartItem::getRect() const +{ + return rect; +} + +ChartRectItem::ChartRectItem(StatsView &v, const QPen &pen, const QBrush &brush, double radius) : ChartItem(v), + pen(pen), brush(brush), radius(radius) +{ +} + +ChartRectItem::~ChartRectItem() +{ +} + +void ChartRectItem::resize(QSizeF size) +{ + ChartItem::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); +} diff --git a/stats/chartitem.h b/stats/chartitem.h new file mode 100644 index 000000000..fb4b67dff --- /dev/null +++ b/stats/chartitem.h @@ -0,0 +1,51 @@ +// 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 +#include + +class QSGImageNode; +class QSGTexture; +class StatsView; + +class ChartItem { +public: + ChartItem(StatsView &v); + ~ChartItem(); + // Attention: The children are responsible for updating the item. None of these calls will. + void resize(QSizeF size); // Resets the canvas. Attention: image is *unitialized*. + void setPos(QPointF pos); + void render(); // Only call on render thread! + QRectF getRect() const; + bool dirty; // If true, call render() when rebuilding the scene +protected: + std::unique_ptr painter; + std::unique_ptr img; + QSizeF sceneSize() const; + void setTextureDirty(); + void setPositionDirty(); +private: + StatsView &view; + QRectF rect; + bool positionDirty; + bool textureDirty; + std::unique_ptr node; + std::unique_ptr texture; +}; + +// Draw a rectangular background after resize. Children are responsible for calling update(). +class ChartRectItem : public ChartItem { +public: + ChartRectItem(StatsView &v, const QPen &pen, const QBrush &brush, double radius); + ~ChartRectItem(); + void resize(QSizeF size); +private: + QPen pen; + QBrush brush; + double radius; +}; + +#endif diff --git a/stats/legend.cpp b/stats/legend.cpp index 27607fb51..2cbd883db 100644 --- a/stats/legend.cpp +++ b/stats/legend.cpp @@ -4,8 +4,6 @@ #include "zvalues.h" #include -#include -#include #include static const double legendBorderSize = 2.0; @@ -16,51 +14,30 @@ 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 &names) : - RoundRectItem(legendBoxBorderRadius), - displayedItems(0), width(0.0), height(0.0) +Legend::Legend(StatsView &view, const std::vector &names) : + ChartRectItem(view, QPen(legendBorderColor, legendBorderSize), QBrush(legendColor), legendBoxBorderRadius), + displayedItems(0), width(0.0), height(0.0), + font(QFont()) // Make configurable { - 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 +45,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 +77,32 @@ 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(); - } - show(); + + // For now, place the legend in the top right corner. + QPointF pos(size.width() - width - 10.0, 10.0); + setPos(pos); } diff --git a/stats/legend.h b/stats/legend.h index c643a41f3..a9d42bf39 100644 --- a/stats/legend.h +++ b/stats/legend.h @@ -3,34 +3,34 @@ #ifndef STATS_LEGEND_H #define STATS_LEGEND_H -#include "backend-shared/roundrectitem.h" +#include "chartitem.h" #include #include +#include -class QGraphicsScene; -class QGraphicsSceneMouseEvent; +class QFontMetrics; -class Legend : public RoundRectItem { +class Legend : public ChartRectItem { public: - Legend(const std::vector &names); + Legend(StatsView &view, const std::vector &names); void hover(QPointF pos); void resize(); // called when the chart size changes. private: // Each entry is a text besides a rectangle showing the color struct Entry { - std::unique_ptr rect; - std::unique_ptr 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; int fontHeight; std::vector entries; - void updatePosition(); void hide(); }; diff --git a/stats/statsview.cpp b/stats/statsview.cpp index e5e6b0b0d..61510d60c 100644 --- a/stats/statsview.cpp +++ b/stats/statsview.cpp @@ -25,28 +25,6 @@ #include #include -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(oldNode); - if (!n) - n = window()->createImageNode(); - - QRectF rect = boundingRect(); - if (plotRect != rect) { - plotRect = 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); - return n; -} - // Constants that control the graph layouts static const QColor quartileMarkerColor(Qt::red); static const double quartileMarkerSize = 15.0; @@ -56,7 +34,8 @@ static const double titleBorder = 2.0; // Border between title and chart StatsView::StatsView(QQuickItem *parent) : QQuickItem(parent), highlightedSeries(nullptr), xAxis(nullptr), - yAxis(nullptr) + yAxis(nullptr), + rootNode(nullptr) { setFlag(ItemHasContents, true); @@ -76,6 +55,59 @@ StatsView::~StatsView() { } +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(oldNode); + if (!n) + n = rootNode = window()->createImageNode(); + + for (ChartItem *item: items) { + if (item->dirty) + item->render(); + } + + QRectF rect = boundingRect(); + if (plotRect != rect) { + plotRect = 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); + return n; +} + +void StatsView::addQSGNode(QSGNode *node, int) +{ + rootNode->appendChildNode(node); +} + +// Currently this does an inefficient linear search in the chart-item vector. +// The reason is that removing individual chart items is very rare: for now, +// it is only done when hiding an InfoBox. In the future, this might have to +// be improved. +void StatsView::unregisterChartItem(const ChartItem *item) +{ + auto it = std::find(items.begin(), items.end(), item); + if (it != items.end()) + items.erase(it); +} + +QQuickWindow *StatsView::w() const +{ + return window(); +} + +QSizeF StatsView::size() const +{ + return boundingRect().size(); +} + void StatsView::plotAreaChanged(const QSizeF &s) { // Make sure that image is at least one pixel wide / high, otherwise @@ -202,6 +234,14 @@ T *StatsView::createAxis(const QString &title, Args&&... args) return res; } +template +std::unique_ptr StatsView::createChartItem(Args&&... args) +{ + std::unique_ptr res(new T(*this, std::forward(args)...)); + items.push_back(res.get()); + return res; +} + void StatsView::setAxes(StatsAxis *x, StatsAxis *y) { xAxis = x; @@ -214,6 +254,7 @@ void StatsView::reset() { highlightedSeries = nullptr; xAxis = yAxis = nullptr; + items.clear(); // non-owning pointers legend.reset(); series.clear(); quartileMarkers.clear(); @@ -406,7 +447,7 @@ void StatsView::plotBarChart(const std::vector &dives, // Paint legend first, because the bin-names will be moved away from. if (showLegend) - legend = createItemPtr(&scene, data.vbinNames); + legend = createChartItem(data.vbinNames); std::vector items; items.reserve(data.hbin_counts.size()); @@ -623,7 +664,7 @@ void StatsView::plotPieChart(const std::vector &dives, PieSeries *series = createSeries(categoryVariable->name(), data, keepOrder, labels); if (showLegend) - legend = createItemPtr(&scene, series->binNames()); + legend = createChartItem(series->binNames()); } void StatsView::plotDiscreteBoxChart(const std::vector &dives, @@ -959,7 +1000,7 @@ void StatsView::plotHistogramStackedChart(const std::vector &dives, BarPlotData data(categoryBins, *valueBinner); if (showLegend) - legend = createItemPtr(&scene, data.vbinNames); + legend = createChartItem(data.vbinNames); CountAxis *valAxis = createCountAxis(data.maxCategoryCount, isHorizontal); diff --git a/stats/statsview.h b/stats/statsview.h index b1e178565..385a591f4 100644 --- a/stats/statsview.h +++ b/stats/statsview.h @@ -19,8 +19,10 @@ struct StatsVariable; class QGraphicsLineItem; class QGraphicsSimpleTextItem; +class QSGImageNode; class StatsSeries; class CategoryAxis; +class ChartItem; class CountAxis; class HistogramAxis; class StatsAxis; @@ -46,6 +48,10 @@ public: ~StatsView(); void plot(const StatsState &state); + QQuickWindow *w() const; // Make window available to items + QSizeF size() const; + void addQSGNode(QSGNode *node, int z); // Must only be called in render thread! + void unregisterChartItem(const ChartItem *item); private slots: void replotIfVisible(); private: @@ -103,6 +109,9 @@ private: template T *createAxis(const QString &title, Args&&... args); + template + std::unique_ptr createChartItem(Args&&... args); + template CategoryAxis *createCategoryAxis(const QString &title, const StatsBinner &binner, const std::vector &bins, bool isHorizontal); @@ -156,11 +165,14 @@ private: std::vector regressionLines; std::vector histogramMarkers; std::unique_ptr title; + std::vector items; // Attention: currently, items are not automatically removed on destruction! StatsSeries *highlightedSeries; StatsAxis *xAxis, *yAxis; void hoverEnterEvent(QHoverEvent *event) override; void hoverMoveEvent(QHoverEvent *event) override; + + QSGImageNode *rootNode; }; #endif