subsurface/profile-widget/divecartesianaxis.cpp
Berthold Stoeger 3ae160857d profile: reformat timestamp labels
The timestamp labels may change their format on zoom from
"mm" to "mm:ss", depending on the zoom level. Since the
animation kept old labels, this meant that one can end up
with a mix of labels.

Therefore, always reformat the labels. Of course, this
means that the labels switch instantaneously from one format
to the other. This is in conflict with the whole idea of
"smooth" animation. Such a smooth animation could be realized
by adding a "format" flag to the Label structure and keeping
thus fading in/out labels if the format changes. Do we want
that?

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

484 lines
15 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 "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);
// The axis is implemented by a line, which gives the position.
// If we don't show labels or grid, we don't want to show the line,
// as this gives a strange artifact. TODO: Don't derive from a
// line object in the first place.
setVisible(textVisible || linesVisible);
}
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 = label.label ? label.label->opacity()
: label.line->opacity();
label.opacityEnd = opacityEnd;
if (label.label) {
label.labelPosStart = label.label->pos();
label.labelPosEnd = labelPos(pos);
// For the time-axis, the format might change from "mm" to "mm:ss",
// or vice versa. Currently, we don't animate that, i.e. it will
// switch instantaneously.
if (position == Position::Bottom)
label.label->set(textForValue(label.value), textColor);
}
if (label.line) {
label.lineStart = label.line->line();
label.lineEnd = linePos(pos);
}
}
DiveCartesianAxis::Label DiveCartesianAxis::createLabel(double value, double pos, double dataMinOld, double dataMaxOld, int animSpeed, bool noLabel)
{
Label label { value, 0.0, 1.0 };
double posStart = posAtValue(value, dataMaxOld, dataMinOld);
if (textVisibility && !noLabel) {
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 {
// This is horrible: for the depth axis, we don't want to show the first label (0).
// We recognize this by the fact that the depth axis is the only "inverted" axis.
// This really should be replaced by a general flag to avoid surprises!
bool noLabel = inverted && i == 0;
// Create new label
newLabels.push_back(createLabel(value, pos, dataMinOld, dataMaxOld, animSpeed, noLabel));
}
}
// 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());
}