// SPDX-License-Identifier: GPL-2.0 #include "profile-widget/divecartesianaxis.h" #include "profile-widget/divetextitem.h" #include "core/profile.h" #include "core/qthelper.h" #include "core/subsurface-float.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), gridIsMultipleOfThree(false), 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, bool is_multiple_of_three) { 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); if (is_multiple_of_three) { // Do increments quantized to 3. In general: 1, 3, 6, 15 if (inc_int > 6) inc_int = 15; else if (inc_int > 3) inc_int = 6; else if (inc_int == 2) inc_int = 3; } else { // 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, gridIsMultipleOfThree); // 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::deltaToValue(double delta) const { QLineF m = line(); double screenSize = position == Position::Bottom ? m.x2() - m.x1() : m.y2() - m.y1(); double axisSize = max - min; double res = delta * axisSize / screenSize; return ((position == Position::Bottom) == inverted) ? -res : res; } 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 (nearly_equal(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; } void DiveCartesianAxis::setGridIsMultipleOfThree(bool arg1) { gridIsMultipleOfThree = arg1; } 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()); }