diff --git a/src/Gui/GcWindowRegistry.cpp b/src/Gui/GcWindowRegistry.cpp index de7b0713b..1de592a6a 100644 --- a/src/Gui/GcWindowRegistry.cpp +++ b/src/Gui/GcWindowRegistry.cpp @@ -43,6 +43,7 @@ #include "MetadataWindow.h" #include "TreeMapWindow.h" #include "DialWindow.h" +#include "ElevationChartWindow.h" #include "RealtimePlotWindow.h" #include "SpinScanPlotWindow.h" #include "WorkoutPlotWindow.h" @@ -69,7 +70,7 @@ GcWindowRegistry* GcWindows; void GcWindowRegistry::initialize() { - static GcWindowRegistry GcWindowsInit[34] = { + static GcWindowRegistry GcWindowsInit[35] = { // name GcWinID { VIEW_TRENDS|VIEW_DIARY, tr("Season Overview"),GcWindowTypes::OverviewTrends }, { VIEW_TRENDS|VIEW_DIARY, tr("Blank Overview "),GcWindowTypes::OverviewTrendsBlank }, @@ -111,6 +112,7 @@ GcWindowRegistry::initialize() { VIEW_TRAIN, tr("Video Player"),GcWindowTypes::VideoPlayer }, { VIEW_TRAIN, tr("Workout Editor"),GcWindowTypes::WorkoutWindow }, { VIEW_TRAIN, tr("Live Map"),GcWindowTypes::LiveMapWebPageWindow }, + { VIEW_TRAIN, tr("Elevation Chart"),GcWindowTypes::ElevationChart }, { VIEW_ANALYSIS|VIEW_TRENDS|VIEW_TRAIN, tr("Web page"),GcWindowTypes::WebPageWindow }, { 0, "", GcWindowTypes::None }}; // initialize the global registry @@ -231,6 +233,7 @@ GcWindowRegistry::newGcWindow(GcWinID id, Context *context) case GcWindowTypes::WebPageWindow: returning = new WebPageWindow(context); break; case GcWindowTypes::LiveMapWebPageWindow: returning = new LiveMapWebPageWindow(context); break; + case GcWindowTypes::ElevationChart: returning = new ElevationChartWindow(context); break; #if 0 // not till v4.0 case GcWindowTypes::RouteSegment: returning = new RouteWindow(context); break; #else diff --git a/src/Gui/GcWindowRegistry.h b/src/Gui/GcWindowRegistry.h index c5a86b9e3..39b89aebd 100644 --- a/src/Gui/GcWindowRegistry.h +++ b/src/Gui/GcWindowRegistry.h @@ -76,8 +76,8 @@ enum gcwinid { OverviewTrends=47, LiveMapWebPageWindow = 48, OverviewAnalysisBlank=49, - OverviewTrendsBlank=50 - + OverviewTrendsBlank=50, + ElevationChart=51 }; }; typedef enum GcWindowTypes::gcwinid GcWinID; diff --git a/src/Gui/HelpWhatsThis.cpp b/src/Gui/HelpWhatsThis.cpp index 7c58ec679..481930675 100644 --- a/src/Gui/HelpWhatsThis.cpp +++ b/src/Gui/HelpWhatsThis.cpp @@ -290,6 +290,8 @@ HelpWhatsThis::getText(GCHelp chapter) { return text.arg("ChartTypes_Train#workout-editor").arg(tr("Edition and diplay of ergometer type workout files")); case ChartTrain_LiveMap: return text.arg("ChartTypes_Train#live-map").arg(tr("Real time display of the route of simulation workouts in an Open Street Map")); + case ChartTrain_Elevation: + return text.arg("ChartTypes_Train#elevation").arg(tr("Show elevation profile of instant position")); // Sidebars case SideBarTrendsView_DateRanges: diff --git a/src/Gui/HelpWhatsThis.h b/src/Gui/HelpWhatsThis.h index 8945f7084..1eaac4c5c 100644 --- a/src/Gui/HelpWhatsThis.h +++ b/src/Gui/HelpWhatsThis.h @@ -155,6 +155,7 @@ Q_OBJECT ChartTrain_VideoPlayer, ChartTrain_WorkoutEditor, ChartTrain_LiveMap, + ChartTrain_Elevation, SideBarTrendsView_DateRanges, SideBarTrendsView_Events, diff --git a/src/Train/ElevationChartWindow.cpp b/src/Train/ElevationChartWindow.cpp new file mode 100644 index 000000000..ea4ecddf9 --- /dev/null +++ b/src/Train/ElevationChartWindow.cpp @@ -0,0 +1,342 @@ +#include +#include + +#include "ElevationChartWindow.h" +#include "Context.h" +#include "Colors.h" +#include "HelpWhatsThis.h" +#include + + +namespace elevationChart { + + +//Set bubble color based on % grade + +// With cxx17 the class compiles to readonly memory and no template parameters are needed. +// Someday... +#if defined(CXX17) +#define CONSTEXPR constexpr +#define CONSTEXPR_FUNC constexpr +#else constexpr +#define CONSTEXPR static const +#define CONSTEXPR_FUNC +#endif + +struct RangeColorCriteria { + double m_point; + QColor m_color; + + CONSTEXPR_FUNC RangeColorCriteria(double p, QColor c) : m_point(p), m_color(c) {} +}; + +template struct RangeColorMapper { + std::array m_colorMap; + + QColor toColor(double m) const { + if (m <= m_colorMap[0].m_point) return m_colorMap[0].m_color; + + for (size_t i = 1; i < T_size; i++) { + if (m < m_colorMap[i].m_point) { + const RangeColorCriteria& start = m_colorMap[i - 1]; + const RangeColorCriteria& end = m_colorMap[i]; + + double unit = (m - start.m_point) / (end.m_point - start.m_point); + + int sh, ss, sv; + start.m_color.getHsv(&sh, &ss, &sv); + + int eh, es, ev; + end.m_color.getHsv(&eh, &es, &ev); + + return QColor::fromHsv(sh + unit * (eh - sh), // lerp + ss + unit * (es - ss), // it + sv + unit * (ev - sv), // real good + 128); // 50% transparency + } + } + + return m_colorMap[T_size - 1].m_color; + } +}; + +#ifdef CXX17 +// Template deduction guide for RangeColorMapper +template struct EnforceSame { + static_assert(std::conjunction_v...>); + using type = First; +}; +template RangeColorMapper(First, Rest...) +->RangeColorMapper2::type, 1 + sizeof...(Rest)>; +#endif + +CONSTEXPR RangeColorMapper s_gradientToColorMapper{ + RangeColorCriteria(-10., Qt::black), + RangeColorCriteria(0., Qt::white), + RangeColorCriteria(1., Qt::yellow), + RangeColorCriteria(3., QColor(255, 140, 0, 255)), // orange + RangeColorCriteria(4., Qt::red) +}; + + +void BubbleWidget::paintEvent(QPaintEvent *event) +{ + if (!m_rtData || !m_ergFileAdapter) + return; + + QWidget::paintEvent(event); + + QPainter bubblePainter(this); + bubblePainter.setRenderHint(QPainter::Antialiasing); + bubblePainter.setCompositionMode(QPainter::CompositionMode_SourceOver); + + // Set bubble painter pen and brush + QPen bubblePen; + bubblePen.setColor(Qt::black); + bubblePen.setWidth(3); + bubblePen.setStyle(Qt::SolidLine); + bubblePainter.setPen(bubblePen); + + // Average slope in deltaSeconds seconds (taking into account current speed) + + int lap; + geolocation geoloc; + double diffSlope; + double dummy_gradient; + double speed = m_rtData->getSpeed(); + double gradientValue = m_rtData->getSlope(); + double distDeltaseconds = speed / 3.6 * deltaSeconds; + double currDist = m_rtData->getRouteDistance() * 1000.0; + m_ergFileAdapter->locationAt(currDist + distDeltaseconds, lap, geoloc, dummy_gradient); + double alt2 = geoloc.Alt(); + m_ergFileAdapter->locationAt(currDist, lap, geoloc, dummy_gradient); + double alt = geoloc.Alt(); + double averSlope = (alt2 - alt) / distDeltaseconds * 100.0; + + diffSlope = averSlope - gradientValue; + + QColor bubbleColor = s_gradientToColorMapper.toColor(diffSlope); + bubblePainter.setBrush(bubbleColor); + + double bubbleRadius = std::min(width(), height()) / 5.0; + double maxDiffSlope = 5.0; + double bubbleX = width() - bubbleRadius; + double bubbleY = - height() / maxDiffSlope * std::max(std::min(diffSlope, maxDiffSlope), 0.0) + height() - bubbleRadius; + + bubblePainter.drawEllipse(QPointF(bubbleX, bubbleY), (qreal)bubbleRadius, (qreal)bubbleRadius); + + QString diffSlopeString = (diffSlope < 0.0 ? QString("-") : QString("+")) + QString::number(abs((int)diffSlope)) + + QString(".") + QString::number(abs((int)(diffSlope * 10.0)) % 10) + QString("%"); + + // Display diff gradient text in the bubble + bubblePainter.drawText(bubbleX - 15 , bubbleY + 5, diffSlopeString); + +} + + +void SlopeWidget::paintEvent(QPaintEvent *event) +{ + QWidget::paintEvent(event); + + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + painter.setCompositionMode(QPainter::CompositionMode_SourceOver); + + // Set pen and brush for the plot + QPen pen(QColor(255,0,0,180)); + pen.setWidth(2); + pen.setStyle(Qt::SolidLine); + painter.setPen(pen); + QBrush brush(QColor(153, 76, 0, 128)); // Blue color with transparency + painter.setBrush(brush); + + if (plotQ.isEmpty()) + return; + + // Calculate scaling factors + double xScale = width() / (plotQ.last().x() - plotQ.first().x()); + double yMin = std::numeric_limits::max(); + double yMax; + + for (const QPointF &point : plotQ) + { + if (point.y() < yMin) yMin = point.y(); + } + + // yMax is a % of the distance over yMin + yMax = yMin + (plotQ.last().x() - plotQ.first().x()) * m_yPlotScale / 100.0; + double yScale = height() / (yMax - yMin); + + // Create a QPolygonF to hold the points + QPolygonF polygon; + for (const QPointF &point : plotQ) + { + QPointF scaledPoint((point.x() - plotQ.first().x()) * xScale, height() - (point.y() - yMin) * yScale); + polygon << scaledPoint; + } + + // Add points to close the polygon at the bottom + polygon << QPointF((plotQ.last().x() - plotQ.first().x()) * xScale, height()); + polygon << QPointF(0, height()); + + // Draw the polygon + painter.drawPolygon(polygon); + + // Draw a vertical line at current position + QPen linePen(GColor(CPLOTMARKER)); + linePen.setWidth(2); + painter.setPen(linePen); + int currPosX = (m_currPos - plotQ.first().x()) * xScale; + painter.drawLine(currPosX, 0, currPosX, height()); + QFont font = painter.font(); + font.setPointSize(14); + painter.setFont(font); + QString textSlope = QString("%1%").arg(m_slope, 0, 'f', 1); + QFontMetrics fm(font); + int textWidth = fm.horizontalAdvance(textSlope); + if (currPosX > textWidth + 5) + painter.drawText(currPosX - textWidth - 5, 15, textSlope); + else + painter.drawText(currPosX + 5, 15, textSlope); + +} + +} // namespace elevationChart + + +ElevationChartWindow::ElevationChartWindow(Context *context) : + GcChartWindow(context) +{ + HelpWhatsThis *helpContents = new HelpWhatsThis(this); + this->setWhatsThis(helpContents->getWhatsThisText(HelpWhatsThis::ChartTrain_Elevation)); + + QWidget *settingsWidget = new QWidget(this); + HelpWhatsThis *helpConfig = new HelpWhatsThis(settingsWidget); + settingsWidget->setWhatsThis(helpConfig->getWhatsThisText(HelpWhatsThis::ChartTrain_Elevation)); + settingsWidget->setContentsMargins(0,0,0,0); + setProperty("color", GColor(CTRAINPLOTBACKGROUND)); + + QFormLayout* commonLayout = new QFormLayout(settingsWidget); + + customPlotDistanceLabel = new QLabel(tr("Profile Distance (m)")); + customPlotDistance = new QSpinBox(this); + customPlotDistance->setFixedWidth(60); + if (customPlotDistance->text().trimmed().isEmpty()) customPlotDistance->setValue(500); + customPlotDistance->setRange(50, 2000); + + commonLayout->addRow(customPlotDistanceLabel, customPlotDistance); + + customDeltaSlopeSecondsLabel = new QLabel(tr("Seconds for delta slope")); + customDeltaSlopeSeconds = new QSpinBox(this); + customDeltaSlopeSeconds->setFixedWidth(60); + if (customDeltaSlopeSeconds->text().trimmed().isEmpty()) customDeltaSlopeSeconds->setValue(10); + customDeltaSlopeSeconds->setRange(5, 600); + + commonLayout->addRow(customDeltaSlopeSecondsLabel, customDeltaSlopeSeconds); + + customyPlotScaleLabel = new QLabel(tr("Elevation window size (%) relative to current altitude")); + customyPlotScale = new QSpinBox(this); + customyPlotScale->setSuffix("%"); + customyPlotScale->setFixedWidth(60); + if (customyPlotScale->text().trimmed().isEmpty()) customyPlotScale->setValue(10); + customyPlotScale->setRange(5, 20.0); + + commonLayout->addRow(customyPlotScaleLabel, customyPlotScale); + + setControls(settingsWidget); + setContentsMargins(0, 0, 0, 0); + + // Data shown in the chart + + QVBoxLayout *mainLayout = new QVBoxLayout; + mainLayout->setSpacing(0); + mainLayout->setContentsMargins(3,3,3,3); + + // Container widget for the overlaid widgets + QWidget *containerWidget = new QWidget; + containerWidget->setContentsMargins(3,3,3,3); + + // Layout for the container that will hold both widgets + QGridLayout *overlayLayout = new QGridLayout(containerWidget); + overlayLayout->setSpacing(0); + overlayLayout->setContentsMargins(0,0,0,0); + + + slopeWidget = new elevationChart::SlopeWidget(this); + slopeWidget->setAttribute(Qt::WA_TransparentForMouseEvents); + slopeWidget->setStyleSheet("background:transparent;"); + overlayLayout->addWidget(slopeWidget, 0, 0); + + + bubbleWidget = new elevationChart::BubbleWidget(customDeltaSlopeSeconds->value(), this); + bubbleWidget->setAttribute(Qt::WA_TransparentForMouseEvents); + bubbleWidget->setStyleSheet("background:transparent;"); + overlayLayout->addWidget(bubbleWidget, 0, 0); + + mainLayout->addWidget(containerWidget); + setChartLayout(mainLayout); + + // get updates.. + connect(context, SIGNAL(telemetryUpdate(RealtimeData)), this, SLOT(telemetryUpdate(RealtimeData))); + connect(context, SIGNAL(ergFileSelected(ErgFile*)), this, SLOT(ergFileSelected(ErgFile*))); + + ergFileSelected(context->currentErgFile()); +} + + +void ElevationChartWindow::ergFileSelected(ErgFile* f) +{ + if (!f || f->filename() == "" ) + return; + m_ergFileAdapter.setErgFile(f); + bubbleWidget->setErgFileAdapter(&m_ergFileAdapter); +} + + +void +ElevationChartWindow::paintEvent(QPaintEvent *event) +{ + GcChartWindow::paintEvent(event); +} + +void +ElevationChartWindow::telemetryUpdate(const RealtimeData &rtData) +{ + // If it is not visible, it saves time + if (isHidden()) + return; + m_rtData = rtData; + bubbleWidget->setRealtimeData(&m_rtData); + + int npoints = 30; + int pointsAfter = 25; + int pointsBefore = npoints - pointsAfter; + + QQueue &plotQ = slopeWidget->getPlotQ(); + plotQ.clear(); + m_ergFileAdapter.resetQueryState(); + + + double x0 = m_rtData.getRouteDistance() * 1000.0; // Current point + double x1 = std::min(x0 + customPlotDistance->value(), m_ergFileAdapter.Duration()); // last point drawn + double xs = std::max(x0 - customPlotDistance->value() / 4.0, 0.0); // first point drawn + + double pointLength = (x1 - xs) / npoints; + + for (int i = 0; i < npoints; i++) + { + int lap; + double x = xs + pointLength * i; + double y = m_ergFileAdapter.altitudeAt(x, lap); + plotQ.enqueue(QPointF(x, y)); + } + + // Data to show current position, slope and elevation window size + slopeWidget->setSlope(m_rtData.getSlope()); + slopeWidget->setCurrPos(x0); + slopeWidget->setyPlotScale(customyPlotScale->value()); + + // This forces a call to paintEvent(), since it is only called when the mouse is passed over the chart, + // or when Update() is invoked and it is visible + update(); +} diff --git a/src/Train/ElevationChartWindow.h b/src/Train/ElevationChartWindow.h new file mode 100644 index 000000000..e0aa9da7f --- /dev/null +++ b/src/Train/ElevationChartWindow.h @@ -0,0 +1,123 @@ +#ifndef _GC_ElevationChartWindow_h +#define _GC_ElevationChartWindow_h 1 + +#include +#include "GoldenCheetah.h" + + +#include "ErgFile.h" +#include "RealtimeData.h" + +namespace elevationChart { + + // Two classes in this namespace, each one to show a figure in the chart window: + // - BubbleWidget: to show the elevation gradient in a bubble, whose color and position reflects the gradient. + // - SlopeWidget: to show current position and slope, and the profile for the short term + // They both overlap the other, so they are shown in the same space + + class BubbleWidget : public QWidget + { + Q_OBJECT + + public: + explicit BubbleWidget(double deltaSeconds_, QWidget *parent = nullptr) : + deltaSeconds(deltaSeconds_), QWidget(parent), m_rtData(nullptr), m_ergFileAdapter(nullptr) + { + // Set a size policy to allow resizing + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + } + + void setRealtimeData(RealtimeData *rtData) { m_rtData = rtData; } + + void setErgFileAdapter(ErgFileQueryAdapter *ergFileAdapter) { m_ergFileAdapter = ergFileAdapter; } + + private: + RealtimeData *m_rtData; + ErgFileQueryAdapter *m_ergFileAdapter; + double deltaSeconds; + + protected: + void paintEvent(QPaintEvent *event) override; + }; + + + class SlopeWidget : public QWidget + { + Q_OBJECT + public: + //explicit SlopeWidget(QWidget *parent = nullptr) : QWidget(parent), m_rtData(nullptr), m_ergFileAdapter(nullptr) + explicit SlopeWidget(QWidget *parent = nullptr) : QWidget(parent) + { + // Set a size policy to allow resizing + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + } + + QQueue& getPlotQ() { return plotQ; } + void setSlope(double slope) { m_slope = slope; } + void setCurrPos(double currPos) { m_currPos = currPos; } + void setyPlotScale(int yPlotScale) { m_yPlotScale = yPlotScale; } + + protected: + void paintEvent(QPaintEvent *event) override; + + private: + QQueue plotQ; + double m_slope; + double m_currPos; // Current position + int m_yPlotScale; // Elevation window size + }; + +} // namespace elevationChart + +class Context; + +class ElevationChartWindow : public GcChartWindow +{ + Q_OBJECT + G_OBJECT + + // properties can be saved/restored/set by the layout manager + Q_PROPERTY(int deltaSlopeSeconds READ deltaSlopeSeconds WRITE setDeltaSlopeSeconds USER true) + Q_PROPERTY(int plotDistance READ plotDistance WRITE setPlotDistance USER true) + Q_PROPERTY(int yPlotScale READ yPlotScale WRITE setyPlotScale USER true) + + public: + + ElevationChartWindow(Context *context); + + // set/get properties + int deltaSlopeSeconds() const { return customDeltaSlopeSeconds->value(); } + void setDeltaSlopeSeconds(int x) { customDeltaSlopeSeconds->setValue(x); } + int plotDistance() const { return customPlotDistance->value(); } + void setPlotDistance(int x) { customPlotDistance->setValue(x); } + int yPlotScale() const { return customyPlotScale->value(); } + void setyPlotScale(int x) { customyPlotScale->setValue(x); } + + + public slots: + void ergFileSelected(ErgFile*); + void telemetryUpdate(const RealtimeData &rtData); // got new data + + protected: + void paintEvent(QPaintEvent *event) override; + + private: + // Settings data + QLabel* customPlotDistanceLabel; + QLabel* customDeltaSlopeSecondsLabel; + QLabel* customyPlotScaleLabel; + QSpinBox* customPlotDistance; + QSpinBox* customDeltaSlopeSeconds; + QSpinBox* customyPlotScale; + + // Chart widgets + elevationChart::BubbleWidget *bubbleWidget; + elevationChart::SlopeWidget *slopeWidget; + + // Configuration data + ErgFileQueryAdapter m_ergFileAdapter; + RealtimeData m_rtData; + +}; + +#endif // _GC_ElevationChartWindow_h diff --git a/src/src.pro b/src/src.pro index 08e784590..430110a25 100644 --- a/src/src.pro +++ b/src/src.pro @@ -704,7 +704,7 @@ HEADERS += Train/AddDeviceWizard.h Train/CalibrationData.h Train/ComputrainerCon Train/VideoSyncFileBase.h Train/ErgFileBase.h \ Train/ModelFilter.h Train/MultiFilterProxyModel.h Train/WorkoutFilter.h Train/FilterEditor.h \ Train/WorkoutFilterBox.h Train/TagBar.h Train/Taggable.h Train/TagStore.h Train/TagWidget.h \ - Train/TrainerDayAPIQuery.h Train/TrainerDayAPIDialog.h + Train/TrainerDayAPIQuery.h Train/TrainerDayAPIDialog.h Train/ElevationChartWindow.h HEADERS += Train/TrainBottom.h Train/TrainDB.h Train/TrainSidebar.h \ Train/VideoLayoutParser.h Train/VideoSyncFile.h Train/WorkoutPlotWindow.h Train/WebPageWindow.h \ @@ -817,7 +817,7 @@ SOURCES += Train/AddDeviceWizard.cpp Train/CalibrationData.cpp Train/Computraine Train/VideoSyncFileBase.cpp Train/ErgFileBase.cpp \ Train/ModelFilter.cpp Train/MultiFilterProxyModel.cpp Train/WorkoutFilter.cpp Train/FilterEditor.cpp \ Train/WorkoutFilterBox.cpp Train/TagBar.cpp Train/TagWidget.cpp \ - Train/TrainerDayAPIQuery.cpp Train/TrainerDayAPIDialog.cpp + Train/TrainerDayAPIQuery.cpp Train/TrainerDayAPIDialog.cpp Train/ElevationChartWindow.cpp SOURCES += Train/TrainBottom.cpp Train/TrainDB.cpp Train/TrainSidebar.cpp \ Train/VideoLayoutParser.cpp Train/VideoSyncFile.cpp Train/WorkoutPlotWindow.cpp Train/WebPageWindow.cpp \