subsurface/profile-widget/divecartesianaxis.cpp
Berthold Stoeger 5c5c0c4880 profile: redo animation of labels and grid lines
The old animation was weird: it would reuse the labels
based on the index, not on the value. Thus, with the
new scaling code, sometimes there was no animation at all,
if the value, but not the position changed.

Consider the values instead and let labels appear/disappear.
This makes things slightly more complex.

While changing this code, create our own animation-class.
Thus, we can avoid having the dive axes being QObjects.

Signed-off-by: Berthold Stoeger <bstoeger@mail.tuwien.ac.at>
2021-12-17 11:54:23 -08:00

467 lines
14 KiB
C++

// SPDX-License-Identifier: GPL-2.0
#include "profile-widget/divecartesianaxis.h"
#include "profile-widget/divetextitem.h"
#include "core/qthelper.h"
#include "core/subsurface-string.h"
#include "qt-models/diveplotdatamodel.h"
#include "profile-widget/animationfunctions.h"
#include "profile-widget/divelineitem.h"
#include "profile-widget/profilescene.h"
static const double labelSpaceHorizontal = 2.0; // space between label and ticks
static const double labelSpaceVertical = 2.0; // space between label and ticks
void DiveCartesianAxis::setBounds(double minimum, double maximum)
{
dataMin = min = minimum;
dataMax = max = maximum;
}
DiveCartesianAxis::DiveCartesianAxis(Position position, bool inverted, int integralDigits, int fractionalDigits, color_index_t gridColor,
QColor textColor, bool textVisible, bool linesVisible,
double dpr, double labelScale, bool printMode, bool isGrayscale, ProfileScene &scene) :
printMode(printMode),
position(position),
inverted(inverted),
fractionalDigits(fractionalDigits),
textColor(textColor),
scene(scene),
min(0),
max(0),
textVisibility(textVisible),
lineVisibility(linesVisible),
labelScale(labelScale),
dpr(dpr),
transform({1.0, 0.0})
{
QPen pen;
pen.setColor(getColor(TIME_GRID, isGrayscale));
/* cosmetic width() == 0 for lines in printMode
* having setCosmetic(true) and width() > 0 does not work when
* printing on OSX and Linux */
pen.setWidth(DiveCartesianAxis::printMode ? 0 : 2);
pen.setCosmetic(true);
setPen(pen);
pen.setBrush(getColor(gridColor, isGrayscale));
gridPen = pen;
/* Create the longest expected label, e.g. 999.99. */
QString label;
label.reserve(integralDigits + fractionalDigits + 1);
for (int i = 0; i < integralDigits; ++i)
label.append('9');
if (fractionalDigits > 0) {
label.append('.');
for (int i = 0; i < fractionalDigits; ++i)
label.append('9');
}
std::tie(labelWidth, labelHeight) = DiveTextItem::getLabelSize(dpr, labelScale, label);
}
DiveCartesianAxis::~DiveCartesianAxis()
{
}
void DiveCartesianAxis::setTransform(double a, double b)
{
transform.a = a;
transform.b = b;
}
double DiveCartesianAxis::width() const
{
return labelWidth + labelSpaceHorizontal * dpr;
}
double DiveCartesianAxis::height() const
{
return labelHeight + labelSpaceVertical * dpr;
}
double DiveCartesianAxis::horizontalOverhang() const
{
return labelWidth / 2.0;
}
int DiveCartesianAxis::getMinLabelDistance(const DiveCartesianAxis &timeAxis) const
{
// For the plot not being to crowded we want at least two
// labels to fit between each pair of displayed labels.
// May need some optimization.
QLineF m = timeAxis.line();
double interval = labelWidth * 3.0 * (timeAxis.maximum() - timeAxis.minimum()) / (m.x2() - m.x1());
return int(ceil(interval));
}
static double sensibleInterval(double inc, int decimals, bool is_time_axis)
{
if (is_time_axis && inc < 60.0) {
// for time axes and less than one hour increments, round to
// 1, 2, 3, 4, 5, 6 or 12 parts of an hour or a minute
// (that is 60, 30, 20, 15, 12, 10 or 5 min/sec).
bool fraction_of_hour = inc > 1.0;
if (fraction_of_hour)
inc /= 60.0;
inc = inc <= 1.0 / 12.0 ? 1.0 / 12.0 :
inc <= 1.0 / 6.0 ? 1.0 / 6.0 :
1.0 / floor(1.0/inc);
if (fraction_of_hour)
inc *= 60.0;
return inc;
}
// Use full decimal increments
double digits = floor(log10(inc));
int digits_int = lrint(digits);
// Don't do increments smaller than the displayed decimals.
if (digits_int < -decimals) {
digits_int = -decimals;
digits = static_cast<double>(digits_int);
}
double digits_factor = pow(10.0, digits);
int inc_int = std::max((int)ceil(inc / digits_factor), 1);
// Do "nice" increments of the leading digit. In general: 1, 2, 4, 5.
if (inc_int > 5)
inc_int = 10;
if (inc_int == 3)
inc_int = 4;
inc = inc_int * digits_factor;
return inc;
}
void DiveCartesianAxis::updateTicks(int animSpeed)
{
if (dataMax - dataMin < 1e-5)
return;
if (!textVisibility && !lineVisibility)
return; // Nothing to display...
// Remember the old range for animations.
double dataMaxOld = dataMax;
double dataMinOld = dataMin;
// Guess the number of tick marks.
QLineF m = line();
double spaceNeeded = position == Position::Bottom ? labelWidth * 3.0 / 2.0
: labelHeight * 2.0;
double size = position == Position::Bottom ? fabs(m.x2() - m.x1())
: fabs(m.y2() - m.y1());
int numTicks = lrint(size / spaceNeeded);
numTicks = std::clamp(numTicks, 2, 50);
double stepValue = (dataMax - dataMin) / numTicks;
// Round the interval to a sensible size in display units
double intervalDisplay = stepValue * transform.a;
intervalDisplay = sensibleInterval(intervalDisplay, fractionalDigits, position == Position::Bottom);
// Choose full multiples of the interval as minumum and maximum values
double minDisplay = transform.to(dataMin);
double maxDisplay = transform.to(dataMax);
// The time axis is special: use the full width in that case.
// Other axes round to the next "nice" number
double firstDisplay, lastDisplay;
double firstValue;
if (position == Position::Bottom) {
firstDisplay = ceil(minDisplay / intervalDisplay * (1.0 - 1e-5)) * intervalDisplay;
lastDisplay = floor(maxDisplay / intervalDisplay * (1.0 + 1e-5)) * intervalDisplay;
firstValue = transform.from(firstDisplay);
} else {
firstDisplay = floor(minDisplay / intervalDisplay * (1.0 + 1e-5)) * intervalDisplay;
lastDisplay = ceil(maxDisplay / intervalDisplay * (1.0 - 1e-5)) * intervalDisplay;
min = transform.from(firstDisplay);
max = transform.from(lastDisplay);
firstValue = min;
}
numTicks = lrint((lastDisplay - firstDisplay) / intervalDisplay) + 1;
numTicks = std::max(numTicks, 0);
if (numTicks == 0)
return;
double internalToScreen = size / (max - min);
stepValue = position == Position::Bottom ?
intervalDisplay / transform.a : // special case for time axis.
numTicks > 1 ? (max - min) / (numTicks - 1) : 0;
double stepScreen = stepValue * internalToScreen;
// The ticks of the time axis don't necessarily start at the beginning.
double offsetScreen = position == Position::Bottom ?
(firstValue - min) * internalToScreen : 0.0;
// Move the remaining grid lines / labels to their correct positions
// regarding the possible new values for the axis.
double firstPosScreen = position == Position::Bottom ?
(inverted ? m.x2() - offsetScreen : m.x1() + offsetScreen) :
(inverted ? m.y1() + offsetScreen : m.y2() - offsetScreen);
updateLabels(numTicks, firstPosScreen, firstValue, stepScreen, stepValue, animSpeed, dataMinOld, dataMaxOld);
}
QPointF DiveCartesianAxis::labelPos(double pos) const
{
return position == Position::Bottom ? QPointF(pos, rect.bottom() + labelSpaceVertical * dpr) :
position == Position::Left ? QPointF(rect.left() - labelSpaceHorizontal * dpr, pos) :
QPointF(rect.right() + labelSpaceHorizontal * dpr, pos);
}
QLineF DiveCartesianAxis::linePos(double pos) const
{
return position == Position::Bottom ? QLineF(pos, rect.top(), pos, rect.bottom()) :
QLineF(rect.left(), pos, rect.right(), pos);
}
void DiveCartesianAxis::updateLabel(Label &label, double opacityEnd, double pos) const
{
label.opacityStart = textVisibility ? label.label->opacity()
: label.line->opacity();
label.opacityEnd = opacityEnd;
if (textVisibility) {
label.labelPosStart = label.label->pos();
label.labelPosEnd = labelPos(pos);
}
if (lineVisibility) {
label.lineStart = label.line->line();
label.lineEnd = linePos(pos);
}
}
DiveCartesianAxis::Label DiveCartesianAxis::createLabel(double value, double pos, double dataMinOld, double dataMaxOld, int animSpeed)
{
Label label { value, 0.0, 1.0 };
double posStart = posAtValue(value, dataMaxOld, dataMinOld);
if (textVisibility) {
label.labelPosStart = labelPos(posStart);
label.labelPosEnd = labelPos(pos);
int alignFlags = position == Position::Bottom ? Qt::AlignTop | Qt::AlignHCenter :
position == Position::Left ? Qt::AlignVCenter | Qt::AlignLeft:
Qt::AlignVCenter | Qt::AlignRight;
label.label = std::make_unique<DiveTextItem>(dpr, labelScale, alignFlags, this);
label.label->set(textForValue(value), textColor);
label.label->setZValue(1);
label.label->setPos(animSpeed <= 0 ? label.labelPosEnd : label.labelPosStart);
label.label->setOpacity(animSpeed <= 0 ? 1.0 : 0.0);
}
if (lineVisibility) {
label.lineStart = linePos(posStart);
label.lineEnd = linePos(pos);
label.line = std::make_unique<DiveLineItem>(this);
label.line->setPen(gridPen);
label.line->setZValue(0);
label.line->setLine(animSpeed <= 0 ? label.lineEnd : label.lineStart);
label.line->setOpacity(animSpeed <= 0 ? 1.0 : 0.0);
}
return label;
}
void DiveCartesianAxis::updateLabels(int numTicks, double firstPosScreen, double firstValue, double stepScreen, double stepValue,
int animSpeed, double dataMinOld, double dataMaxOld)
{
if (animSpeed <= 0)
labels.clear(); // No animation? Simply redo the labels.
std::vector<Label> newLabels;
newLabels.reserve(numTicks);
auto actOld = labels.begin();
double value = firstValue;
for (int i = 0; i < numTicks; i++, value += stepValue) {
// Check if we already got that label. If we find unused labels, mark them for deletion.
// Labels to be deleted are recognized by an end-opacity of 0.0.
// Note: floating point comparisons should be fine owing to our rounding to integers above.
for ( ; actOld != labels.end() && actOld->value < value; ++actOld) {
double pos = posAtValue(actOld->value);
updateLabel(*actOld, 0.0, pos);
newLabels.push_back(std::move(*actOld));
}
double pos = ((position == Position::Bottom) != inverted) ?
firstPosScreen + i * stepScreen :
firstPosScreen - i * stepScreen;
if (actOld != labels.end() && actOld->value == value) {
// Update label, but don't delete it
updateLabel(*actOld, 1.0, pos);
newLabels.push_back(std::move(*actOld));
++actOld;
} else {
// Create new label
newLabels.push_back(createLabel(value, pos, dataMinOld, dataMaxOld, animSpeed));
}
}
// If there are any labels left, mark them for deletion.
for ( ; actOld != labels.end(); ++actOld) {
double pos = posAtValue(actOld->value);
updateLabel(*actOld, 0.0, pos);
newLabels.push_back(std::move(*actOld));
}
labels = std::move(newLabels);
}
// Arithmetics with lines. Needed for animations. Operates pointwise.
static QLineF operator-(const QLineF &l1, const QLineF &l2)
{
return QLineF(l1.p1() - l2.p1(), l1.p2() - l2.p2());
}
static QLineF operator+(const QLineF &l1, const QLineF &l2)
{
return QLineF(l1.p1() + l2.p1(), l1.p2() + l2.p2());
}
static QLineF operator*(double f, const QLineF &l)
{
return QLineF(f*l.p1(), f*l.p2());
}
// Helper template: get point in interval (0.0: start, 1.0: end)
template <typename T>
T mid(const T &start, const T &end, double fraction)
{
return start + fraction * (end - start);
}
void DiveCartesianAxis::anim(double fraction)
{
if (fraction == 1.0) {
// The animation has finished.
// Remove labels that have been marked for deletion by setting the opacity to 0.0.
// Use the erase-remove idiom (yes, it is a weird idiom).
labels.erase(std::remove_if(labels.begin(), labels.end(),
[](const Label &l) { return l.opacityEnd == 0.0; }),
labels.end());
}
for (Label &label: labels) {
double opacity = mid(label.opacityStart, label.opacityEnd, fraction);
if (label.label) {
label.label->setOpacity(opacity);
label.label->setPos(mid(label.labelPosStart, label.labelPosEnd, fraction));
}
if (label.line) {
label.line->setOpacity(opacity);
label.line->setLine(mid(label.lineStart, label.lineEnd, fraction));
}
}
}
void DiveCartesianAxis::setPosition(const QRectF &rectIn)
{
rect = rectIn;
switch (position) {
case Position::Left:
setLine(QLineF(rect.topLeft(), rect.bottomLeft()));
break;
case Position::Right:
setLine(QLineF(rect.topRight(), rect.bottomRight()));
break;
case Position::Bottom:
default:
setLine(QLineF(rect.bottomLeft(), rect.bottomRight()));
break;
}
}
double DiveCartesianAxis::Transform::to(double x) const
{
return a*x + b;
}
double DiveCartesianAxis::Transform::from(double y) const
{
return (y - b) / a;
}
QString DiveCartesianAxis::textForValue(double value) const
{
if (position == Position::Bottom) {
// The bottom axis is the time axis and that needs special treatment.
int nr = lrint(value) / 60;
if (maximum() - minimum() < 600.0)
return QString("%1:%2").arg(nr).arg((int)value % 60, 2, 10, QChar('0'));
return QString::number(nr);
} else {
return QStringLiteral("%L1").arg(transform.to(value), 0, 'f', fractionalDigits);
}
}
double DiveCartesianAxis::valueAt(const QPointF &p) const
{
QLineF m = line();
QPointF relativePosition = p;
relativePosition -= pos(); // normalize p based on the axis' offset on screen
double fraction = position == Position::Bottom ?
(relativePosition.x() - m.x1()) / (m.x2() - m.x1()) :
(relativePosition.y() - m.y1()) / (m.y2() - m.y1());
if ((position == Position::Bottom) == inverted)
fraction = 1.0 - fraction;
return fraction * (max - min) + min;
}
double DiveCartesianAxis::posAtValue(double value, double max, double min) const
{
QLineF m = line();
double screenFrom = position == Position::Bottom ? m.x1() : m.y1();
double screenTo = position == Position::Bottom ? m.x2() : m.y2();
if (IS_FP_SAME(min, max))
return (screenFrom + screenTo) / 2.0;
if ((position == Position::Bottom) == inverted)
std::swap(screenFrom, screenTo);
return (value - min) / (max - min) *
(screenTo - screenFrom) + screenFrom;
}
double DiveCartesianAxis::posAtValue(double value) const
{
return posAtValue(value, max, min);
}
static std::pair<double, double> getLineFromTo(const QLineF &l, bool horizontal)
{
if (horizontal)
return std::make_pair(l.x1(), l.x2());
else
return std::make_pair(l.y1(), l.y2());
}
double DiveCartesianAxis::screenPosition(double pos) const
{
if ((position == Position::Bottom) == inverted)
pos = 1.0 - pos;
auto [from, to] = getLineFromTo(line(), position == Position::Bottom);
return (to - from) * pos + from;
}
double DiveCartesianAxis::pointInRange(double pos) const
{
auto [from, to] = getLineFromTo(line(), position == Position::Bottom);
if (from > to)
std::swap(from, to);
return pos >= from && pos <= to;
}
double DiveCartesianAxis::maximum() const
{
return max;
}
double DiveCartesianAxis::minimum() const
{
return min;
}
std::pair<double, double> DiveCartesianAxis::screenMinMax() const
{
return position == Position::Bottom ? std::make_pair(rect.left(), rect.right())
: std::make_pair(rect.top(), rect.bottom());
}