mirror of
https://github.com/GoldenCheetah/GoldenCheetah.git
synced 2026-02-15 00:49:55 +00:00
2513 lines
70 KiB
C++
2513 lines
70 KiB
C++
/*
|
|
* Copyright (c) 2015 Mark Liversedge (liversedge@gmail.com)
|
|
*
|
|
* This program is free software; you can redistribute it and/or modify it
|
|
* under the terms of the GNU General Public License as published by the Free
|
|
* Software Foundation; either version 2 of the License, or (at your option)
|
|
* any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful, but WITHOUT
|
|
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
|
* more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License along
|
|
* with this program; if not, write to the Free Software Foundation, Inc., 51
|
|
* Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
|
*/
|
|
|
|
|
|
#include "WorkoutWidget.h"
|
|
#include "WorkoutWindow.h"
|
|
#include "WorkoutWidgetItems.h"
|
|
|
|
#include "WPrime.h"
|
|
#include "ErgFile.h"
|
|
#include "RideFile.h"
|
|
#include "RideFileCache.h"
|
|
#include "RealtimeData.h"
|
|
|
|
#include "TimeUtils.h" // time_to_string()
|
|
|
|
#include <QFontMetrics>
|
|
#include <QRegExp>
|
|
|
|
#include <cmath>
|
|
#include <float.h> // DBL_EPSILON
|
|
|
|
static int MINTOOLHEIGHT = 350; // minimum size for a full editor
|
|
static int RECOVERY = 70; // anything below 70% of CP is a recovery effort
|
|
|
|
void WorkoutWidget::adjustLayout()
|
|
{
|
|
// adjust all the settings based upon current size
|
|
if (height() > MINTOOLHEIGHT) {
|
|
|
|
// big, can edit and all widgets shown
|
|
IHEIGHT = 10;
|
|
THEIGHT = 35;
|
|
BHEIGHT = 35;
|
|
LWIDTH = 65;
|
|
RWIDTH = 35;
|
|
XTICLENGTH = 3;
|
|
YTICLENGTH = 0;
|
|
XTICS = 20;
|
|
YTICS = 10;
|
|
SPACING = 2; // between labels and tics (if there are tics)
|
|
XMOVE = 5; // how many to move X when cursoring
|
|
YMOVE = 1; // how many to move Y when cursoring
|
|
GRIDLINES = true;
|
|
|
|
} else {
|
|
|
|
// mini mode
|
|
IHEIGHT = 0;
|
|
THEIGHT = 0;
|
|
BHEIGHT = 20;
|
|
LWIDTH = 10;
|
|
RWIDTH = 10;
|
|
XTICLENGTH = 3;
|
|
YTICLENGTH = 0;
|
|
XTICS = 20;
|
|
YTICS = 5;
|
|
SPACING = 2; // between labels and tics (if there are tics)
|
|
XMOVE = 5; // how many to move X when cursoring
|
|
YMOVE = 1; // how many to move Y when cursoring
|
|
GRIDLINES = false;
|
|
}
|
|
}
|
|
|
|
WorkoutWidget::WorkoutWidget(WorkoutWindow *parent, Context *context) :
|
|
QWidget(parent), state(none), ergFile(NULL), dragging(NULL), parent(parent), context(context), stackptr(0), recording_(false)
|
|
{
|
|
maxX_=3600;
|
|
maxY_=400;
|
|
|
|
// when plotting telemetry these are maxY for those series
|
|
cadenceMax = 200; // make it line up between power and hr
|
|
hrMax = 220;
|
|
speedMax = 50;
|
|
|
|
onDrag = onCreate = onRect = atRect = QPointF(-1,-1);
|
|
qwkactive = false;
|
|
|
|
// watch mouse events for user interaction
|
|
adjustLayout();
|
|
installEventFilter(this);
|
|
setMouseTracking(true);
|
|
|
|
connect(context, SIGNAL(configChanged(qint32)), this, SLOT(configChanged(qint32)));
|
|
connect(context, SIGNAL(ergFileSelected(ErgFile*)), this, SLOT(ergFileSelected(ErgFile*)));
|
|
connect(context, SIGNAL(telemetryUpdate(RealtimeData)), this, SLOT(telemetryUpdate(RealtimeData)));
|
|
configChanged(CONFIG_APPEARANCE);
|
|
}
|
|
|
|
void
|
|
WorkoutWidget::start()
|
|
{
|
|
recording_ = true;
|
|
|
|
// if we have edited the erg we need to update the in-memory points
|
|
if (ergFile && stack.count()) {
|
|
|
|
// replace all the points
|
|
ergFile->Points.clear();
|
|
ergFile->Duration = 0;
|
|
foreach(WWPoint *p, points_) {
|
|
ergFile->Points.append(ErgFilePoint(p->x * 1000, p->y, p->y));
|
|
ergFile->Duration = p->x * 1000; // whatever the last is
|
|
}
|
|
|
|
// force any other plots to take the changes
|
|
context->notifyErgFileSelected(ergFile);
|
|
}
|
|
|
|
// clear previous data
|
|
wbal.clear();
|
|
watts.clear();
|
|
hr.clear();
|
|
speed.clear();
|
|
cadence.clear();
|
|
|
|
// and resampling data
|
|
count = wbalSum = wattsSum = hrSum = speedSum = cadenceSum =0;
|
|
|
|
// set initial
|
|
cadenceMax = 200;
|
|
hrMax = 220;
|
|
speedMax = 50;
|
|
|
|
// replot
|
|
update();
|
|
}
|
|
|
|
void
|
|
WorkoutWidget::stop()
|
|
{
|
|
recording_ = false;
|
|
update();
|
|
}
|
|
|
|
void
|
|
WorkoutWidget::telemetryUpdate(RealtimeData rt)
|
|
{
|
|
// only plot when recording
|
|
if (!recording_) return;
|
|
|
|
wbalSum += rt.getWbal();
|
|
wattsSum += rt.getWatts();
|
|
hrSum += rt.getHr();
|
|
cadenceSum += rt.getCadence();
|
|
speedSum += rt.getSpeed();
|
|
|
|
count++;
|
|
|
|
// did we get 5 samples (5hz refresh rate) ?
|
|
if (count == 5) {
|
|
int b = wbalSum / 5.0f;
|
|
wbal << b;
|
|
int w = wattsSum / 5.0f;
|
|
watts << w;
|
|
int h = hrSum / 5.0f;
|
|
hr << h;
|
|
double s = speedSum / 5.0f;
|
|
speed << s;
|
|
int c = cadenceSum / 5.0f;
|
|
cadence << c;
|
|
|
|
// clear for next time
|
|
count = wbalSum = wattsSum = hrSum = speedSum = cadenceSum =0;
|
|
|
|
// do we need to increase maxes?
|
|
if (c > cadenceMax) cadenceMax=c;
|
|
if (s > speedMax) speedMax=s;
|
|
if (h > hrMax) hrMax=h;
|
|
|
|
// replot
|
|
update();
|
|
}
|
|
}
|
|
|
|
void
|
|
WorkoutWidget::timeout()
|
|
{
|
|
// into event filter (our state machine)
|
|
QEvent timer(QEvent::Timer);
|
|
eventFilter(this, &timer);
|
|
}
|
|
|
|
|
|
// Inbound events are processed through a "state machine" that reacts
|
|
// to each event depending upon the current state.
|
|
//
|
|
// Since there are only a handful of states and the transitions are
|
|
// not complex this is performed using basic if/else clauses rather
|
|
// than an FSM.
|
|
//
|
|
// The state transitions are complex enough to need documenting:
|
|
//
|
|
//
|
|
// STATE MACHINE
|
|
//
|
|
// # EVENT STATE ACTION NEXT STATE
|
|
// - -------------- ------ --------------- ----------
|
|
// 1 mouse move none hover/unhover point/block none
|
|
// none hover/unhover lap marker none
|
|
// drag move point around drag
|
|
// dragblock move block around dragblock
|
|
// rect resize and scan for selections rect
|
|
// create add point and move it drag
|
|
//
|
|
// 2 mouse click none hovering? drag point drag
|
|
// not shifted none not hovering? set to create create
|
|
//
|
|
// 3 mouse release drag unselect none
|
|
// rect none none
|
|
// create create point none
|
|
// dragblock create block none
|
|
//
|
|
// 4 mouse timeout create create block dragblock
|
|
// drag ignore drag
|
|
//
|
|
//
|
|
// 5 mouse wheel none rescale selectes/all none
|
|
// up and down drag ignore drag
|
|
//
|
|
//
|
|
// 6 mouse click none hovering? select point none
|
|
// shifted none not hovering? begin select rect
|
|
//
|
|
//
|
|
// 7 key press any ESC clear selection unchanged
|
|
// any ^Z undo, ^Y redo ^X cut unchanged
|
|
// any ^A select all
|
|
// any cursors move selected unchanged
|
|
// any DEL delete selected unchanged
|
|
//
|
|
//
|
|
// 8 mouse enter any grab keyboard focus unchanged
|
|
//
|
|
// 9 screen resize any recalculate geometry objects unchanged
|
|
// e.g. selection/cursor Block
|
|
//
|
|
|
|
bool
|
|
WorkoutWidget::eventFilter(QObject *obj, QEvent *event)
|
|
{
|
|
// process as normal if not one of ours
|
|
if (obj != this) return false;
|
|
|
|
// where is the cursor
|
|
QPoint p = mapFromGlobal(QCursor::pos());
|
|
QPointF v = reverseTransform(p.x(),p.y());
|
|
|
|
// is a repaint going to be needed?
|
|
bool updateNeeded=false;
|
|
|
|
// are we filtering out the event? (e.g. keyboard / scroll)
|
|
bool filterNeeded=false;
|
|
|
|
//
|
|
// 1 MOUSE MOVE [we always repaint]
|
|
//
|
|
if (event->type() == QEvent::MouseMove) {
|
|
|
|
// forget the onCreate!
|
|
onCreate = QPoint(-1,-1);
|
|
|
|
// always update the x/y values in the toolbar
|
|
parent->xlabel->setText(time_to_string(v.x()));
|
|
parent->ylabel->setText(QString("%1w").arg(v.y()));
|
|
|
|
// STATE: NONE
|
|
if (state == none) {
|
|
|
|
// if we're not in any particular state then just
|
|
// highlight for hover/unhover
|
|
updateNeeded = setBlockCursor();
|
|
|
|
// is the mouse on the canvas?
|
|
if (canvas().contains(p)) {
|
|
|
|
// unser/set hover/unhover state *IF NEED TO*
|
|
foreach(WWPoint *point, points_) {
|
|
if (point->bounding().contains(p)) {
|
|
if (!point->hover) {
|
|
point->hover = true;
|
|
updateNeeded=true;
|
|
}
|
|
} else {
|
|
if (point->hover) {
|
|
point->hover = false;
|
|
updateNeeded=true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// set lap marker state if needed, but don't
|
|
// lost the updateNeeded state if already true
|
|
updateNeeded= updateNeeded || setLapState();
|
|
|
|
// STATE: CREATE
|
|
} else if (state == create) {
|
|
|
|
// moved before timeout on create
|
|
updateNeeded = createPoint(p);
|
|
|
|
// now get ready to drag
|
|
state = drag;
|
|
|
|
// recompute metrics
|
|
recompute();
|
|
|
|
// STATE: DRAG
|
|
} else if (state == drag) {
|
|
|
|
// we're dragging this point around, get on and
|
|
// do that, but apply constrains
|
|
if (dragging) {
|
|
updateNeeded = movePoint(p);
|
|
|
|
// this may possibly be too expensive
|
|
// on slower hardware?
|
|
recompute();
|
|
|
|
} else {
|
|
// not possible?
|
|
state = none;
|
|
qDebug()<<"WW FSM: drag state dragging=NULL";
|
|
}
|
|
|
|
// STATE: RECT
|
|
} else if (state == dragblock) {
|
|
|
|
// move it
|
|
updateNeeded = moveBlock(p);
|
|
|
|
// we moved the block
|
|
recompute();
|
|
|
|
} else if (state == rect) {
|
|
|
|
// we're selecting via a rectangle
|
|
atRect = p;
|
|
updateNeeded = selectPoints(); // go and check / select
|
|
}
|
|
}
|
|
|
|
//
|
|
// 2 AND 6 MOUSE PRESS
|
|
//
|
|
if (event->type() == QEvent::MouseButtonPress) {
|
|
|
|
// watch for shift when clicking
|
|
Qt::KeyboardModifiers kmod = static_cast<QInputEvent*>(event)->modifiers();
|
|
|
|
// if not in draw mode toggle shift to select
|
|
// so if press shift in draw -> select
|
|
// if press shift in select -> draw
|
|
if (parent->draw == false) kmod ^= Qt::ShiftModifier;
|
|
|
|
// STATE: NONE
|
|
if (state == none && canvas().contains(p)) {
|
|
|
|
// either select existing to drag
|
|
// or create a new one to drag
|
|
bool hover=false;
|
|
foreach(WWPoint *point, points_) {
|
|
if (point->bounding().contains(p)) {
|
|
|
|
//
|
|
// HOVERING
|
|
//
|
|
|
|
// SHIFT-CLICK TO TOGGLE SELECT
|
|
if (kmod & Qt::ShiftModifier) {
|
|
point->selected = !point->selected;
|
|
hover=true;
|
|
updateNeeded=true;
|
|
} else {
|
|
|
|
// PLAIN CLICK TO DRAG
|
|
updateNeeded=true;
|
|
dragging = point;
|
|
hover=true;
|
|
onDrag = QPointF(dragging->x, dragging->y);
|
|
state = drag;
|
|
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
// if state is still none and we're not hovering,
|
|
// we aren't on top of a point, so create a new
|
|
// one or start select mode if shift is pressed
|
|
if (state == none && !hover) {
|
|
|
|
// SHIFT RECTANGLE SELECT
|
|
if (kmod & Qt::ShiftModifier) {
|
|
|
|
// where are we
|
|
atRect = onRect = p;
|
|
state = rect;
|
|
updateNeeded = true;
|
|
|
|
} else {
|
|
|
|
// UNSHIFTED CREATE A POINT
|
|
state = create;
|
|
|
|
// but we may press and hold for a snip
|
|
// so lets set the timer and remember
|
|
// where we were
|
|
onCreate = p;
|
|
QTimer::singleShot(500, this, SLOT(timeout()));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
//
|
|
// 3. MOUSE RELEASED
|
|
//
|
|
if (event->type() == QEvent::MouseButtonRelease) {
|
|
|
|
// STATE: DRAG
|
|
if (state == drag && dragging) {
|
|
|
|
// create command to reflect the drag, but only
|
|
// if it actually moved!
|
|
if (dragging->x != onDrag.x() || dragging->y != onDrag.y())
|
|
new MovePointCommand(this, onDrag, QPointF(dragging->x, dragging->y), points_.indexOf(dragging));
|
|
|
|
// recompute metrics
|
|
recompute();
|
|
}
|
|
|
|
// STATE: DRAG BLOCK
|
|
if (state == dragblock && cr8block.count()) {
|
|
new CreateBlockCommand(this, cr8block);
|
|
|
|
// now recompute
|
|
recompute();
|
|
}
|
|
|
|
// STATE: RECT
|
|
if (state == rect) {
|
|
selectedPoints();
|
|
onRect = atRect = QPointF(-1,-1);
|
|
|
|
// STATE: CREATE
|
|
} else if (state == create) {
|
|
|
|
// moved before timeout on create
|
|
updateNeeded = createPoint(p);
|
|
|
|
// recompute metrics
|
|
recompute();
|
|
}
|
|
|
|
foreach(WWPoint *point, points_) point->hover=false;
|
|
state = none;
|
|
dragging = NULL;
|
|
updateNeeded = true;
|
|
}
|
|
|
|
//
|
|
// 4. MOUSE TIMEOUT [click and hold]
|
|
//
|
|
if (event->type() == QEvent::Timer) {
|
|
|
|
// STATE: CREATE
|
|
if (state == create && onCreate == p) {
|
|
|
|
// if we are still on state create from initial click
|
|
// then we can create, otherwise just ignore
|
|
|
|
// create a block
|
|
updateNeeded = createBlock(p);
|
|
|
|
// recompute metrics
|
|
recompute();
|
|
|
|
// set state to dragblock
|
|
state = dragblock;
|
|
}
|
|
|
|
}
|
|
|
|
//
|
|
// 5. MOUSE WHEEL
|
|
//
|
|
if (event->type() == QEvent::Wheel) {
|
|
|
|
// STATE: NONE
|
|
if (state == none) {
|
|
QWheelEvent *w = static_cast<QWheelEvent*>(event);
|
|
#if QT_VERSION >= 0x050000
|
|
updateNeeded = scale(w->angleDelta());
|
|
#else
|
|
updateNeeded = scale(QPoint(0,w->delta()));
|
|
#endif
|
|
filterNeeded = true;
|
|
}
|
|
|
|
// will need to ..
|
|
recompute();
|
|
}
|
|
|
|
//
|
|
// 7. KEYPRESS
|
|
//
|
|
if (event->type() == QEvent::KeyPress) {
|
|
|
|
// STATE: ANY (!)
|
|
|
|
// we care about cmd / ctrl
|
|
Qt::KeyboardModifiers kmod = static_cast<QInputEvent*>(event)->modifiers();
|
|
bool ctrl = (kmod & Qt::ControlModifier) != 0;
|
|
int key;
|
|
|
|
switch((key=static_cast<QKeyEvent*>(event)->key())) {
|
|
|
|
case Qt::Key_Up:
|
|
case Qt::Key_Down:
|
|
case Qt::Key_Left:
|
|
case Qt::Key_Right:
|
|
filterNeeded = true; // we grab all key events
|
|
updateNeeded=movePoints(key,kmod);
|
|
break;
|
|
|
|
case Qt::Key_Escape:
|
|
filterNeeded = true; // we grab all key events
|
|
updateNeeded=selectClear();
|
|
break;
|
|
|
|
case Qt::Key_C:
|
|
if (ctrl) {
|
|
filterNeeded = true; // we grab all key events
|
|
copy();
|
|
}
|
|
break;
|
|
|
|
case Qt::Key_V:
|
|
if (ctrl) {
|
|
filterNeeded = true; // we grab all key events
|
|
paste();
|
|
}
|
|
break;
|
|
|
|
case Qt::Key_X:
|
|
if (ctrl) {
|
|
filterNeeded = true; // we grab all key events
|
|
cut();
|
|
}
|
|
break;
|
|
|
|
case Qt::Key_A:
|
|
if (ctrl) {
|
|
filterNeeded = true; // we grab all key events
|
|
updateNeeded=selectAll();
|
|
}
|
|
break;
|
|
|
|
case Qt::Key_Y:
|
|
if (ctrl) {
|
|
redo();
|
|
filterNeeded = true; // we grab all key events
|
|
updateNeeded=true;
|
|
}
|
|
break;
|
|
|
|
case Qt::Key_Z:
|
|
if (ctrl) {
|
|
undo();
|
|
filterNeeded = true; // we grab all key events
|
|
updateNeeded=true;
|
|
}
|
|
break;
|
|
|
|
case Qt::Key_Delete:
|
|
// delete!
|
|
filterNeeded = true; // we grab all key events
|
|
updateNeeded=deleteSelected();
|
|
break;
|
|
}
|
|
|
|
// we moved / deleted etc, so redo metrics et al
|
|
if (updateNeeded) recompute();
|
|
}
|
|
|
|
//
|
|
// 8. MOUSE ENTERS
|
|
//
|
|
if (event->type() == QEvent::Enter) {
|
|
|
|
// STATE: ANY
|
|
if (!hasFocus()) {
|
|
setFocus(Qt::MouseFocusReason);
|
|
}
|
|
}
|
|
|
|
//
|
|
// 9. RESIZE EVENT
|
|
//
|
|
if (event->type() == QEvent::Resize) {
|
|
|
|
// we need to adjust layout and repaint
|
|
adjustLayout();
|
|
updateNeeded = true;
|
|
}
|
|
|
|
|
|
// ALL DONE
|
|
|
|
// trigger an update if one is needed
|
|
if (updateNeeded) {
|
|
|
|
// the cursor may now hover over a block or point
|
|
// but don't interfere whilst state processing
|
|
if (state == none) setBlockCursor();
|
|
|
|
// repaint
|
|
update();
|
|
}
|
|
|
|
// return false - we are eavesdropping not processing.
|
|
// except for wheel events which we steal
|
|
return filterNeeded;
|
|
}
|
|
|
|
bool
|
|
WorkoutWidget::setLapState()
|
|
{
|
|
// by default nothing to do
|
|
bool returning = false;
|
|
|
|
// have laps been hovered/unhovered?
|
|
if (laps_.count()==0) return false;
|
|
|
|
// where is the cursor
|
|
QPoint p = mapFromGlobal(QCursor::pos());
|
|
int x = reverseTransform(p.x(),0).x();
|
|
|
|
bool intop = top().contains(p);
|
|
|
|
// run through lap markers..
|
|
for(int i=0; i<laps_.count(); i++) {
|
|
|
|
if (!intop) {
|
|
|
|
// unselect regardless as cursor not there, notify if needed
|
|
if (laps_[i].selected == true) returning = true;
|
|
laps_[i].selected = false;
|
|
|
|
} else {
|
|
// we need to work out if the cursor is for us
|
|
if ((x > (laps_[i].x/1000.00f)) && (i == (laps_.count()-1) || x < (laps_[i+1].x/1000.00f))) { // cursor to right
|
|
|
|
// select and notify it changed
|
|
if (!laps_[i].selected) returning=true;
|
|
laps_[i].selected = true;
|
|
|
|
} else {
|
|
|
|
// nope, so deselect and notify if changed
|
|
if (laps_[i].selected) returning=true;
|
|
laps_[i].selected = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return returning;
|
|
}
|
|
|
|
bool
|
|
WorkoutWidget::setBlockCursor()
|
|
{
|
|
// where is the mouse?
|
|
QPoint c = mapFromGlobal(QCursor::pos());
|
|
|
|
//
|
|
// SELECTION BLOCK - block created by selecting points
|
|
//
|
|
|
|
// lets set the selection block first, coz if the cursor
|
|
// first and last index of selected items
|
|
int begin=-1, end=-1;
|
|
for(int i=0; i<points_.count(); i++) {
|
|
if (points_[i]->selected) {
|
|
if (begin == -1) begin = i;
|
|
end = i;
|
|
}
|
|
}
|
|
|
|
// if we need a path, lets create one
|
|
if (begin >=0 && end >= 0 && points_[begin]->x < points_[end]->x) {
|
|
|
|
// accumalate joules and time
|
|
double joules=0;
|
|
double secs=0;
|
|
|
|
// create a painterpath for all the selected blocks
|
|
QPointF firstp = transform(points_[begin]->x, 0);
|
|
QPainterPath block(firstp); // origin
|
|
for (int i=begin; i <= end; i++) {
|
|
|
|
// accumalate
|
|
if (i != begin) {
|
|
double duration = points_[i]->x - points_[i-1]->x;
|
|
joules += (points_[i]->y + points_[i-1]->y) / 2 * duration;
|
|
secs += duration;
|
|
}
|
|
|
|
QPointF here = transform(points_[i]->x, points_[i]->y);
|
|
block.lineTo(here);
|
|
}
|
|
|
|
// and back again
|
|
QPointF lastp = transform(points_[end]->x, 0);
|
|
block.lineTo(lastp);
|
|
block.lineTo(firstp);
|
|
|
|
// done
|
|
selectionBlock = block;
|
|
|
|
// average power
|
|
selectionBlockText2 = QString("%1w").arg(joules/secs, 0, 'f', 0);
|
|
selectionBlockText = time_to_string(secs);
|
|
|
|
parent->copyAct->setEnabled(true);
|
|
parent->cutAct->setEnabled(true);
|
|
|
|
} else {
|
|
|
|
selectionBlock = QPainterPath();
|
|
selectionBlockText = selectionBlockText2 = "";
|
|
|
|
parent->copyAct->setEnabled(false);
|
|
parent->cutAct->setEnabled(false);
|
|
}
|
|
|
|
//
|
|
// CURSOR BLOCK -- HOVER BLOCK AS WE MOVE MOUSE
|
|
//
|
|
|
|
// not on canvas?
|
|
if (!canvas().contains(c)) {
|
|
if (cursorBlock != QPainterPath()) {
|
|
cursorBlock = QPainterPath();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool returning=false;
|
|
QPointF last(0,0);
|
|
int lastx=0;
|
|
int lasty=0;
|
|
int hoveri=-1;
|
|
|
|
foreach(WWPoint *p, points_) {
|
|
|
|
// might be better to always use float?
|
|
QPoint center = transform(p->x,p->y);
|
|
QPointF dot(center.x(), center.y());
|
|
|
|
// cursor in ?
|
|
if (last.x() > 0 && c.x() > last.x() && c.x() < dot.x() && dot.x() > last.x()) {
|
|
|
|
// found the cursor, but is it in the block?
|
|
QPointF begin(last.x(), canvas().bottom());
|
|
QPainterPath block(begin);
|
|
block.lineTo(last);
|
|
block.lineTo(dot);
|
|
block.lineTo(dot.x(),begin.y());
|
|
block.lineTo(begin);
|
|
|
|
// is it inside?
|
|
if (block.contains(c)) {
|
|
|
|
// if different then update and want a repaint
|
|
hoveri=points_.indexOf(p);
|
|
if (cursorBlock != block) {
|
|
cursorBlock = block;
|
|
cursorBlockText = time_to_string(p->x - lastx);
|
|
cursorBlockText2= QString("%1w").arg(double(lasty + ((p->y-lasty)/2)), 0, 'f', 0);
|
|
returning = true;
|
|
}
|
|
} else if (cursorBlock != QPainterPath()) {
|
|
|
|
// not inside but not set to null
|
|
cursorBlock = QPainterPath();
|
|
returning = true;
|
|
}
|
|
break;
|
|
}
|
|
|
|
// moving on
|
|
last = dot;
|
|
lastx = p->x;
|
|
lasty = p->y;
|
|
}
|
|
|
|
//
|
|
// QWKCODE TEXT
|
|
//
|
|
if (!parent->code->isHidden()) {
|
|
|
|
qwkactive = true;
|
|
|
|
// which line we hovering on?
|
|
if (hoveri > -1) {
|
|
|
|
// cursor to work with the document text
|
|
QTextCursor cursor(parent->code->document());
|
|
|
|
// look for line of code that includes the point we are
|
|
// hovering over so we can highlight it in the text edit
|
|
int indexin=0;
|
|
for (int i=0; i<codePoints.count();i++) {
|
|
|
|
// if between or at end this is the line we're hovering on
|
|
if ((hoveri >= codePoints[i]) &&
|
|
(((i<codePoints.count()-1) && hoveri < codePoints[i+1])
|
|
|| (i==codePoints.count()-1))) {
|
|
|
|
// we have found the line in coreStrings that we
|
|
// move cursor, the line will be highlighted by the editor
|
|
cursor.setPosition(indexin, QTextCursor::MoveAnchor);
|
|
cursor.setPosition(indexin + codeStrings[i].length(), QTextCursor::KeepAnchor);
|
|
cursor.clearSelection();
|
|
|
|
// move the visible cursor and make visible
|
|
parent->code->setTextCursor(cursor);
|
|
parent->code->ensureCursorVisible();
|
|
break;
|
|
}
|
|
indexin += codeStrings[i].length()+1;
|
|
}
|
|
}
|
|
|
|
qwkactive = false;
|
|
}
|
|
|
|
return returning;
|
|
}
|
|
|
|
static bool doubles_equal(double a, double b)
|
|
{
|
|
double errorB = b * DBL_EPSILON;
|
|
return (a >= b - errorB) && (a <= b + errorB);
|
|
}
|
|
|
|
// move the selected points in he direction of the cursor key
|
|
// constrained to the limits of the points not selected
|
|
bool
|
|
WorkoutWidget::movePoints(int key, Qt::KeyboardModifiers kmod)
|
|
{
|
|
Q_UNUSED(kmod); // perhaps in the future...
|
|
|
|
// apply constraints, don't move if we are constrained
|
|
// by moving off the scale or create a workout that is
|
|
// invalid (e.g. time goes backwards
|
|
bool constrained = false;
|
|
|
|
for(int index=0; index < points_.count(); index++) {
|
|
|
|
WWPoint *p = points_[index];
|
|
|
|
// going left
|
|
if (key == Qt::Key_Left) {
|
|
|
|
// look at prev
|
|
WWPoint *prev = index ? points_[index-1] : NULL;
|
|
|
|
// hit the start of the workout
|
|
if (p->selected && (p->x-XMOVE) < 0) { constrained=true; break; }
|
|
|
|
// hit the previous unselected
|
|
if (p->selected && prev && !prev->selected && prev->x > (p->x-XMOVE)) {
|
|
constrained=true; break;
|
|
}
|
|
}
|
|
|
|
// going right
|
|
if (key == Qt::Key_Right) {
|
|
|
|
// look at next
|
|
WWPoint *next = index+1 < points_.count() ? points_[index+1] : NULL;
|
|
|
|
// hit the end of the workout
|
|
if (p->selected && (p->x+XMOVE) > maxX()) { constrained=true; break; }
|
|
|
|
// hit the next unselected
|
|
if (p->selected && next && !next->selected && next->x < (p->x+XMOVE)) {
|
|
constrained=true; break;
|
|
}
|
|
}
|
|
|
|
// going down
|
|
if (key == Qt::Key_Down) {
|
|
|
|
// hit zero
|
|
if (p->selected && (p->y-YMOVE) < 0) { constrained=true; break; }
|
|
}
|
|
}
|
|
|
|
// no dice, skip update we changed nothing
|
|
if (constrained) return false;
|
|
|
|
// create command as we go
|
|
QList<PointMemento> before, after;
|
|
|
|
// visit every point and move if it is selected
|
|
for(int index=0; index < points_.count(); index++) {
|
|
|
|
WWPoint *p = points_[index];
|
|
|
|
// just selected ones
|
|
if (p->selected == false) continue;
|
|
|
|
// add before
|
|
before << PointMemento(p->x, p->y, index);
|
|
|
|
switch(key) {
|
|
|
|
case Qt::Key_Up:
|
|
p->y += YMOVE;
|
|
break;
|
|
|
|
case Qt::Key_Down:
|
|
p->y -= YMOVE;
|
|
break;
|
|
|
|
case Qt::Key_Left:
|
|
p->x -= XMOVE;
|
|
break;
|
|
|
|
case Qt::Key_Right:
|
|
p->x += XMOVE;
|
|
break;
|
|
}
|
|
|
|
// add after
|
|
after << PointMemento(p->x, p->y, index);
|
|
}
|
|
|
|
if (before.count()) {
|
|
new MovePointsCommand(this, before, after);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
bool
|
|
WorkoutWidget::movePoint(QPoint p)
|
|
{
|
|
int index = points_.indexOf(dragging); // XXX optimise this out on set drag state
|
|
QPoint f = transform(dragging->x, dragging->y); // current loc of point
|
|
QPointF to = reverseTransform(p.x(), p.y()); // watts/secs to move to
|
|
|
|
// boom .. we don't exist!
|
|
if (index == -1) return false;
|
|
|
|
// moving left
|
|
if (p.x() < f.x()) {
|
|
|
|
// we are constrained by another point?
|
|
if (index > 0) {
|
|
|
|
// get constraining point to the left
|
|
QPoint c = transform(points_[index-1]->x, points_[index-1]->y); // current loc of point
|
|
|
|
// not beyond just move, otherwise align to constraint
|
|
if (c.x() < p.x()) dragging->x = to.x();
|
|
else dragging->x = points_[index-1]->x;
|
|
|
|
} else {
|
|
|
|
// unconstrained
|
|
dragging->x = to.x();
|
|
|
|
}
|
|
}
|
|
|
|
// moving right
|
|
if (p.x() > f.x()) {
|
|
|
|
// we are constrained by another point?
|
|
if ((index+1) < points_.count()) {
|
|
|
|
// get constraining point to the right
|
|
QPoint c = transform(points_[index+1]->x, points_[index+1]->y); // current loc of point
|
|
|
|
// not beyond just move, otherwise align to constraint
|
|
if (c.x() > p.x()) dragging->x = to.x();
|
|
else dragging->x = points_[index+1]->x;
|
|
|
|
} else {
|
|
|
|
// unconstrained
|
|
dragging->x = to.x();
|
|
|
|
}
|
|
}
|
|
|
|
// we don't constrain y, but highlight points that align
|
|
foreach(WWPoint *point, points_) {
|
|
if (point == dragging) continue;
|
|
|
|
point->hover=false;
|
|
if (doubles_equal(point->y, to.y())) point->hover = true;
|
|
}
|
|
dragging->y = to.y();
|
|
return true;
|
|
}
|
|
|
|
bool
|
|
WorkoutWidget::createPoint(QPoint p)
|
|
{
|
|
// add a point!
|
|
QPointF to = reverseTransform(p.x(), p.y());
|
|
|
|
// don't auto append, we are going to insert vvvvv
|
|
WWPoint *add = new WWPoint(this, to.x(), to.y(), false);
|
|
|
|
onDrag = QPointF(add->x, add->y); // yuk, this should be done in the FSM (eventFilter)
|
|
// action at a distance ... yuk XXX TIDY THIS XXX
|
|
// add into the points
|
|
for(int i=0; i<points_.count(); i++) {
|
|
if (points_[i]->x > to.x()) {
|
|
points_.insert(i, add);
|
|
new CreatePointCommand(this, to.x(), to.y(), i);
|
|
|
|
// enter drag mode -- add command resets we
|
|
// are an edge case so handle it ourselves
|
|
state = drag;
|
|
dragging = add;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// after current
|
|
points_.append(add);
|
|
new CreatePointCommand(this, to.x(), to.y(), -1);
|
|
|
|
// enter drag mode - edge case as above
|
|
state = drag;
|
|
dragging = add;
|
|
return true;
|
|
}
|
|
|
|
bool
|
|
WorkoutWidget::moveBlock(QPoint p)
|
|
{
|
|
// we are drag creating blocks
|
|
// Remove any that might be there
|
|
// delete the points in reverse
|
|
for (int i=cr8block.count()-1; i>=0; i--) {
|
|
WWPoint *p = NULL;
|
|
if (cr8block[i].index >= 0) p = points_.takeAt(cr8block[i].index);
|
|
else p = points_.takeAt(points_.count()-1);
|
|
delete p;
|
|
}
|
|
|
|
// stop these cr8block
|
|
cr8block.clear();
|
|
|
|
// now create again
|
|
createBlock(p);
|
|
|
|
// refresh
|
|
return true;
|
|
}
|
|
|
|
bool
|
|
WorkoutWidget::createBlock(QPoint p)
|
|
{
|
|
// just in case
|
|
cr8block.clear();
|
|
|
|
// if between points we INSERT, if at the end
|
|
// we APPEND
|
|
WWPoint *add;
|
|
|
|
// add a point!
|
|
QPointF to = reverseTransform(p.x(), p.y());
|
|
QList<WWPoint *> adding;
|
|
|
|
// nothing there yet, create first flat block
|
|
if (points_.count() == 0) {
|
|
|
|
//
|
|
// Empty workout so create a single block starting from x=0
|
|
//
|
|
add = new WWPoint(this, 0, to.y());
|
|
adding << add;
|
|
cr8block << PointMemento(add->x, add->y, -1);
|
|
|
|
add = new WWPoint(this, to.x(), to.y());
|
|
adding << add;
|
|
cr8block << PointMemento(add->x, add->y, -1);
|
|
|
|
} else {
|
|
|
|
// appending ?
|
|
if (points_.last()->x < to.x()) {
|
|
|
|
//
|
|
// Append a block accounting for trailing end of workout
|
|
//
|
|
|
|
// we should really just add two points as the last
|
|
// point will be our 'bottom left' (forgetting for a
|
|
// moment that we go below or above).
|
|
//
|
|
// but we will need to add 4 points not 2 if
|
|
// a) we are above the last point and it is directly
|
|
// below the previous point (i.e. vertical drop)
|
|
// b) we are below the last point and it is directly
|
|
// above the previous point (i.e. vertical raise
|
|
//
|
|
// So lets work out what the last point is doing
|
|
enum { notvert, down, up } direction = notvert;
|
|
WWPoint *last = points_.last();
|
|
if (points_.count() > 1) {
|
|
|
|
// WEIRD: using i as temp variable to workaround weird
|
|
// macro expansion issue in qglobal.h (!!)
|
|
unsigned int i = points_.count(); i -=2;
|
|
WWPoint *prev = points_[i];
|
|
|
|
if (last->x == prev->x) {
|
|
if (last->y > prev->y) direction = up;
|
|
if (last->y < prev->y) direction = down;
|
|
}
|
|
}
|
|
|
|
switch(direction) {
|
|
|
|
case notvert:
|
|
// not vert just add 2 points
|
|
add = new WWPoint(this, last->x, to.y());
|
|
adding << add;
|
|
cr8block << PointMemento(add->x, add->y, -1);
|
|
add = new WWPoint(this, to.x(), to.y());
|
|
adding << add;
|
|
cr8block << PointMemento(add->x, add->y, -1);
|
|
break;
|
|
|
|
default:
|
|
case down:
|
|
case up:
|
|
// add a right angle, since that is
|
|
// consistent to what they have
|
|
add = new WWPoint(this, to.x(), last->y);
|
|
adding << add;
|
|
cr8block << PointMemento(add->x, add->y, -1);
|
|
add = new WWPoint(this, to.x(), to.y());
|
|
adding << add;
|
|
cr8block << PointMemento(add->x, add->y, -1);
|
|
|
|
break;
|
|
}
|
|
|
|
} else {
|
|
|
|
//
|
|
// Insert a block between points and handle slopes gracefully
|
|
//
|
|
|
|
// we are between two points, so the point that
|
|
// was clicked by the user is the MIDDLE of the block
|
|
int prev=-1, next=-1;
|
|
for(int i=0; i < points_.count(); i++) {
|
|
WWPoint *p = points_[i];
|
|
if (p->x < to.x()) prev=i; // to left
|
|
if (p->x >= to.x()) {
|
|
next=i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// not between?
|
|
if (prev == -1 || next == -1 || prev == next) return false;
|
|
|
|
// directly below?
|
|
if (points_[prev]->x == to.x() || points_[next]->x == to.x()) return false;
|
|
|
|
// it will be as wide as the distance from the nearest
|
|
// point divided by 1.5 (1width space, 1width / 2 for centre)
|
|
int left = (to.x() - points_[prev]->x);
|
|
int right = (points_[next]->x - to.x());
|
|
int width = double(left > right ? right : left) / 1.5;
|
|
|
|
// now we know the width we can just add four points
|
|
int index=next;
|
|
|
|
// if prev and next are above/below each other then
|
|
// we need to account for that when placing the bottom
|
|
// if the block - ie. place it on a slope
|
|
double ratio = double(points_[next]->y - points_[prev]->y)
|
|
/ double(points_[next]->x - points_[prev]->x);
|
|
|
|
// horizontal gap between prev point and lhs and rhs
|
|
double lwidth = to.x() - (width/2) - points_[prev]->x;
|
|
double rwidth = to.x() + (width/2) - points_[prev]->x;
|
|
|
|
// bottom left
|
|
add = new WWPoint(this, to.x()-(width/2),
|
|
points_[prev]->y + (lwidth * ratio),
|
|
false);
|
|
adding << add;
|
|
cr8block << PointMemento(add->x, add->y, index);
|
|
points_.insert(index++, add);
|
|
|
|
// top left
|
|
add = new WWPoint(this, to.x()-(width/2), to.y(), false);
|
|
adding << add;
|
|
cr8block << PointMemento(add->x, add->y, index);
|
|
points_.insert(index++, add);
|
|
|
|
// top right
|
|
add = new WWPoint(this, to.x()+(width/2), to.y(), false);
|
|
adding << add;
|
|
cr8block << PointMemento(add->x, add->y, index);
|
|
points_.insert(index++, add);
|
|
|
|
// bottom right
|
|
add = new WWPoint(this, to.x()+(width/2),
|
|
points_[prev]->y + (rwidth * ratio),
|
|
false);
|
|
adding << add;
|
|
cr8block << PointMemento(add->x, add->y, index);
|
|
points_.insert(index++, add);
|
|
|
|
}
|
|
}
|
|
|
|
// did we create any
|
|
if (cr8block.count()) {
|
|
|
|
// highlight were we align.
|
|
foreach(WWPoint *point, points_) {
|
|
if (adding.contains(point)) continue;
|
|
|
|
point->hover=false;
|
|
if (doubles_equal(point->y, to.y())) point->hover = true;
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool
|
|
WorkoutWidget::scale(QPoint p)
|
|
{
|
|
// scale selected (all at present) points
|
|
// up of y is positive, down 1% if y is negative
|
|
if (p.y() == 0) return false;
|
|
|
|
double factor = p.y() > 0 ? 1.01 : 0.99;
|
|
|
|
// scale
|
|
foreach (WWPoint *p, points_) p->y *= factor;
|
|
|
|
// register command
|
|
new ScaleCommand(this, 1.01, 0.99, p.y() > 0);
|
|
|
|
return true;
|
|
}
|
|
|
|
bool
|
|
WorkoutWidget::deleteSelected()
|
|
{
|
|
// get a work list for the command then delete them backwards
|
|
QList<PointMemento> list;
|
|
for(int i=0; i<points_.count(); i++) {
|
|
WWPoint *p = points_[i];
|
|
if (p->selected) list << PointMemento(p->x, p->y, i);
|
|
}
|
|
|
|
// run the command instead of duplicating here :)
|
|
for (int j=list.count()-1; j>=0; j--) {
|
|
PointMemento m = list[j];
|
|
WWPoint *rm = points_.takeAt(m.index);
|
|
delete rm;
|
|
}
|
|
|
|
// create a command on stack
|
|
new DeleteWPointsCommand(this, list);
|
|
|
|
return true;
|
|
}
|
|
|
|
bool
|
|
WorkoutWidget::selectAll()
|
|
{
|
|
bool selected=false;
|
|
|
|
foreach(WWPoint *p, points_) {
|
|
|
|
// if not selected, select it
|
|
if (p->selected==false)
|
|
p->selected=selected=true;
|
|
}
|
|
return selected;
|
|
}
|
|
|
|
bool
|
|
WorkoutWidget::selectPoints()
|
|
{
|
|
// if points are in rectangle then set selecting
|
|
// if they are not then unset selecting
|
|
QRectF rect(onRect,atRect);
|
|
foreach(WWPoint *p, points_) {
|
|
|
|
// experiment with deselecting when using a
|
|
// rect selection tool since more often than
|
|
// not I keep forgetting points are highlighted
|
|
// XXX maybe a keyboard modifier in the future ?
|
|
p->selected=false;
|
|
|
|
if (p->bounding().intersects(rect))
|
|
p->selecting = true;
|
|
else
|
|
p->selecting = false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool
|
|
WorkoutWidget::selectedPoints()
|
|
{
|
|
// any points marked as selecting are now selected
|
|
foreach(WWPoint *p, points_) {
|
|
if (p->selecting) {
|
|
p->selected=true;
|
|
p->selecting=false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool
|
|
WorkoutWidget::selectClear()
|
|
{
|
|
// clear all selection
|
|
foreach(WWPoint *p, points_) {
|
|
p->selected=false;
|
|
p->selecting=false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void
|
|
WorkoutWidget::ergFileSelected(ErgFile *ergFile)
|
|
{
|
|
// reset state and stack
|
|
state = none;
|
|
dragging = NULL;
|
|
foreach (WorkoutWidgetCommand *p, stack) delete p;
|
|
stack.clear();
|
|
stackptr = 0;
|
|
parent->saveAct->setEnabled(false);
|
|
parent->undoAct->setEnabled(false);
|
|
parent->redoAct->setEnabled(false);
|
|
cursorBlock = selectionBlock = QPainterPath();
|
|
cursorBlockText = selectionBlockText = cursorBlockText2 = selectionBlockText2 = "";
|
|
//XXX consider refactoring this !!! XXX
|
|
|
|
// wipe out points
|
|
foreach(WWPoint *point, points_) delete point;
|
|
points_.clear();
|
|
|
|
// we suport ERG but not MRC/CRS currently
|
|
if (ergFile && ergFile->format == ERG) {
|
|
|
|
this->ergFile = ergFile;
|
|
|
|
maxX_=0;
|
|
maxY_=400;
|
|
|
|
// get laps
|
|
laps_ = ergFile->Laps;
|
|
|
|
// add points for this....
|
|
foreach(ErgFilePoint point, ergFile->Points) {
|
|
WWPoint *add = new WWPoint(this, point.x / 1000.0f, point.y); // in ms
|
|
if (add->x > maxX_) maxX_ = add->x;
|
|
if (add->y > maxY_) maxY_ = add->y;
|
|
}
|
|
|
|
maxY_ *= 1.1f;
|
|
|
|
} else {
|
|
|
|
// not supported
|
|
this->ergFile = NULL;
|
|
}
|
|
|
|
// reset metrics etc
|
|
recompute();
|
|
|
|
// repaint
|
|
repaint();
|
|
}
|
|
|
|
void
|
|
WorkoutWidget::recompute(bool editing)
|
|
{
|
|
//QTime timer;
|
|
//timer.start();
|
|
|
|
//
|
|
// As data changes so must the selection/cursor
|
|
//
|
|
setBlockCursor();
|
|
|
|
int rnum=-1;
|
|
if (context->athlete->zones(false) == NULL ||
|
|
(rnum = context->athlete->zones(false)->whichRange(QDate::currentDate())) == -1) {
|
|
|
|
// no cp or ftp set
|
|
parent->TSSlabel->setText("- TSS");
|
|
parent->IFlabel->setText("- IF");
|
|
|
|
}
|
|
|
|
//
|
|
// PREPARE DATA
|
|
//
|
|
|
|
// get CP/FTP to use in calculation
|
|
int WPRIME = context->athlete->zones(false)->getWprime(rnum);
|
|
int CP = context->athlete->zones(false)->getCP(rnum);
|
|
int FTP = context->athlete->zones(false)->getFTP(rnum);
|
|
bool useCPForFTP = (appsettings->cvalue(context->athlete->cyclist,
|
|
context->athlete->zones(false)->useCPforFTPSetting(), 0).toInt() == 0);
|
|
if (useCPForFTP) FTP=CP;
|
|
|
|
// truncate
|
|
wattsArray.resize(0);
|
|
mmpArray.resize(0);
|
|
|
|
// running time and watts for interpolating
|
|
int ctime = 0;
|
|
double cwatts = 0;
|
|
|
|
double maxy=0;
|
|
|
|
// resample the erg file into 1s samples
|
|
foreach(WWPoint *p, points_) {
|
|
|
|
// ramprate per second
|
|
double ramp = double(p->y - cwatts) / double(p->x - ctime);
|
|
|
|
while (ctime < p->x) {
|
|
cwatts += ramp;
|
|
ctime++;
|
|
wattsArray << int(cwatts);
|
|
}
|
|
|
|
cwatts = p->y;
|
|
if (cwatts > maxy) maxy=cwatts;
|
|
}
|
|
|
|
// rescale the yaxis
|
|
if (maxY_ > (maxy*2) && maxY_ > 400) maxY_ = maxy *1.5; // too big
|
|
if (maxY_ < maxy) maxY_ = maxy *1.5; // too small
|
|
if (maxy == 0) maxY_ = 400;
|
|
|
|
//
|
|
// COMPUTE KEY METRICS TSS/IF
|
|
//
|
|
|
|
// The Workout Window has labels for TSS and IF.
|
|
double NP=0, TSS=0, IF=0;
|
|
|
|
// calculating NP
|
|
QVector<int> NProlling(30);
|
|
NProlling.fill(0,30);
|
|
double NPtotal=0;
|
|
double NPsum=0;
|
|
int NPindex=0;
|
|
int NPcount=0;
|
|
|
|
foreach(int watts, wattsArray) {
|
|
|
|
//
|
|
// Normalised Power
|
|
//
|
|
|
|
// sum last 30secs
|
|
NPsum += watts;
|
|
NPsum -= NProlling[NPindex];
|
|
NProlling[NPindex] = watts;
|
|
|
|
// running total and count
|
|
NPtotal += pow(NPsum/30,4); // raise rolling average to 4th power
|
|
NPcount ++;
|
|
|
|
// it moves up and down during the ride
|
|
if (NPcount > 30) {
|
|
NP = pow(double(NPtotal) / double(NPcount), 0.25f);
|
|
}
|
|
|
|
// move index on/round
|
|
NPindex = (NPindex >= 29) ? 0 : NPindex+1;
|
|
}
|
|
|
|
// IF.....
|
|
IF = double(NP) / double(FTP);
|
|
|
|
// TSS.....
|
|
double normWork = NP * NPcount;
|
|
double rawTSS = normWork * IF;
|
|
double workInAnHourAtCP = FTP * 3600;
|
|
TSS = rawTSS / workInAnHourAtCP * 100.0;
|
|
|
|
parent->IFlabel->setText(QString("%1 IF").arg(IF, 0, 'f', 2));
|
|
parent->TSSlabel->setText(QString("%1 TSS").arg(TSS, 0, 'f', 0));
|
|
|
|
//
|
|
// COMPUTE W'BAL
|
|
//
|
|
wpBal.setWatts(context, wattsArray, CP, WPRIME);
|
|
|
|
//
|
|
// MEAN MAX [works but need to think about UI]
|
|
//
|
|
RideFileCache::fastSearch(wattsArray, mmpArray, mmpOffsets);
|
|
//qDebug()<<"RECOMPUTE:"<<timer.elapsed()<<"ms"<<wattsArray.count()<<"samples";
|
|
|
|
//
|
|
// SEARCH FOR IMPOSSIBLE TTE SECTIONS
|
|
//
|
|
QVector<long> integrated;
|
|
integrated.resize(wattsArray.size());
|
|
long rt=0;
|
|
int secs=wattsArray.size();
|
|
for(int i=0; i<wattsArray.size(); i++) {
|
|
rt += wattsArray[i];
|
|
integrated[i] = rt;
|
|
}
|
|
|
|
// clear what we found last time
|
|
efforts.clear();
|
|
|
|
for (int j=0; j<2; j++) {
|
|
|
|
// 2 iterations:- 85% sustained, then 100% or higher
|
|
WWEffort tte; tte.start = tte.duration = 0;
|
|
|
|
for (int i=0; i<secs; i++) {
|
|
|
|
// start out at 30 minutes and drop back to
|
|
// 2 minutes, anything shorter and we are done
|
|
int t = (secs-i-1) > 3600 ? 3600 : secs-i-1;
|
|
|
|
while (t > 120) {
|
|
|
|
// calculate the TTE for the joules in the interval
|
|
// starting at i seconds with duration t
|
|
// This takes the monod equation p(t) = W'/t + CP and
|
|
// solves for t, but the added complication of also
|
|
// accounting for the fact it is expressed in joules
|
|
// So take Joules = (W'/t + CP) * t and solving that
|
|
// for t gives t = (Joules - W') / CP
|
|
double tc = ((integrated[i+t]-integrated[i]) - WPRIME) / CP;
|
|
// NOTE FOR ABOVE: it is looking at accumulation AFTER this point
|
|
// not FROM this point, so we are looking 1s ahead of i
|
|
// which is why the interval is registered as starting
|
|
// at i+1 in the code below
|
|
|
|
// this is either a TTE or getting very close
|
|
if (tc >= (j ? t : (t*0.85))) {
|
|
|
|
if (tte.start > (i+1) || (tte.duration+tte.start) < (i+t)) {
|
|
|
|
tte.start = i + 1; // see NOTE above
|
|
tte.duration = t;
|
|
tte.joules = integrated[i+t]-integrated[i];
|
|
tte.quality = tc / double(t);
|
|
|
|
// add 100 or more on second round
|
|
// quick way of doing overlapping
|
|
if ((j && tc >= t) || (!j && tc < t)) efforts << tte;
|
|
}
|
|
|
|
|
|
// move on shorter/harder are just as bad
|
|
t=0;
|
|
|
|
} else {
|
|
|
|
t = tc;
|
|
if (t<120)
|
|
t=120;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// set the properties if not editing
|
|
if (!editing) {
|
|
qwkactive = true;
|
|
parent->code->document()->setPlainText(qwkcode());
|
|
qwkactive = false;
|
|
}
|
|
}
|
|
|
|
// as 1m or 60s etc
|
|
static QString qduration(int t)
|
|
{
|
|
if (t%60 == 0) return QString("%1m").arg(t/60);
|
|
else return QString("%1s").arg(t);
|
|
}
|
|
|
|
QString
|
|
WorkoutWidget::qwkcode()
|
|
{
|
|
codeStrings.clear();
|
|
|
|
int rnum=-1;
|
|
int CP=250; // default if none set
|
|
if (context->athlete->zones(false) != NULL &&
|
|
(rnum = context->athlete->zones(false)->whichRange(QDate::currentDate())) != -1) {
|
|
CP = context->athlete->zones(false)->getCP(rnum); // get actual value
|
|
|
|
}
|
|
|
|
// convert the points to a string that can be edited
|
|
// it is a list of sections separated by commas
|
|
// of the form
|
|
//
|
|
// N - repeat N times
|
|
// ttt - duration N[ms]
|
|
// @iii - watts
|
|
// @iii-ppp - from iii to ppp watts
|
|
// rttt - recovery for ttt
|
|
// @rrr - recovery watts
|
|
// @rrr-sss - from rrr to sss watts
|
|
//
|
|
// e.g.
|
|
// 4x10@300r3m@200 - 4 time 10 mins at 300W followed by 3m at 200w
|
|
// 5x30s@450r30s - 5 times 30seconds at 450w followed by 30s at 'recovery'
|
|
// 20m@100-400 - 20 minutes going from 100w to 400w
|
|
//
|
|
//
|
|
// XXX COME AND FIX THIS EXAMPLE XXX
|
|
// A complete workout example;
|
|
// 3m@65,1@100r3@65,5x5@105r3@65,10@65
|
|
//
|
|
// Which decodes as a "classic" 5x5 vo2max workout:
|
|
// 1) 3minute at 65% of CP to warm upXXX
|
|
// 2) 1minute at CP followed by 3 mins recovery at 65% of CP to blow away the cobwebsXXX
|
|
// 3) 5 sets of 5 minutes at 105% of CP with 3 minutes recovery at 65% of CPXXX
|
|
// 4) 10minutes at 65% of CP to cool downXXX
|
|
|
|
// just loop through for now doing xx@yy and optionally add rxx
|
|
if (points_.count() == 1) {
|
|
// just a single point?
|
|
codeStrings << QString("%1@%2").arg(qduration(points_[0]->x)).arg(points_[0]->y);
|
|
codePoints<<0;
|
|
}
|
|
|
|
// don't do recovery just yet
|
|
QStringList blocks;
|
|
QList<int> blockp; //map to index
|
|
QList<double>aps;
|
|
|
|
for (int i=0; i< (points_.count()-1); i++) {
|
|
|
|
QString section;
|
|
double ap=0;
|
|
|
|
// how long is this section ?
|
|
int duration = points_[i+1]->x - points_[i]->x;
|
|
|
|
// if duration is 0 its a rise, so move on 1
|
|
if (duration <=0) {
|
|
|
|
// we need to keep duplicate time points
|
|
// for round trip - these are ones that are
|
|
// between points at the same point in time
|
|
if (i==0 || points_[i]->x - points_[i-1]->x <= 0) {
|
|
|
|
// its a block
|
|
section = QString("0@%1-%2").arg(points_[i]->y).arg(points_[i+1]->y);
|
|
ap = points_[i]->y / CP * 100.0f;
|
|
|
|
} else {
|
|
|
|
// skip!
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// is it a level or a rise?
|
|
if (doubles_equal(points_[i+1]->y, points_[i]->y)) {
|
|
|
|
// its a block
|
|
section = QString("%1@%2").arg(qduration(duration)).arg(points_[i]->y);
|
|
ap = points_[i]->y / CP * 100.0f;
|
|
|
|
} else {
|
|
// its a rise
|
|
section = QString("%1@%2-%3").arg(qduration(duration))
|
|
.arg(points_[i]->y)
|
|
.arg(points_[i+1]->y);
|
|
ap = ((points_[i]->y + points_[i+1]->y) / 2) / CP * 100.0f;
|
|
}
|
|
|
|
blocks << section;
|
|
blockp << i;
|
|
aps << ap;
|
|
}
|
|
|
|
// one code per block with not optimisation, so we now look for
|
|
// blocks followed by recovery so we can join them together as
|
|
// an effort followed by recovery
|
|
QStringList sections;
|
|
QList<int> sectionp;
|
|
for(int i=0; i<blocks.count(); i++) {
|
|
QString section = blocks[i];
|
|
sectionp << blockp[i];
|
|
|
|
// if we were above recovery and next is below recovery we have
|
|
// an effort followed by some recovery so join together
|
|
if ((i < aps.count() -1) && aps[i] > RECOVERY && aps[i+1]<aps[i] && aps[i+1] < RECOVERY && !blocks[i+1].startsWith("0@")) {
|
|
section += "r" + blocks[i+1];
|
|
i++;
|
|
}
|
|
sections << section;
|
|
}
|
|
|
|
// ok, we could probably do this in one loop
|
|
// above, but just to keep this maintainable
|
|
// we now run through the sections looking
|
|
// for repeats and adding Nx .. when there
|
|
// are 2 or more repeated blocks
|
|
codePoints.clear();
|
|
for(int i=0; i<sections.count();) {
|
|
|
|
// count dupes
|
|
int count=1;
|
|
for(int j=i+1; j<sections.count(); j++) {
|
|
if (sections[j] == sections[i])
|
|
count++;
|
|
else
|
|
break; // stop when end of matches
|
|
}
|
|
|
|
// multiple or no ..
|
|
if (count > 1) {
|
|
codeStrings << QString("%1x%2").arg(count).arg(sections[i]);
|
|
codePoints << sectionp[i];
|
|
} else {
|
|
codeStrings << sections[i];
|
|
codePoints << sectionp[i];
|
|
}
|
|
|
|
i += count;
|
|
}
|
|
|
|
// still not optimised to 4x ..
|
|
return codeStrings.join("\n");
|
|
}
|
|
|
|
void
|
|
WorkoutWidget::fromQwkcode(QString code)
|
|
{
|
|
if (qwkactive == true) return;
|
|
|
|
// clear points etc
|
|
state = none;
|
|
dragging = NULL;
|
|
cursorBlock = selectionBlock = QPainterPath();
|
|
cursorBlockText = selectionBlockText = cursorBlockText2 = selectionBlockText2 = "";
|
|
|
|
// wipe out points NEED TO COME BACK FOR REDO!!! XXX TODO XXX
|
|
foreach(WWPoint *point, points_) delete point;
|
|
points_.clear();
|
|
|
|
// keep a track of current load and time
|
|
int secs = 0;
|
|
int watts= 0;
|
|
int index=0;
|
|
|
|
// save away
|
|
codePoints.clear();
|
|
codeStrings = code.split("\n");
|
|
|
|
foreach(QString line, code.split("\n")) {
|
|
|
|
//
|
|
// QWKCODE syntax
|
|
//
|
|
// A line can be (using [] to express optionality
|
|
//
|
|
// [Nx]t1@w1[-w2][rt2@w3[-w4]]
|
|
//
|
|
// Where Nx - Repeat N times
|
|
// t1@w1 - Time t1 at Watts w1
|
|
// -w2 - Optionally Time t from watts w1 to w2
|
|
//
|
|
// rt2@w3 - Optional recovery time t2 at watts w3
|
|
// -w4 - Optionally recover from time t2 watts w3 to watts w4
|
|
//
|
|
// time t2/t2 can be expressed as a number and may
|
|
// optionally be followed by m or s for minutes or seconds
|
|
// if no units specified defaults to minutes
|
|
QRegExp qwk("([0-9]+x)?([0-9]+[ms]?)@([0-9]+)(-[0-9]+)?(r([0-9]+[ms]?)@([0-9]+)(-[0-9]+)?)?");
|
|
|
|
// REGEXP capture texts
|
|
//
|
|
// 0 - The entire line
|
|
// 1 - Count with trailing x e.g. "4x"
|
|
// 2 - Duration with trailing units (optional) e.g. "10m"
|
|
// 3 - Watts without units e.g. "120"
|
|
// 4 - Watts rise to with leading minus e.g. "-150"
|
|
// 5 - The entire recovery string (optional)
|
|
// 6 - Recovery Duration with trailing units (optional) e.g. "3m"
|
|
// 7 - Recovery watts without units e.g. "70"
|
|
// 8 - Recovert rise to with leading minus e.g. "-100"
|
|
//
|
|
// Obviously if not present then the captured text will be blank
|
|
// but we always get 9 captured texts.
|
|
|
|
// need a full match, we ignore malformed entries
|
|
if (qwk.exactMatch(line.trimmed())) {
|
|
|
|
codePoints << index;
|
|
|
|
// extract the values to use when adding
|
|
int count, t1, t2, w1, w2, w3, w4;
|
|
|
|
// initialise
|
|
count = 1;
|
|
t1 = t2 = w1 = w2 = w3 = w4 = -1;
|
|
|
|
// repeat ?
|
|
if (qwk.cap(1) != "") count=qwk.cap(1).mid(0, qwk.cap(1).length()-1).toInt();
|
|
|
|
// duration t1
|
|
if (qwk.cap(2) != "") {
|
|
|
|
// minutes
|
|
if (qwk.cap(2).endsWith("m")) {
|
|
t1=qwk.cap(2).mid(0, qwk.cap(2).length()-1).toInt();
|
|
t1 *= 60;
|
|
|
|
// seconds
|
|
} else if (qwk.cap(2).endsWith("s")) {
|
|
t1=qwk.cap(2).mid(0, qwk.cap(2).length()-1).toInt();
|
|
|
|
// default minutes
|
|
} else {
|
|
t1=qwk.cap(2).toInt();
|
|
t1 *= 60;
|
|
}
|
|
}
|
|
|
|
// w1
|
|
if (qwk.cap(3) != "") w1 = qwk.cap(3).toInt();
|
|
// w2
|
|
if (qwk.cap(4) != "") w2 = -1 * qwk.cap(4).toInt();
|
|
|
|
// duration t2
|
|
if (qwk.cap(6) != "") {
|
|
|
|
// minutes
|
|
if (qwk.cap(6).endsWith("m")) {
|
|
t2=qwk.cap(6).mid(0, qwk.cap(6).length()-1).toInt();
|
|
t2 *= 60;
|
|
|
|
// seconds
|
|
} else if (qwk.cap(6).endsWith("s")) {
|
|
t2=qwk.cap(6).mid(0, qwk.cap(6).length()-1).toInt();
|
|
|
|
// default minutes
|
|
} else {
|
|
t2=qwk.cap(6).toInt();
|
|
t2 *= 60;
|
|
}
|
|
}
|
|
|
|
// w3
|
|
if (qwk.cap(7) != "") w3 = qwk.cap(7).toInt();
|
|
// w4
|
|
if (qwk.cap(8) != "") w4 = -1 * qwk.cap(8).toInt();
|
|
|
|
//DEBUGqDebug()<<"PARSE:" << qwk.cap(0) <<"count:"<<count<<"EFFORT:"<<t1<<w1<<w2<<"RECOVERY:"<<t2<<w3<<w4;
|
|
|
|
//
|
|
// SET THE POINTS TO MATCH QWKCODE
|
|
//
|
|
for(int i=0; i<count; i++) {
|
|
|
|
// EFFORT
|
|
// add a point for starting watts if not already there
|
|
if (w1 != watts) {
|
|
index++;
|
|
new WWPoint(this, secs, w1);
|
|
}
|
|
|
|
// end of block
|
|
secs += t1;
|
|
watts = w2 > 0 ? w2 : w1;
|
|
index++;
|
|
new WWPoint(this, secs, watts);
|
|
|
|
// RECOVERY
|
|
if (t2 > 0) {
|
|
if (w3 != watts) {
|
|
index++;
|
|
new WWPoint(this, secs, w3);
|
|
}
|
|
|
|
// end of recovery block
|
|
secs += t2;
|
|
watts = w4 >0 ? w4 : w3;
|
|
index++;
|
|
new WWPoint(this,secs,watts);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// recompute but critically don't redo qwkcode
|
|
recompute(true);
|
|
|
|
// repaint
|
|
update();
|
|
}
|
|
|
|
void
|
|
WorkoutWidget::configChanged(qint32)
|
|
{
|
|
setProperty("color", GColor(CTRAINPLOTBACKGROUND));
|
|
|
|
gridPen = QPen(GColor(CPLOTGRID));
|
|
gridPen.setWidth(1);
|
|
|
|
markerPen = QPen(GColor(CPLOTMARKER));
|
|
markerPen.setWidth(1);
|
|
|
|
markerFont.fromString(appsettings->value(this, GC_FONT_CHARTLABELS, QFont().toString()).toString());
|
|
markerFont.setPointSize(appsettings->value(NULL, GC_FONT_CHARTLABELS_SIZE, 8).toInt());
|
|
|
|
bigFont = markerFont;
|
|
bigFont.setPointSize(markerFont.pointSize() * 2);
|
|
bigFont.setWeight(QFont::Bold);
|
|
|
|
repaint();
|
|
}
|
|
|
|
void
|
|
WorkoutWidget::paintEvent(QPaintEvent*)
|
|
{
|
|
QPainter painter(this);
|
|
painter.save();
|
|
|
|
// use antialiasing when drawing
|
|
painter.setRenderHint(QPainter::Antialiasing, true);
|
|
|
|
// just for debug for now
|
|
//DEBUG painter.setPen(QPen(QColor(255,255,255,30)));
|
|
//DEBUG painter.drawRect(left());
|
|
//DEBUG painter.drawRect(right());
|
|
//DEBUG painter.drawRect(top());
|
|
//DEBUG painter.drawRect(bottom());
|
|
//DEBUG painter.setPen(QPen(QColor(255,0,0,30)));
|
|
//DEBUG painter.drawRect(canvas());
|
|
|
|
//
|
|
// SCALES [refactor later]
|
|
//
|
|
|
|
// paint the scale backbones
|
|
painter.setPen(markerPen);
|
|
painter.setFont(markerFont);
|
|
QFontMetrics fontMetrics(markerFont);
|
|
|
|
// backbone
|
|
if (YTICLENGTH) painter.drawLine(left().topRight(), left().bottomRight()); //Y
|
|
if (XTICLENGTH) painter.drawLine(bottom().topLeft(), bottom().topRight()); //X
|
|
|
|
// start with 5 min tics and get longer and longer
|
|
int tsecs = 1 * 60; // 1 minute tics
|
|
int xrange = maxX() - minX();
|
|
while (double(xrange) / double(tsecs) > XTICS && tsecs < xrange) {
|
|
if (tsecs==120) tsecs = 300;
|
|
else tsecs *= 2;
|
|
}
|
|
|
|
// now paint them
|
|
for(int i=minX(); i<=maxX(); i += tsecs) {
|
|
|
|
painter.setPen(markerPen);
|
|
|
|
// paint a tic
|
|
int x= transform(i, 0).x();
|
|
|
|
if (XTICLENGTH) { // we can make the tics disappear
|
|
|
|
painter.drawLine(QPoint(x, bottom().topLeft().y()),
|
|
QPoint(x, bottom().topLeft().y()+XTICLENGTH));
|
|
}
|
|
|
|
// always paint the label
|
|
QString label = time_to_string(i);
|
|
QRect bound = fontMetrics.boundingRect(label);
|
|
painter.drawText(QPoint(x - (bound.width() / 2),
|
|
bottom().topLeft().y()+fontMetrics.ascent()+XTICLENGTH+(XTICLENGTH ? SPACING : 0)),
|
|
label);
|
|
|
|
}
|
|
|
|
// Y-SCALE (absolute shown on canvas next to grid lines)
|
|
QString label = tr("Intensity");
|
|
QRect box = fontMetrics.boundingRect(label);
|
|
painter.rotate(-90);
|
|
painter.setPen(markerPen);
|
|
//painter.drawText(QPoint(left().x()+box.height(), left().center().y()+(box.width()/2)), label);
|
|
painter.drawText(0,box.height(),label);
|
|
painter.rotate(90);
|
|
|
|
// start with 50w tics and get longer and longer
|
|
int twatts = 50; // 50w tics
|
|
int yrange = maxY() - minY();
|
|
while (double(yrange) / double(twatts) > YTICS) twatts *= 2;
|
|
|
|
// now paint them
|
|
for(int i=minY(); i<=maxY(); i += twatts) {
|
|
|
|
painter.setPen(markerPen);
|
|
|
|
// paint a tic
|
|
int y= transform(0, i).y();
|
|
|
|
if (YTICLENGTH) { // we can make the tics disappear
|
|
|
|
painter.drawLine(QPoint(left().topRight().x(), y),
|
|
QPoint(left().topRight().x()-YTICLENGTH, y));
|
|
|
|
}
|
|
|
|
// always paint the watts as a reference
|
|
if (GRIDLINES && i>0) {
|
|
painter.setPen(markerPen);
|
|
|
|
QString label = QString("%1w").arg(i);
|
|
painter.drawText(QPoint(canvas().left()+SPACING,
|
|
y+(fontMetrics.ascent()/2)), // we use ascent not height to line up numbers
|
|
label);
|
|
|
|
#if 0 // ONLY SHOW GRIDLINES FROM POWERSCALE
|
|
// GRIDLINES - but not on top and bottom border of canvas
|
|
if (y > canvas().y() && y < canvas().height() && GRIDLINES == true) {
|
|
painter.setPen(gridPen);
|
|
painter.drawLine(QPoint(canvas().x(), y), QPoint(canvas().x()+canvas().width(), y));
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
|
|
// now paint the scene and related widgets
|
|
foreach(WorkoutWidgetItem*x, children_) x->paint(&painter);
|
|
|
|
// now paint the points
|
|
foreach(WorkoutWidgetItem*x, points_) x->paint(&painter);
|
|
|
|
painter.restore();
|
|
}
|
|
|
|
QRectF
|
|
WorkoutWidget::left()
|
|
{
|
|
QRect all = geometry();
|
|
return QRectF(0,THEIGHT, LWIDTH, all.height() - IHEIGHT - THEIGHT - BHEIGHT);
|
|
}
|
|
|
|
QRectF
|
|
WorkoutWidget::right()
|
|
{
|
|
QRect all = geometry();
|
|
return QRectF(all.width()-RWIDTH,THEIGHT, RWIDTH, all.height() - IHEIGHT - THEIGHT - BHEIGHT);
|
|
}
|
|
|
|
QRectF
|
|
WorkoutWidget::bottom()
|
|
{
|
|
QRect all = geometry();
|
|
return QRectF(LWIDTH, all.height() - BHEIGHT, all.width() - LWIDTH - RWIDTH, BHEIGHT);
|
|
}
|
|
|
|
QRectF
|
|
WorkoutWidget::bottomgap()
|
|
{
|
|
QRect all = geometry();
|
|
return QRectF(LWIDTH, all.height() - (IHEIGHT+BHEIGHT), all.width() - LWIDTH - RWIDTH, IHEIGHT);
|
|
}
|
|
|
|
QRectF
|
|
WorkoutWidget::top()
|
|
{
|
|
QRect all = geometry();
|
|
return QRectF(LWIDTH, 0, all.width() - LWIDTH - RWIDTH, THEIGHT);
|
|
}
|
|
|
|
QRectF
|
|
WorkoutWidget::canvas()
|
|
{
|
|
QRect all = geometry();
|
|
return QRectF(LWIDTH, THEIGHT, all.width() - LWIDTH - RWIDTH, all.height() - IHEIGHT - THEIGHT - BHEIGHT);
|
|
}
|
|
|
|
WorkoutWidgetItem::WorkoutWidgetItem(WorkoutWidget *w) : w(w)
|
|
{
|
|
}
|
|
|
|
double
|
|
WorkoutWidget::maxX()
|
|
{
|
|
return maxX_;
|
|
}
|
|
|
|
double
|
|
WorkoutWidget::maxY()
|
|
{
|
|
return maxY_;
|
|
}
|
|
|
|
// transform from plot to painter co-ordinate
|
|
QPoint
|
|
WorkoutWidget::transform(double seconds, double watts, RideFile::SeriesType s)
|
|
{
|
|
// from plot coords to painter coords on the canvas
|
|
QRectF c = canvas();
|
|
|
|
switch (s) {
|
|
|
|
default:
|
|
case RideFile::watts:
|
|
{
|
|
// ratio of pixels to plot units
|
|
double yratio = double(c.height()) / (maxY()-minY());
|
|
double xratio = double(c.width()) / (maxX()-minX());
|
|
|
|
return QPoint(c.x() + (seconds * xratio), c.bottomLeft().y() - (watts * yratio));
|
|
}
|
|
|
|
case RideFile::hr:
|
|
{
|
|
// ratio of pixels to plot units
|
|
double yratio = double(c.height()) / double(hrMax);
|
|
double xratio = double(c.width()) / (maxX()-minX());
|
|
|
|
return QPoint(c.x() + (seconds * xratio), c.bottomLeft().y() - (watts * yratio));
|
|
}
|
|
case RideFile::cad:
|
|
{
|
|
// ratio of pixels to plot units
|
|
double yratio = double(c.height()) / double(cadenceMax);
|
|
double xratio = double(c.width()) / (maxX()-minX());
|
|
|
|
return QPoint(c.x() + (seconds * xratio), c.bottomLeft().y() - (watts * yratio));
|
|
}
|
|
case RideFile::kph:
|
|
{
|
|
// ratio of pixels to plot units
|
|
double yratio = double(c.height()) / double(speedMax);
|
|
double xratio = double(c.width()) / (maxX()-minX());
|
|
|
|
return QPoint(c.x() + (seconds * xratio), c.bottomLeft().y() - (watts * yratio));
|
|
}
|
|
}
|
|
}
|
|
|
|
// transform from painter to plot co-ordinate
|
|
QPointF
|
|
WorkoutWidget::reverseTransform(int x, int y)
|
|
{
|
|
// from painter coords to plot cords on the canvas
|
|
QRectF c = canvas();
|
|
|
|
// ratio of pixels to plot units
|
|
double yratio = double(c.height()) / (maxY()-minY());
|
|
double xratio = double(c.width()) / (maxX()-minX());
|
|
|
|
return QPoint((x-c.x()) / xratio, (c.bottomLeft().y() - y) / yratio);
|
|
}
|
|
|
|
WorkoutWidgetCommand::WorkoutWidgetCommand(WorkoutWidget *w) : workoutWidget_(w)
|
|
{
|
|
// add us
|
|
w->addCommand(this);
|
|
}
|
|
|
|
// add to the stack, don't execute since it was already executed
|
|
// and this is a memento to enable undo / redo - its a trigger
|
|
// to recompute metrics though since the model has been changed
|
|
void
|
|
WorkoutWidget::addCommand(WorkoutWidgetCommand *cmd)
|
|
{
|
|
// stop dragging
|
|
dragging = NULL;
|
|
state = none;
|
|
|
|
// truncate if needed
|
|
if (stack.count()) {
|
|
// wipe away commands we can no longer redo
|
|
while (stack.count() > stackptr) {
|
|
WorkoutWidgetCommand *p = stack.takeAt(stackptr);
|
|
delete p;
|
|
}
|
|
}
|
|
|
|
// add to stack
|
|
stack.append(cmd);
|
|
stackptr++;
|
|
|
|
// set undo enabled, redo disabled
|
|
parent->saveAct->setEnabled(true);
|
|
parent->undoAct->setEnabled(true);
|
|
parent->redoAct->setEnabled(false);
|
|
}
|
|
|
|
void
|
|
WorkoutWidget::redo()
|
|
{
|
|
// stop dragging
|
|
dragging = NULL;
|
|
state = none;
|
|
|
|
// redo if we can
|
|
if (stackptr >= 0 && stackptr < stack.count()) stack[stackptr++]->redo();
|
|
|
|
// disable/enable buttons
|
|
if (stackptr > 0) {
|
|
parent->undoAct->setEnabled(true);
|
|
parent->saveAct->setEnabled(true);
|
|
}
|
|
if (stackptr >= stack.count()) parent->redoAct->setEnabled(false);
|
|
|
|
// recompute metrics
|
|
recompute();
|
|
|
|
// update
|
|
update();
|
|
}
|
|
|
|
void
|
|
WorkoutWidget::undo()
|
|
{
|
|
// stop dragging
|
|
dragging = NULL;
|
|
state = none;
|
|
|
|
// run it
|
|
if (stackptr > 0) stack[--stackptr]->undo();
|
|
|
|
// disable/enable button
|
|
if (stackptr <= 0) {
|
|
parent->saveAct->setEnabled(false);
|
|
parent->undoAct->setEnabled(false);
|
|
}
|
|
if (stackptr < stack.count()) parent->redoAct->setEnabled(true);
|
|
|
|
// recompute metrics
|
|
recompute();
|
|
|
|
// update
|
|
update();
|
|
}
|
|
|
|
// if no block is highlighted return false, otherwise true
|
|
bool
|
|
WorkoutWidget::getBlockSelected(QList<int>©Indexes,
|
|
QList<int>&deleteIndexes,
|
|
double &shift)
|
|
{
|
|
// from first to last selected these are in scope
|
|
int begin=-1, end=-1;
|
|
for(int i=0; i<points_.count(); i++) {
|
|
if (points_[i]->selected) {
|
|
if (begin == -1) begin = i;
|
|
end = i;
|
|
}
|
|
}
|
|
|
|
// make sure there is some kind of selection
|
|
if (begin == -1 || end == -1 || begin == end || end < begin) return false;
|
|
|
|
// trim unwanted points from the front
|
|
int blockStarts=-1, blockFinishes=-1;
|
|
|
|
// j loops through the indexes of the points in scope
|
|
for(int index=begin; index<=end; index++) {
|
|
|
|
// index of this, next and previous points
|
|
int next = index < end ? index+1 : -1;
|
|
|
|
// we got to block end
|
|
if (next != -1 && points_[next]->x > points_[index]->x) {
|
|
blockStarts = index;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// j loops back through the indexes of the points in scope
|
|
for(int index=end; index>=begin; index--) {
|
|
|
|
// index of this, next and previous points
|
|
int prev = index > begin ? (index-1) : - 1;
|
|
|
|
// we got to block end
|
|
if (prev != -1 && points_[prev]->x < points_[index]->x) {
|
|
blockFinishes = index;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (blockStarts == -1 || blockFinishes == -1 || blockStarts > blockFinishes) return false;
|
|
|
|
// what is the shift required to move points
|
|
// to the right across in a cut operation
|
|
shift = points_[blockFinishes]->x - points_[blockStarts]->x;
|
|
|
|
// what are the indexes we will put into the
|
|
// buffer in a copy operation
|
|
for(int i=blockStarts; i<=blockFinishes; i++)
|
|
copyIndexes << i;
|
|
|
|
// what are the indexes we would delete in
|
|
// a cut operation - for now same as copy
|
|
// but should really get rid of duplicate
|
|
// points esp. when multiple points for the
|
|
// same point in time XXX fix this later XXX
|
|
// as a result of shifting this happens a
|
|
// fair amount ............
|
|
deleteIndexes = copyIndexes;
|
|
|
|
// do we have something ?
|
|
if (copyIndexes.count() > 1) return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
void
|
|
WorkoutWidget::cut()
|
|
{
|
|
QList<PointMemento>d,c;
|
|
|
|
QList<int> copyIndexes, deleteIndexes;
|
|
double shift=0;
|
|
|
|
// work out what we're doing
|
|
if (!getBlockSelected(copyIndexes, deleteIndexes, shift)) return;
|
|
|
|
// we cut and paste BLOCKS, not POINTS
|
|
// so this means we have to only get points
|
|
// that are part of the block we are cutting
|
|
// and also shift the points to the right
|
|
// across to the left to account for the
|
|
// blocks we removed.
|
|
|
|
// since copy also needs to do this we have
|
|
// the following utility
|
|
|
|
// copy points
|
|
foreach(int index, copyIndexes) {
|
|
WWPoint *p = points_[index];
|
|
c << PointMemento(p->x, p->y, index);
|
|
}
|
|
setClipboard(c);
|
|
|
|
// delete backwards
|
|
int last=-1;
|
|
for(int i=deleteIndexes.count()-1; i>=0; i--) {
|
|
WWPoint *take = points_.takeAt(deleteIndexes[i]);
|
|
d << PointMemento(take->x, take->y, deleteIndexes[i]);
|
|
delete take;
|
|
last = deleteIndexes[i];
|
|
}
|
|
|
|
// now shift the rest
|
|
for(int i=last; i >0 && i<points_.count(); i++) {
|
|
points_[i]->x -= shift;
|
|
}
|
|
|
|
// add the cut command
|
|
new CutCommand(this, c, d, shift);
|
|
|
|
// refresh
|
|
recompute();
|
|
|
|
// update the display
|
|
update();
|
|
}
|
|
|
|
void
|
|
WorkoutWidget::copy()
|
|
{
|
|
QList<PointMemento>d,c;
|
|
|
|
// to use getBlockSelected
|
|
QList<int> copyIndexes, deleteIndexes;
|
|
double shift=0;
|
|
|
|
// work out what we're doing
|
|
if (!getBlockSelected(copyIndexes, deleteIndexes, shift)) return;
|
|
|
|
// copy points
|
|
foreach(int index, copyIndexes) {
|
|
WWPoint *p = points_[index];
|
|
c << PointMemento(p->x, p->y, index);
|
|
}
|
|
setClipboard(c);
|
|
}
|
|
|
|
void
|
|
WorkoutWidget::setClipboard(QList<PointMemento>&c)
|
|
{
|
|
// always offset from zero
|
|
// for both time and indexes
|
|
double offset=0;
|
|
for(int i=0; i<c.count(); i++) {
|
|
if (i==0) offset=c[i].x;
|
|
c[i].x -= offset; // time starts from zero
|
|
c[i].index = i; // indexes start from zero
|
|
}
|
|
|
|
// store it and set the action button
|
|
clipboard = c;
|
|
parent->pasteAct->setEnabled(c.count() > 0);
|
|
}
|
|
|
|
void
|
|
WorkoutWidget::paste()
|
|
{
|
|
// empty clipboard, nothing to do
|
|
if (clipboard.count() == 0) return;
|
|
|
|
// ok, so where to paste?
|
|
int here=-1;
|
|
int offset= points_.count() ? points_.last()->x : 0;
|
|
|
|
// search for a selected point
|
|
for(int i=points_.count()-1; i>=0; i--) {
|
|
if (points_[i]->selected) {
|
|
offset=points_[i]->x;
|
|
here=i+1;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// if its the last point append!
|
|
if (here >= (points_.count())) here = -1;
|
|
|
|
double shift=0;
|
|
if (here != -1) {
|
|
|
|
// need to shift everyone across to make space
|
|
shift = clipboard.last().x;
|
|
|
|
for(int i=here; i<points_.count(); i++) {
|
|
points_[i]->x += shift;
|
|
}
|
|
}
|
|
|
|
// here is either the index to append after
|
|
// or we add to the end of the workout
|
|
foreach(PointMemento m, clipboard) {
|
|
|
|
if (here == -1) {
|
|
new WWPoint(this, m.x+offset, m.y);
|
|
} else {
|
|
|
|
WWPoint *add = new WWPoint(this, m.x+offset, m.y, false);
|
|
points_.insert(here + m.index, add);
|
|
}
|
|
}
|
|
|
|
// increase maxX ?
|
|
if (points_.count() && points_.last()->x > maxX_)
|
|
maxX_ = points_.last()->x;
|
|
|
|
// paste command
|
|
new PasteCommand(this, here, offset, shift, clipboard);
|
|
|
|
// refresh
|
|
recompute();
|
|
|
|
// update the display
|
|
update();
|
|
}
|