/* This file is part of Telegram Desktop, the official desktop application for the Telegram messaging service. For license and copyright information please follow this link: https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "statistics/chart_widget.h" #include "base/qt/qt_key_modifiers.h" #include "lang/lang_keys.h" #include "statistics/chart_lines_filter_controller.h" #include "statistics/view/abstract_chart_view.h" #include "statistics/view/chart_view_factory.h" #include "statistics/view/stack_chart_common.h" #include "statistics/widgets/chart_header_widget.h" #include "statistics/widgets/chart_lines_filter_widget.h" #include "statistics/widgets/point_details_widget.h" #include "ui/abstract_button.h" #include "ui/effects/animation_value_f.h" #include "ui/effects/ripple_animation.h" #include "ui/effects/show_animation.h" #include "ui/image/image_prepare.h" #include "ui/painter.h" #include "ui/rect.h" #include "ui/widgets/buttons.h" #include "styles/style_layers.h" #include "styles/style_statistics.h" namespace Statistic { namespace { constexpr auto kHeightLimitsUpdateTimeout = crl::time(320); inline float64 InterpolationRatio(float64 from, float64 to, float64 result) { return (result - from) / (to - from); }; void FillLineColorsByKey(Data::StatisticalChart &chartData) { for (auto &line : chartData.lines) { if (line.colorKey == u"BLUE"_q) { line.color = st::statisticsChartLineBlue->c; } else if (line.colorKey == u"GREEN"_q) { line.color = st::statisticsChartLineGreen->c; } else if (line.colorKey == u"RED"_q) { line.color = st::statisticsChartLineRed->c; } else if (line.colorKey == u"GOLDEN"_q) { line.color = st::statisticsChartLineGolden->c; } else if (line.colorKey == u"LIGHTBLUE"_q) { line.color = st::statisticsChartLineLightblue->c; } else if (line.colorKey == u"LIGHTGREEN"_q) { line.color = st::statisticsChartLineLightgreen->c; } else if (line.colorKey == u"ORANGE"_q) { line.color = st::statisticsChartLineOrange->c; } else if (line.colorKey == u"INDIGO"_q) { line.color = st::statisticsChartLineIndigo->c; } else if (line.colorKey == u"PURPLE"_q) { line.color = st::statisticsChartLinePurple->c; } else if (line.colorKey == u"CYAN"_q) { line.color = st::statisticsChartLineCyan->c; } } } [[nodiscard]] QString HeaderSubTitle( const Data::StatisticalChart &chartData, int xIndexMin, int xIndexMax) { constexpr auto kOneDay = 3600 * 24 * 1000; const auto leftTimestamp = chartData.x[xIndexMin]; if (leftTimestamp < kOneDay) { return {}; } const auto formatter = u"d MMM yyyy"_q; const auto leftDateTime = QDateTime::fromSecsSinceEpoch( leftTimestamp / 1000); const auto leftText = QLocale().toString(leftDateTime.date(), formatter); if ((xIndexMin == xIndexMax) && !chartData.weekFormat) { return leftText; } else { constexpr auto kSevenDays = 3600 * 24 * 7; const auto rightDateTime = QDateTime::fromSecsSinceEpoch(0 + (chartData.x[xIndexMax] / 1000) + (chartData.weekFormat ? kSevenDays : 0)); return leftText + ' ' + QChar(8212) + ' ' + QLocale().toString(rightDateTime.date(), formatter); } } void PaintBottomLine( QPainter &p, const std::vector &dates, Data::StatisticalChart &chartData, const Limits &xPercentageLimits, int fullWidth, int chartWidth, int y, int captionIndicesOffset) { p.setFont(st::statisticsDetailsBottomCaptionStyle.font); const auto opacity = p.opacity(); const auto startXIndex = chartData.findStartIndex( xPercentageLimits.min); const auto endXIndex = chartData.findEndIndex( startXIndex, xPercentageLimits.max); const auto edgeAlphaSize = st::statisticsChartBottomCaptionMaxWidth / 4.; for (auto k = 0; k < dates.size(); k++) { const auto &date = dates[k]; const auto isLast = (k == dates.size() - 1); const auto resultAlpha = date.alpha; const auto step = std::max(date.step, 1); auto start = startXIndex - captionIndicesOffset; while (start % step != 0) { start--; } auto end = endXIndex - captionIndicesOffset; while ((end % step != 0) || end < (chartData.x.size() - 1)) { end++; } start += captionIndicesOffset; end += captionIndicesOffset; const auto offset = fullWidth * xPercentageLimits.min; // 30 ms / 200 ms = 0.15. constexpr auto kFastAlphaSpeed = 0.85; const auto hasFastAlpha = (date.stepRaw < dates.back().stepMinFast); const auto fastAlpha = isLast ? 1. : std::max(resultAlpha - kFastAlphaSpeed, 0.); for (auto i = start; i < end; i += step) { if ((i < 0) || (i >= (chartData.x.size() - 1))) { continue; } const auto xPercentage = (chartData.x[i] - chartData.x.front()) / float64(chartData.x.back() - chartData.x.front()); const auto xPoint = xPercentage * fullWidth - offset; const auto r = QRectF( xPoint - st::statisticsChartBottomCaptionMaxWidth / 2., y, st::statisticsChartBottomCaptionMaxWidth, st::statisticsChartBottomCaptionHeight); const auto edgeAlpha = (r.x() < 0) ? std::max( 0., 1. + (r.x() / edgeAlphaSize)) : (rect::right(r) > chartWidth) ? std::max( 0., 1. + ((chartWidth - rect::right(r)) / edgeAlphaSize)) : 1.; p.setOpacity(opacity * edgeAlpha * (hasFastAlpha ? fastAlpha : resultAlpha)); p.drawText(r, chartData.getDayString(i), style::al_center); } } } } // namespace class RpMouseWidget : public Ui::AbstractButton { public: using Ui::AbstractButton::AbstractButton; struct State { QPoint point; QEvent::Type mouseState; }; [[nodiscard]] const QPoint &start() const; [[nodiscard]] rpl::producer mouseStateChanged() const; protected: void mousePressEvent(QMouseEvent *e) override; void mouseMoveEvent(QMouseEvent *e) override; void mouseReleaseEvent(QMouseEvent *e) override; private: QPoint _start = QPoint(-1, -1); rpl::event_stream _mouseStateChanged; }; const QPoint &RpMouseWidget::start() const { return _start; } rpl::producer RpMouseWidget::mouseStateChanged() const { return _mouseStateChanged.events(); } void RpMouseWidget::mousePressEvent(QMouseEvent *e) { _start = e->pos(); _mouseStateChanged.fire({ e->pos(), QEvent::MouseButtonPress }); } void RpMouseWidget::mouseMoveEvent(QMouseEvent *e) { if (_start.x() >= 0 || _start.y() >= 0) { _mouseStateChanged.fire({ e->pos(), QEvent::MouseMove }); } } void RpMouseWidget::mouseReleaseEvent(QMouseEvent *e) { _start = { -1, -1 }; _mouseStateChanged.fire({ e->pos(), QEvent::MouseButtonRelease }); } class ChartWidget::Footer final : public RpMouseWidget { public: using PaintCallback = Fn; explicit Footer(not_null parent); void setXPercentageLimits(const Limits &xLimits); [[nodiscard]] Limits xPercentageLimits() const; [[nodiscard]] rpl::producer xPercentageLimitsChange() const; void setPaintChartCallback(PaintCallback paintChartCallback); protected: void paintEvent(QPaintEvent *e) override; int resizeGetHeight(int newWidth) override; private: void moveSide(bool left, float64 x); void moveCenter( bool isDirectionToLeft, float64 x, float64 diffBetweenStartAndLeft); void fire() const; enum class DragArea { None, Middle, Left, Right, }; DragArea _dragArea = DragArea::None; float64 _diffBetweenStartAndSide = 0; Ui::Animations::Simple _moveCenterAnimation; bool _draggedAfterPress = false; const QPen _sidePen; float64 _width = 0.; float64 _widthBetweenSides = 0.; PaintCallback _paintChartCallback; QImage _frame; QImage _mask; Limits _leftSide; Limits _rightSide; rpl::event_stream _xPercentageLimitsChange; }; ChartWidget::Footer::Footer(not_null parent) : RpMouseWidget(parent) , _sidePen( st::premiumButtonFg, st::statisticsChartLineWidth, Qt::SolidLine, Qt::RoundCap) { sizeValue( ) | rpl::take(2) | rpl::start_with_next([=](const QSize &s) { const auto current = xPercentageLimits(); if (current.min == current.max) { setXPercentageLimits({ 0., 1. }); } }, lifetime()); mouseStateChanged( ) | rpl::start_with_next([=](const RpMouseWidget::State &state) { if (_moveCenterAnimation.animating()) { return; } const auto posX = state.point.x(); const auto isLeftSide = (posX >= _leftSide.min) && (posX <= _leftSide.max); const auto isRightSide = !isLeftSide && (posX >= _rightSide.min) && (posX <= _rightSide.max); switch (state.mouseState) { case QEvent::MouseMove: { _draggedAfterPress = true; if (_dragArea == DragArea::None) { return; } const auto resultX = posX - _diffBetweenStartAndSide; if (_dragArea == DragArea::Right) { moveSide(false, resultX); } else if (_dragArea == DragArea::Left) { moveSide(true, resultX); } else if (_dragArea == DragArea::Middle) { const auto toLeft = (posX - _diffBetweenStartAndSide - _leftSide.min) <= 0; moveCenter(toLeft, posX, _diffBetweenStartAndSide); } fire(); } break; case QEvent::MouseButtonPress: { _draggedAfterPress = false; _dragArea = isLeftSide ? DragArea::Left : isRightSide ? DragArea::Right : ((posX < _leftSide.min) || (posX > _rightSide.max)) ? DragArea::None : DragArea::Middle; _diffBetweenStartAndSide = isRightSide ? (start().x() - _rightSide.min) : (start().x() - _leftSide.min); } break; case QEvent::MouseButtonRelease: { const auto finish = [=] { _dragArea = DragArea::None; fire(); }; if ((_dragArea == DragArea::None) && !_draggedAfterPress) { const auto startX = _leftSide.min + (_rightSide.max - _leftSide.min) / 2; const auto finishX = posX; const auto toLeft = (finishX <= startX); const auto diffBetweenStartAndLeft = startX - _leftSide.min; _moveCenterAnimation.stop(); _moveCenterAnimation.start([=](float64 value) { moveCenter(toLeft, value, diffBetweenStartAndLeft); fire(); update(); if (value == finishX) { finish(); } }, startX, finishX, st::slideWrapDuration, anim::sineInOut); } else { finish(); } } break; } update(); }, lifetime()); } int ChartWidget::Footer::resizeGetHeight(int newWidth) { const auto h = st::statisticsChartFooterHeight; if (!newWidth) { return h; } const auto was = xPercentageLimits(); const auto w = float64(st::statisticsChartFooterSideWidth); _width = newWidth - w; _widthBetweenSides = newWidth - w * 2.; _mask = Ui::RippleAnimation::RoundRectMask( QSize(newWidth, h - st::lineWidth * 2), st::boxRadius); _frame = _mask; if (_widthBetweenSides && was.max) { setXPercentageLimits(was); } return h; } Limits ChartWidget::Footer::xPercentageLimits() const { return { .min = _widthBetweenSides ? _leftSide.min / _widthBetweenSides : 0., .max = _widthBetweenSides ? (_rightSide.min - st::statisticsChartFooterSideWidth) / _widthBetweenSides : 0., }; } void ChartWidget::Footer::fire() const { _xPercentageLimitsChange.fire(xPercentageLimits()); } void ChartWidget::Footer::moveCenter( bool isDirectionToLeft, float64 x, float64 diffBetweenStartAndLeft) { const auto resultX = x - diffBetweenStartAndLeft; const auto diffBetweenSides = std::max( _rightSide.min - _leftSide.min, float64(st::statisticsChartFooterBetweenSide)); if (isDirectionToLeft) { moveSide(true, resultX); moveSide(false, _leftSide.min + diffBetweenSides); } else { moveSide(false, resultX + diffBetweenSides); moveSide(true, _rightSide.min - diffBetweenSides); } } void ChartWidget::Footer::moveSide(bool left, float64 x) { const auto w = float64(st::statisticsChartFooterSideWidth); const auto mid = float64(st::statisticsChartFooterBetweenSide); if (_width < (2 * w + mid)) { return; } else if (left) { const auto rightLimit = _rightSide.min - w - mid; const auto min = std::clamp( x, 0., (rightLimit <= 0) ? _widthBetweenSides : rightLimit); _leftSide = Limits{ .min = min, .max = min + w }; } else if (!left) { const auto min = std::clamp(x, _leftSide.max + mid, _width); _rightSide = Limits{ .min = min, .max = min + w }; } } void ChartWidget::Footer::setPaintChartCallback( PaintCallback paintChartCallback) { _paintChartCallback = std::move(paintChartCallback); } void ChartWidget::Footer::paintEvent(QPaintEvent *e) { auto p = QPainter(this); auto hq = PainterHighQualityEnabler(p); const auto lineWidth = st::lineWidth; const auto innerMargins = QMargins{ 0, lineWidth, 0, lineWidth }; const auto r = rect(); const auto innerRect = r - innerMargins; const auto &inactiveColor = st::statisticsChartInactive; _frame.fill(Qt::transparent); if (_paintChartCallback) { auto q = QPainter(&_frame); { const auto opacity = q.opacity(); _paintChartCallback(q, Rect(innerRect.size())); q.setOpacity(opacity); } q.setCompositionMode(QPainter::CompositionMode_DestinationIn); q.drawImage(0, 0, _mask); } p.drawImage(0, lineWidth, _frame); auto inactivePath = QPainterPath(); inactivePath.addRoundedRect( innerRect, st::statisticsChartFooterSideRadius, st::statisticsChartFooterSideRadius); auto sidesPath = QPainterPath(); sidesPath.addRoundedRect( _leftSide.min, 0, _rightSide.max - _leftSide.min, r.height(), st::statisticsChartFooterSideRadius, st::statisticsChartFooterSideRadius); inactivePath = inactivePath.subtracted(sidesPath); sidesPath.addRect( _leftSide.max, lineWidth, _rightSide.min - _leftSide.max, r.height() - lineWidth * 2); p.setBrush(st::statisticsChartActive); p.setPen(Qt::NoPen); p.drawPath(sidesPath); p.setBrush(inactiveColor); p.drawPath(inactivePath); { p.setPen(_sidePen); const auto halfWidth = st::statisticsChartLineWidth / 2.; const auto left = _leftSide.min + (_leftSide.max - _leftSide.min) / 2. + halfWidth; const auto right = _rightSide.min + (_rightSide.max - _rightSide.min) / 2.; const auto halfHeight = st::statisticsChartFooterArrowHeight / 2. - halfWidth; const auto center = r.height() / 2.; const auto top = center - halfHeight; const auto bottom = center + halfHeight; p.drawLine(left, top, left, bottom); p.drawLine(right, top, right, bottom); } } void ChartWidget::Footer::setXPercentageLimits(const Limits &xLimits) { const auto left = xLimits.min * _widthBetweenSides; const auto right = xLimits.max * _widthBetweenSides + st::statisticsChartFooterSideWidth; moveSide(true, left); moveSide(false, right); fire(); update(); } rpl::producer ChartWidget::Footer::xPercentageLimitsChange() const { return _xPercentageLimitsChange.events(); } ChartWidget::ChartAnimationController::ChartAnimationController( Fn &&updateCallback) : _animation(std::move(updateCallback)) { } void ChartWidget::ChartAnimationController::setXPercentageLimits( Data::StatisticalChart &chartData, Limits xPercentageLimits, const std::unique_ptr &chartView, const std::shared_ptr &linesFilter, crl::time now) { if ((_animationValueXMin.to() == xPercentageLimits.min) && (_animationValueXMax.to() == xPercentageLimits.max) && linesFilter->isFinished()) { return; } start(); _animationValueXMin.start(xPercentageLimits.min); _animationValueXMax.start(xPercentageLimits.max); _lastUserInteracted = now; const auto startXIndex = chartData.findStartIndex( _animationValueXMin.to()); const auto endXIndex = chartData.findEndIndex( startXIndex, _animationValueXMax.to()); _currentXIndices = { float64(startXIndex), float64(endXIndex) }; { const auto heightLimits = chartView->heightLimits( chartData, _currentXIndices); if (heightLimits.ranged.min == heightLimits.ranged.max) { return; } _previousFullHeightLimits = _finalHeightLimits; _finalHeightLimits = heightLimits.ranged; if (!_previousFullHeightLimits.max) { _previousFullHeightLimits = _finalHeightLimits; } if (!linesFilter->isFinished()) { _animationValueFooterHeightMin = anim::value( _animationValueFooterHeightMin.current(), heightLimits.full.min); _animationValueFooterHeightMax = anim::value( _animationValueFooterHeightMax.current(), heightLimits.full.max); } else if (!_animationValueFooterHeightMax.to()) { // Will be finished in setChartData. _animationValueFooterHeightMin = anim::value( 0, heightLimits.full.min); _animationValueFooterHeightMax = anim::value( 0, heightLimits.full.max); } } _animationValueHeightMin = anim::value( _animationValueHeightMin.current(), _finalHeightLimits.min); _animationValueHeightMax = anim::value( _animationValueHeightMax.current(), _finalHeightLimits.max); { const auto previousDelta = _previousFullHeightLimits.max - _previousFullHeightLimits.min; auto k = previousDelta / float64(_finalHeightLimits.max - _finalHeightLimits.min); if (k > 1.) { k = 1. / k; } constexpr auto kDtHeightSpeed1 = 0.03 * 2; constexpr auto kDtHeightSpeed2 = 0.03 * 2; constexpr auto kDtHeightSpeed3 = 0.045 * 2; constexpr auto kDtHeightSpeedFilter = kDtHeightSpeed1 / 1.2; constexpr auto kDtHeightSpeedThreshold1 = 0.7; constexpr auto kDtHeightSpeedThreshold2 = 0.1; constexpr auto kDtHeightInstantThreshold = 0.97; if (k < 1.) { auto &alpha = _animationValueHeightAlpha; alpha = anim::value( (alpha.current() == alpha.to()) ? 0. : alpha.current(), 1.); _dtHeight.currentAlpha = 0.; _addRulerRequests.fire({}); } _dtHeight.speed = (!linesFilter->isFinished()) ? kDtHeightSpeedFilter : (k > kDtHeightSpeedThreshold1) ? kDtHeightSpeed1 : (k < kDtHeightSpeedThreshold2) ? kDtHeightSpeed2 : kDtHeightSpeed3; if (k < kDtHeightInstantThreshold) { _dtHeight.current = { 0., 0. }; } } } auto ChartWidget::ChartAnimationController::addRulerRequests() const -> rpl::producer<> { return _addRulerRequests.events(); } void ChartWidget::ChartAnimationController::start() { if (!_animation.animating()) { _animation.start(); } } void ChartWidget::ChartAnimationController::finish() { _animation.stop(); _animationValueXMin.finish(); _animationValueXMax.finish(); _animationValueHeightMin.finish(); _animationValueHeightMax.finish(); _animationValueFooterHeightMin.finish(); _animationValueFooterHeightMax.finish(); _animationValueHeightAlpha.finish(); _benchmark = {}; } void ChartWidget::ChartAnimationController::restartBottomLineAlpha() { _bottomLineAlphaAnimationStartedAt = crl::now(); _animValueBottomLineAlpha = anim::value(0., 1.); start(); } void ChartWidget::ChartAnimationController::tick( crl::time now, ChartRulersView &rulersView, std::vector &dateLines, const std::unique_ptr &chartView, const std::shared_ptr &linesFilter) { if (!_animation.animating()) { return; } constexpr auto kXExpandingDuration = 200.; constexpr auto kAlphaExpandingDuration = 200.; { constexpr auto kIdealFPS = float64(60); const auto currentFPS = _benchmark.lastTickedAt ? (1000. / (now - _benchmark.lastTickedAt)) : kIdealFPS; if (!_benchmark.lastFPSSlow) { constexpr auto kAcceptableFPS = int(30); _benchmark.lastFPSSlow = (currentFPS < kAcceptableFPS); } _benchmark.lastTickedAt = now; const auto k = (kIdealFPS / currentFPS) // Speed up to reduce ugly frames count. * (_benchmark.lastFPSSlow ? 2. : 1.); const auto speed = _dtHeight.speed * k; linesFilter->tick(speed); _dtHeight.current.min = std::min(_dtHeight.current.min + speed, 1.); _dtHeight.current.max = std::min(_dtHeight.current.max + speed, 1.); _dtHeight.currentAlpha = std::min(_dtHeight.currentAlpha + speed, 1.); } const auto dtX = std::min( (now - _animation.started()) / kXExpandingDuration, 1.); const auto dtBottomLineAlpha = std::min( (now - _bottomLineAlphaAnimationStartedAt) / kAlphaExpandingDuration, 1.); const auto isFinished = [](const anim::value &anim) { return anim.current() == anim.to(); }; const auto xFinished = isFinished(_animationValueXMin) && isFinished(_animationValueXMax); const auto yFinished = isFinished(_animationValueHeightMin) && isFinished(_animationValueHeightMax); const auto alphaFinished = isFinished(_animationValueHeightAlpha) && isFinished(_animationValueHeightMax); const auto bottomLineAlphaFinished = isFinished( _animValueBottomLineAlpha); const auto footerMinFinished = isFinished(_animationValueFooterHeightMin); const auto footerMaxFinished = isFinished(_animationValueFooterHeightMax); if (xFinished && yFinished && alphaFinished && bottomLineAlphaFinished && footerMinFinished && footerMaxFinished && linesFilter->isFinished()) { if ((_finalHeightLimits.min == _animationValueHeightMin.to()) && _finalHeightLimits.max == _animationValueHeightMax.to()) { _animation.stop(); _benchmark = {}; } } if (xFinished) { _animationValueXMin.finish(); _animationValueXMax.finish(); } else { _animationValueXMin.update(dtX, anim::linear); _animationValueXMax.update(dtX, anim::linear); } if (bottomLineAlphaFinished) { _animValueBottomLineAlpha.finish(); _bottomLineAlphaAnimationStartedAt = 0; } else { _animValueBottomLineAlpha.update( dtBottomLineAlpha, anim::easeInCubic); } if (!yFinished) { _animationValueHeightMin.update( _dtHeight.current.min, anim::easeInCubic); _animationValueHeightMax.update( _dtHeight.current.max, anim::easeInCubic); rulersView.computeRelative( _animationValueHeightMax.current(), _animationValueHeightMin.current()); } if (!footerMinFinished) { _animationValueFooterHeightMin.update( _dtHeight.current.min, anim::easeInCubic); } if (!footerMaxFinished) { _animationValueFooterHeightMax.update( _dtHeight.current.max, anim::easeInCubic); } if (!alphaFinished) { _animationValueHeightAlpha.update( _dtHeight.currentAlpha, anim::easeInCubic); rulersView.setAlpha(_animationValueHeightAlpha.current()); } if (!bottomLineAlphaFinished) { const auto value = _animValueBottomLineAlpha.current(); for (auto &date : dateLines) { date.alpha = (1. - value) * date.fixedAlpha; } dateLines.back().alpha = value; } else { if (dateLines.size() > 1) { const auto data = dateLines.back(); dateLines.clear(); dateLines.push_back(data); } } } Limits ChartWidget::ChartAnimationController::currentXLimits() const { return { _animationValueXMin.current(), _animationValueXMax.current() }; } Limits ChartWidget::ChartAnimationController::currentXIndices() const { return _currentXIndices; } Limits ChartWidget::ChartAnimationController::finalXLimits() const { return { _animationValueXMin.to(), _animationValueXMax.to() }; } Limits ChartWidget::ChartAnimationController::currentHeightLimits() const { return { _animationValueHeightMin.current(), _animationValueHeightMax.current(), }; } auto ChartWidget::ChartAnimationController::currentFooterHeightLimits() const -> Limits { return { _animationValueFooterHeightMin.current(), _animationValueFooterHeightMax.current(), }; } Limits ChartWidget::ChartAnimationController::finalHeightLimits() const { return _finalHeightLimits; } bool ChartWidget::ChartAnimationController::animating() const { return _animation.animating(); } bool ChartWidget::ChartAnimationController::footerAnimating() const { return (_animationValueFooterHeightMin.current() != _animationValueFooterHeightMin.to()) || (_animationValueFooterHeightMax.current() != _animationValueFooterHeightMax.to()); } ChartWidget::ChartWidget(not_null parent) : Ui::RpWidget(parent) , _chartArea(base::make_unique_q(this)) , _header(std::make_unique
(this)) , _footer(std::make_unique