From ad3024ac7f76287e2621a7f236f08e95962a0b98 Mon Sep 17 00:00:00 2001 From: Mark Liversedge Date: Mon, 24 Feb 2020 09:22:17 +0000 Subject: [PATCH] Add QChart to Python Chart (3f of 5) Added hover code to line chart, so get legend values on hover and marker points as you mouse over the series and an x-axis label at the bottom to show current x value. Also added Utils::removeDP() to remove decimal places from a number string e.g. 24.000 becomes 24, 987.3440500 becomes 987.34405. This is a hack to avoid handling decimal places in the user settings, but will keep as reduces the amount of "digital ink" regardless. --- qwt/src/qwt_text.h | 2 +- src/Charts/GenericPlot.cpp | 411 ++++++++++++++++++++++++------------- src/Charts/GenericPlot.h | 19 +- src/Core/Utils.cpp | 20 ++ src/Core/Utils.h | 1 + 5 files changed, 312 insertions(+), 141 deletions(-) diff --git a/qwt/src/qwt_text.h b/qwt/src/qwt_text.h index e78969083..307fb5130 100644 --- a/qwt/src/qwt_text.h +++ b/qwt/src/qwt_text.h @@ -139,7 +139,7 @@ public: //! Layout attributes typedef QFlags LayoutAttributes; - QwtText( const QString & = QString::null, + QwtText( const QString & = QString(), TextFormat textFormat = AutoText ); QwtText( const QwtText & ); ~QwtText(); diff --git a/src/Charts/GenericPlot.cpp b/src/Charts/GenericPlot.cpp index 1224f3f05..3e7ed99f2 100644 --- a/src/Charts/GenericPlot.cpp +++ b/src/Charts/GenericPlot.cpp @@ -21,6 +21,9 @@ #include "Colors.h" #include "TabView.h" #include "RideFileCommand.h" +#include "Utils.h" + +#include GenericPlot::GenericPlot(QWidget *parent, Context *context) : QWidget(parent), context(context) { @@ -96,9 +99,9 @@ GenericLegendItem::GenericLegendItem(Context *context, QWidget *parent, QString void GenericLegendItem::configChanged(qint32) { - static const double gl_margin = 6 * dpiXFactor; - static const double gl_spacer = 6 * dpiXFactor; - static const double gl_block = 10 * dpiXFactor; + static const double gl_margin = 3 * dpiXFactor; + static const double gl_spacer = 3 * dpiXFactor; + static const double gl_block = 7 * dpiXFactor; static const double gl_linewidth = 1 * dpiXFactor; // we just set geometry for now. @@ -121,7 +124,7 @@ GenericLegendItem::configChanged(qint32) // calculate all the rects used by the painter now since static blockrect = QRectF(gl_margin, gl_margin, gl_block, height-gl_margin); - linerect = QRectF(gl_margin, height-gl_linewidth, width-gl_margin, gl_linewidth); + linerect = QRectF(gl_margin+gl_block, height-gl_linewidth, width-gl_margin, gl_linewidth); namerect = QRectF(gl_margin + gl_block + gl_spacer, gl_margin, fm.boundingRect(name).width(), fm.boundingRect(name).height()); valuerect =QRectF(namerect.x() + namerect.width() + gl_spacer, gl_margin, fm.boundingRect(valuelabel).width(), fm.boundingRect(valuelabel).height()); @@ -144,7 +147,7 @@ GenericLegendItem::paintEvent(QPaintEvent *) // block and line painter.setBrush(QBrush(color)); painter.setPen(Qt::NoPen); - painter.drawRect(blockrect); + //painter.drawRect(blockrect); painter.drawRect(linerect); // just paint the value for now @@ -152,6 +155,9 @@ GenericLegendItem::paintEvent(QPaintEvent *) if (hasvalue) string=QString("%1").arg(value, 0, 'f', 2); else string=" "; + // remove redundat dps (e.g. trailing zeroes) + string = Utils::removeDP(string); + // set pen to series color for now painter.setPen(GCColor::invertColor(GColor(CPLOTBACKGROUND))); // use invert - usually black or white painter.setFont(QFont()); @@ -160,6 +166,7 @@ GenericLegendItem::paintEvent(QPaintEvent *) painter.drawText(namerect, name, Qt::AlignHCenter|Qt::AlignVCenter); painter.drawText(valuerect, string, Qt::AlignHCenter|Qt::AlignVCenter); painter.restore(); + } GenericLegend::GenericLegend(Context *context, GenericPlot *plot) : context(context), plot(plot) @@ -244,12 +251,64 @@ void SelectionTool::paint(QPainter*painter, const QStyleOptionGraphicsItem *, QW painter->save(); painter->setClipRect(mapRectFromScene(host->qchart->plotArea())); + // min max texts + QFont stGiles; // hoho - Chart Font St. Giles ... ok you have to be British to get this joke + stGiles.fromString(appsettings->value(NULL, GC_FONT_CHARTLABELS, QFont().toString()).toString()); + stGiles.setPointSize(appsettings->value(NULL, GC_FONT_CHARTLABELS_SIZE, 8).toInt()); + switch (mode) { case CIRCLE: { } break; + + case XRANGE: + { + + // current position for each series - we only do first, coz only interested in x axis anyway + foreach(QAbstractSeries *series, host->qchart->series()) { + + // convert screen position to value for series + QPointF v = host->qchart->mapToValue(spos,series); + double miny=0; + foreach (QAbstractAxis *axis, series->attachedAxes()) { + if (axis->orientation() == Qt::Vertical && axis->type()==QAbstractAxis::AxisTypeValue) { + miny=static_cast(axis)->min(); + break; + } + } + QPointF posxp = mapFromScene(host->qchart->mapToPosition(QPointF(v.x(),miny),series)); + + QPen markerpen(GColor(CPLOTMARKER)); + painter->setPen(markerpen); + painter->setBrush(QBrush(GColor(CPLOTBACKGROUND))); + + QFontMetrics fm(stGiles); // adjust position to align centre + painter->setFont(stGiles); + + // x value + QString label=QString("%1").arg(v.x(),0,'f',0); // no decimal places XXX fixup on series info + label = Utils::removeDP(label); // remove unneccessary decimal places + painter->drawText(posxp-(QPointF(fm.tightBoundingRect(label).width()/2.0,4)), label); + break; + + } + + // draw the points we are hovering over + foreach(SeriesPoint p, hoverpoints) { + QPointF pos = mapFromScene(host->qchart->mapToPosition(p.xy,p.series)); + QColor invert = GCColor::invertColor(GColor(CPLOTBACKGROUND)); + painter->setBrush(invert); + painter->setPen(invert); + QRectF circle(0,0,5*dpiXFactor,5*dpiYFactor); + circle.moveCenter(pos); + painter->drawEllipse(circle); + painter->setBrush(Qt::NoBrush); + } + } + break; + case RECTANGLE: { if (state == ACTIVE || state == INACTIVE) { @@ -257,27 +316,24 @@ void SelectionTool::paint(QPainter*painter, const QStyleOptionGraphicsItem *, QW if (host->charttype == GC_CHART_LINE || host->charttype == GC_CHART_SCATTER) { - // min max texts - QFont stGiles; // hoho - Chart Font St. Giles ... ok you have to be British to get this joke - stGiles.fromString(appsettings->value(NULL, GC_FONT_CHARTLABELS, QFont().toString()).toString()); - stGiles.setPointSize(appsettings->value(NULL, GC_FONT_CHARTLABELS_SIZE, 8).toInt()); painter->setFont(stGiles); + // hovering around - draw label for current position in axis + if (hoverpoint != QPointF()) { + // draw a circle using marker color + QColor invert = GCColor::invertColor(GColor(CPLOTBACKGROUND)); + painter->setBrush(invert); + painter->setPen(invert); + QRectF circle(0,0,10*dpiXFactor,10*dpiYFactor); + circle.moveCenter(hoverpoint); + painter->drawEllipse(circle); + painter->setBrush(Qt::NoBrush); + + } // current position for each series foreach(QAbstractSeries *series, host->qchart->series()) { - // hovering around - draw label for current position in axis - if (hoverpoint != QPointF()) { - // draw a circle using marker color - painter->setBrush(GColor(CPLOTMARKER)); - painter->setPen(GColor(CPLOTMARKER)); - QRectF circle(0,0,25,25); - circle.moveCenter(hoverpoint); - painter->drawEllipse(circle); - painter->setBrush(Qt::NoBrush); - - } // convert screen position to value for series QPointF v = host->qchart->mapToValue(spos,series); @@ -293,6 +349,7 @@ void SelectionTool::paint(QPainter*painter, const QStyleOptionGraphicsItem *, QW // x value QString label=QString("%1").arg(v.x(),0,'f',0); // no decimal places XXX fixup on series info + label = Utils::removeDP(label); // remove unneccessary decimal places painter->drawText(posxp-(QPointF(fm.tightBoundingRect(label).width()/2.0,4)), label); if (series->type() == QAbstractSeries::SeriesTypeScatter) { @@ -305,14 +362,15 @@ void SelectionTool::paint(QPainter*painter, const QStyleOptionGraphicsItem *, QW // y value label=QString("%1").arg(v.y(),0,'f',0); // no decimal places XXX fixup on series info + label = Utils::removeDP(label); // remove unneccessary decimal places painter->drawText(posyp+QPointF(0,fm.tightBoundingRect(label).height()/2.0), label); // tell the legend or whoever else is listening //fprintf(stderr,"cursor (%f,%f) @(%f,%f) for series %s\n", spos.x(), spos.y(),v.x(),v.y(),series->name().toStdString().c_str()); fflush(stderr); } } - } + if (state != INACTIVE) { // there is a rectangle to draw on the screen @@ -444,6 +502,7 @@ SelectionTool::reset() rect = QRectF(0,0,0,0); hoverpoint = QPointF(); hoverseries = NULL; + hoverpoints.clear(); resetSelections(); update(); return true; @@ -453,37 +512,39 @@ SelectionTool::reset() bool SelectionTool::clicked(QPointF pos) { - if (state==ACTIVE && sceneBoundingRect().contains(pos)) { + if (mode == RECTANGLE) { + if (state==ACTIVE && sceneBoundingRect().contains(pos)) { - // are we moving? - state = MOVING; - start = pos; - startingpos = this->pos(); - update(rect); - return true; + // are we moving? + state = MOVING; + start = pos; + startingpos = this->pos(); + update(rect); + return true; - } else { + } else { - // initial sizing - or click hold to drag? - state = SIZING; - start = pos; - finish = QPointF(0,0); - rect = QRectF(-5,-5,5,5); - setPos(start); + // initial sizing - or click hold to drag? + state = SIZING; + start = pos; + finish = QPointF(0,0); + rect = QRectF(-5,-5,5,5); + setPos(start); - // time 400ms to drag - after lots of playing - // this seems a reasonable time period that isnt - // too long but doesn't conflict with straight - // forward click to select. - // its probably no coincidence that this is also - // the Doherty Threshold for UX - drag.setInterval(400); - drag.setSingleShot(true); - drag.start(); + // time 400ms to drag - after lots of playing + // this seems a reasonable time period that isnt + // too long but doesn't conflict with straight + // forward click to select. + // its probably no coincidence that this is also + // the Doherty Threshold for UX + drag.setInterval(400); + drag.setSingleShot(true); + drag.start(); - update(rect); - return true; + update(rect); + return true; + } } return false; } @@ -491,27 +552,30 @@ SelectionTool::clicked(QPointF pos) bool SelectionTool::released(QPointF) { - // width and heights can be negative if dragged in reverse - if (state == DRAGGING) { + if (mode == RECTANGLE) { - host->setCursor(Qt::ArrowCursor); - state = INACTIVE; - rect = QRectF(0,0,0,0); - return true; + // width and heights can be negative if dragged in reverse + if (state == DRAGGING) { - } else if (rect.width() < 10 && rect.width() > -10 && rect.height() < 10 && rect.height() > -10) { + host->setCursor(Qt::ArrowCursor); + state = INACTIVE; + rect = QRectF(0,0,0,0); + return true; - // tiny, as in click release - deactivate - state = INACTIVE; // reset for any state - rect = QRectF(0,0,0,0); - return true; + } else if (rect.width() < 10 && rect.width() > -10 && rect.height() < 10 && rect.height() > -10) { - } else if (state == SIZING || state == MOVING) { + // tiny, as in click release - deactivate + state = INACTIVE; // reset for any state + rect = QRectF(0,0,0,0); + return true; - // finishing move/resize - state = ACTIVE; - update(rect); - return true; + } else if (state == SIZING || state == MOVING) { + + // finishing move/resize + state = ACTIVE; + update(rect); + return true; + } } return false; } @@ -519,102 +583,166 @@ SelectionTool::released(QPointF) void SelectionTool::dragStart() { - // check still right state for it? - if (state == SIZING) { - fprintf(stderr, "drag mode!\n"); fflush(stderr); - host->setCursor(Qt::ClosedHandCursor); - state = DRAGGING; + if (mode == RECTANGLE) { + // check still right state for it? + if (state == SIZING) { + fprintf(stderr, "drag mode!\n"); fflush(stderr); + host->setCursor(Qt::ClosedHandCursor); + state = DRAGGING; + } } } +// for std::lower_bound search of x QPointF value +struct CompareQPointFX { + bool operator()(const QPointF p1, const QPointF p2) { + return p1.x() < p2.x(); + } +}; + bool SelectionTool::moved(QPointF pos) { - // only care if we are sizing - if (state == SIZING) { + if (mode == RECTANGLE) { - // cancel the timer to trigger drag - drag.stop(); + // user hovers over points, but can select using a rectangle - // work out where we got to - finish = pos; + if (state == SIZING) { - // reshape - rect might have negative sizes if sized backwards - rect = QRectF(QPointF(0,0), finish-start); - update(rect); - return true; + // cancel the timer to trigger drag + drag.stop(); - } else if (state == MOVING) { + // work out where we got to + finish = pos; - QPointF delta = pos - start; - setPos(this->startingpos + delta); - update(rect); - return true; + // reshape - rect might have negative sizes if sized backwards + rect = QRectF(QPointF(0,0), finish-start); + update(rect); + return true; - } else if (state == DRAGGING) { + } else if (state == MOVING) { - //QPointF delta = pos - start; - // move axis to reflect new pos... - return true; + QPointF delta = pos - start; + setPos(this->startingpos + delta); + update(rect); + return true; - } else { + } else if (state == DRAGGING) { - // remember screen pos of cursor for tracking values - // when painting on the axis/plot area - spos = pos; + //QPointF delta = pos - start; + // move axis to reflect new pos... + return true; - // not moving or sizing so just hovering - // look for nearest point for each series - // this needs to be super quick as mouse - // movements are very fast, so we use a - // quadtree to find the nearest points - QPointF hoverv; // value // series x,y co-ord used in signal (and legend later) - hoverpoint = QPointF(); // screen coordinates - QAbstractSeries *originalhoverseries = hoverseries; - hoverseries = NULL; - foreach(QAbstractSeries *series, host->qchart->series()) { + } else { - Quadtree *tree= host->quadtrees.value(series,NULL); - if (tree != NULL) { + // remember screen pos of cursor for tracking values + // when painting on the axis/plot area + spos = pos; - // lets convert cursor pos to value pos to find nearest - double pixels = 10 * dpiXFactor; // within 10 pixels - QRectF srect(pos-QPointF(pixels,pixels), pos+QPointF(pixels,pixels)); - QRectF vrect(host->qchart->mapToValue(srect.topLeft(),series), host->qchart->mapToValue(srect.bottomRight(),series)); - //QPointF vpos = host->qchart->mapToValue(pos, series); + // not moving or sizing so just hovering + // look for nearest point for each series + // this needs to be super quick as mouse + // movements are very fast, so we use a + // quadtree to find the nearest points + QPointF hoverv; // value // series x,y co-ord used in signal (and legend later) + hoverpoint = QPointF(); // screen coordinates + QAbstractSeries *originalhoverseries = hoverseries; + hoverseries = NULL; + foreach(QAbstractSeries *series, host->qchart->series()) { - // find candidates all close by using paint co-ords - QList tohere; - tree->candidates(vrect, tohere); + Quadtree *tree= host->quadtrees.value(series,NULL); + if (tree != NULL) { - QPointF cursorpos=mapFromScene(pos); - foreach(QPointF p, tohere) { - QPointF scpos = mapFromScene(host->qchart->mapToPosition(p, series)); - if (hoverpoint == QPointF()) { - hoverpoint = scpos; - hoverseries = series; - hoverv = p; - } else if ((cursorpos-scpos).manhattanLength() < (cursorpos-hoverpoint).manhattanLength()) { - hoverpoint=scpos; // not happy with this XXX needs more work - hoverseries = series; - hoverv = p; + // lets convert cursor pos to value pos to find nearest + double pixels = 10 * dpiXFactor; // within 10 pixels + QRectF srect(pos-QPointF(pixels,pixels), pos+QPointF(pixels,pixels)); + QRectF vrect(host->qchart->mapToValue(srect.topLeft(),series), host->qchart->mapToValue(srect.bottomRight(),series)); + //QPointF vpos = host->qchart->mapToValue(pos, series); + + // find candidates all close by using paint co-ords + QList tohere; + tree->candidates(vrect, tohere); + + QPointF cursorpos=mapFromScene(pos); + foreach(QPointF p, tohere) { + QPointF scpos = mapFromScene(host->qchart->mapToPosition(p, series)); + if (hoverpoint == QPointF()) { + hoverpoint = scpos; + hoverseries = series; + hoverv = p; + } else if ((cursorpos-scpos).manhattanLength() < (cursorpos-hoverpoint).manhattanLength()) { + hoverpoint=scpos; // not happy with this XXX needs more work + hoverseries = series; + hoverv = p; + } } + + //if (tohere.count()) fprintf(stderr, "HOVER %d candidates nearby\n", tohere.count()); fflush(stderr); } - //if (tohere.count()) fprintf(stderr, "HOVER %d candidates nearby\n", tohere.count()); fflush(stderr); + } + + // hoverpoint changed - either a new series selected, a new point, or no point at all + if (originalhoverseries != hoverseries || hoverv != QPointF()) { + if (hoverseries != originalhoverseries && originalhoverseries != NULL) emit (unhover(originalhoverseries->name())); // old hover changed + if (hoverseries != NULL) emit hover(hoverv, hoverseries->name(), hoverseries); // new hover changed + } + + // for mouse moves.. + update(rect); + return true; + } + + // END OF RECTANLGE MODE + } else if (mode == XRANGE) { + + // xxx just hover for now, will do sizing shortly + // user hovers with a vertical line, but can select a range on the x axis + // lets get x axis value (any old series will do as they should have a common + // x axis + spos = pos; + QMap vals; // what values were found + double nearestx=-9999; + foreach(QAbstractSeries *series, host->qchart->series()) { + + // get x value to search + double xvalue=host->qchart->mapToValue(spos,series).x(); + + // pointsVector + if (series->type() == QAbstractSeries::SeriesTypeLine) { + + // we take a copy, would love to avoid this. + QVector p = static_cast(series)->pointsVector(); + + // value we want + QPointF x= QPointF(xvalue,0); + + // lower_bound to value near x + QVector::const_iterator i = std::lower_bound(p.begin(), p.end(), x, CompareQPointFX()); + + // collect them away + vals.insert(series, QPointF(*i)); + + // nearest x? + if (nearestx == -9999 || (i->x()-xvalue) < (nearestx-xvalue)) nearestx = i->x(); } } - // hoverpoint changed - either a new series selected, a new point, or no point at all - if (originalhoverseries != hoverseries || hoverv != QPointF()) { - if (hoverseries != originalhoverseries && originalhoverseries != NULL) emit (unhover(originalhoverseries->name())); // old hover changed - if (hoverseries != NULL) emit hover(hoverv, hoverseries->name(), hoverseries); // new hover changed + // run over what we found, updating paint points and signal (for legend) + hoverpoints.clear(); + QMapIterator i(vals); + while (i.hasNext()) { + i.next(); + if (i.value().x() == nearestx) { + SeriesPoint add; + add.series = i.key(); + add.xy = i.value(); + emit hover(i.value(), i.key()->name(), i.key()); + if (add.xy.y()) hoverpoints << add; // ignore zeroes + } else emit unhover(i.key()->name()); } - - // for mouse moves.. - update(rect); - return true; + if (vals.count()) return true; } return false; } @@ -622,14 +750,17 @@ SelectionTool::moved(QPointF pos) bool SelectionTool::wheel(int delta) { - // mouse wheel resizes selection rect if it is active - if (state == ACTIVE) { - if (delta < 0) { - rect.setSize(rect.size() * 0.9); - } else { - rect.setSize(rect.size() * 1.1); + + if (mode == RECTANGLE) { + // mouse wheel resizes selection rect if it is active + if (state == ACTIVE) { + if (delta < 0) { + rect.setSize(rect.size() * 0.9); + } else { + rect.setSize(rect.size() * 1.1); + } + return true; } - return true; } return false; } @@ -904,7 +1035,7 @@ SelectionTool::updateScene() QPointF point = scatter->at(i); // avoid deep copy if (point.y() >= miny && point.y() <= maxy && point.x() >= minx && point.x() <= maxx) { - points << point; + if (!points.contains(point)) points << point; // avoid dupes calc.addPoint(point); } } @@ -1048,6 +1179,10 @@ GenericPlot::initialiseChart(QString title, int type, bool animate) // by default they are disabled anyway qchart->setAnimationOptions(animate ? QChart::SeriesAnimations : QChart::NoAnimation); + // what kind of selector do we use? + if (charttype==GC_CHART_LINE) selector->setMode(SelectionTool::XRANGE); + else selector->setMode(SelectionTool::RECTANGLE); + return true; } diff --git a/src/Charts/GenericPlot.h b/src/Charts/GenericPlot.h index 91cc084a3..0264097d3 100644 --- a/src/Charts/GenericPlot.h +++ b/src/Charts/GenericPlot.h @@ -182,6 +182,12 @@ class Calculator QAbstractSeries *series; }; +// hover points etc +struct SeriesPoint { + QAbstractSeries *series; // series this is a point for + QPointF xy; // the actual xy value (not screen position) +}; + // for watcing scene events class SelectionTool : public QObject, public QGraphicsItem { @@ -193,8 +199,13 @@ class SelectionTool : public QObject, public QGraphicsItem public: SelectionTool(GenericPlot *); - enum { INACTIVE, SIZING, MOVING, DRAGGING, ACTIVE } state; // what state are we in? - enum { RECTANGLE, LASSOO, CIRCLE } mode; // what mode are we in? + enum stateType { INACTIVE, SIZING, MOVING, DRAGGING, ACTIVE } state; // what state are we in? + enum modeType { RECTANGLE, XRANGE, LASSOO, CIRCLE } mode; // what mode are we in? + typedef modeType SelectionMode; + typedef stateType SelectionState; + + // set mode + void setMode(SelectionMode mode) { this->mode=mode; } // is invisible and tiny. we are just an observer bool sceneEventFilter(QGraphicsItem *watched, QEvent *event); @@ -235,8 +246,12 @@ class SelectionTool : public QObject, public QGraphicsItem GenericPlot *host; QPointF start, startingpos, finish; // when calculating distances during transitions QPointF spos; // last point we saw + + // scatter does this xxx TODO refactor into hoverpoints QPointF hoverpoint; QAbstractSeries *hoverseries; + // line plot uses this + QList hoverpoints; // selections from original during selection QMap selections; diff --git a/src/Core/Utils.cpp b/src/Core/Utils.cpp index 05e9cad1e..1d09f3a30 100644 --- a/src/Core/Utils.cpp +++ b/src/Core/Utils.cpp @@ -253,5 +253,25 @@ searchPath(QString path, QString binary, bool isexec) return returning; } +QString +removeDP(QString in) +{ + QString out; + if (in.contains('.')) { + + int n=in.indexOf('.'); + out += in.mid(0,n); + int i=in.length()-1; + for(; in[i] != '.'; i--) + if (in[i] != '0') + break; + if (in[i]=='.') return out; + else out += in.mid(n, i-n+1); + return out; + } else { + return in; + } +} + }; diff --git a/src/Core/Utils.h b/src/Core/Utils.h index 23ade2086..eabfa540b 100644 --- a/src/Core/Utils.h +++ b/src/Core/Utils.h @@ -36,6 +36,7 @@ namespace Utils QString jsonprotect(const QString &buffer); QString jsonunprotect(const QString &buffer); QStringList searchPath(QString path, QString binary, bool isexec=true); + QString removeDP(QString); };