mirror of
https://github.com/GoldenCheetah/GoldenCheetah.git
synced 2026-02-14 16:39:57 +00:00
1194 lines
38 KiB
C++
1194 lines
38 KiB
C++
/*
|
|
* Copyright (c) 2011-13 Damien Grauser (Damien.Grauser@pev-geneve.ch)
|
|
* 2014 Mark Liversedgge (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 "MergeActivityWizard.h"
|
|
#include "Context.h"
|
|
#include "RideCache.h"
|
|
#include "MainWindow.h"
|
|
#include "HelpWhatsThis.h"
|
|
|
|
// minimum R-squared fit when trying to find offsets to
|
|
// merge ride files. Lower numbers mean happier to take
|
|
// and answer that is less likely to be correct, but then
|
|
// its possibly better than nothing !
|
|
//
|
|
// I have found that with real world data, where no data
|
|
// has been resampled then fits >0.8 are usual
|
|
// We always use the best fit anyway, this is just to
|
|
// decide what to discard as not valuable
|
|
static const double MINIMUM_R2_FIT = 0.75f;
|
|
/*----------------------------------------------------------------------
|
|
*
|
|
* Page Flow Summary
|
|
*
|
|
* 10 MergeWelcome Welcome message
|
|
*
|
|
* 20 MergeSource Select source (import or download)
|
|
* 25 MergeDownload Download from device
|
|
* 27 MergeChoose Select a ride from a list
|
|
*
|
|
* 30 MergeMode Select mode (join / merge)
|
|
*
|
|
* if merge (not join)
|
|
* 40 MergeSelect Select series to combine
|
|
* 50 MergeStrategy Select strategy for aligning data
|
|
* 60 MergeAdjust Manually adjust alignment
|
|
*
|
|
* 70 MergeConfirm Confirm and Save away
|
|
*
|
|
*---------------------------------------------------------------------*/
|
|
|
|
MergeActivityWizard::MergeActivityWizard(Context *context) : QWizard(context->mainWindow), context(context)
|
|
{
|
|
#ifdef Q_OS_MAC
|
|
setWizardStyle(QWizard::ModernStyle);
|
|
#endif
|
|
setWindowTitle(tr("Combine Activities"));
|
|
|
|
HelpWhatsThis *help = new HelpWhatsThis(this);
|
|
this->setWhatsThis(help->getWhatsThisText(HelpWhatsThis::MenuBar_Activity_CombineRides));
|
|
|
|
setFixedHeight(530);
|
|
setFixedWidth(550);
|
|
|
|
// initialise before setRide since it checks
|
|
// to see if memory needs to be freed first
|
|
ride1 = ride2 = NULL;
|
|
combinedItem = new RideItem(NULL, context);
|
|
combined = NULL;
|
|
|
|
// current ride
|
|
current = const_cast<RideItem*>(context->currentRideItem());
|
|
recIntSecs= current->ride()->recIntSecs();
|
|
setRide(&ride1, current->ride());
|
|
|
|
// default to merge not join
|
|
// and merge on device clocks
|
|
mode = 0;
|
|
strategy = 0;
|
|
|
|
// 5 step process, although Conflict may be skipped
|
|
setPage(10, new MergeWelcome(this));
|
|
setPage(20, new MergeSource(this));
|
|
setPage(25, new MergeDownload(this));
|
|
setPage(27, new MergeChoose(this));
|
|
setPage(30, new MergeMode(this));
|
|
setPage(40, new MergeSelect(this));
|
|
setPage(50, new MergeStrategy(this));
|
|
setPage(60, new MergeAdjust(this)); // might need to rename to adjust
|
|
setPage(1000, new MergeConfirm(this));
|
|
}
|
|
|
|
void
|
|
MergeActivityWizard::setRide(RideFile **here, RideFile *with)
|
|
{
|
|
// wipe current
|
|
if (*here) delete *here;
|
|
|
|
// set with new resampled / filled
|
|
// you may be tempted to optimise this out
|
|
// but by cloning a working copy like this
|
|
// we start with a clean ride and no derived
|
|
// data to 'pollute' the process
|
|
if (with) *here = with->resample(recIntSecs);
|
|
else *here = NULL;
|
|
}
|
|
|
|
void
|
|
MergeActivityWizard::analyse()
|
|
{
|
|
// looking at the parameters determine the offset
|
|
// default to align left if all else fails !
|
|
offset1 = offset2 = 0;
|
|
|
|
switch(strategy) {
|
|
|
|
case 0: // align on the time of day
|
|
// assumes the recording devices have
|
|
// a clock that is well synchronised
|
|
{
|
|
double diff = ride1->startTime().toMSecsSinceEpoch() - ride2->startTime().toMSecsSinceEpoch();
|
|
int samples = (diff/recIntSecs)/1000.0f;
|
|
|
|
if (samples < 0) { // ride2 was later than ride 1
|
|
offset2 = abs(samples);
|
|
offset1 = 0;
|
|
} else { // ride1 was later than ride 2
|
|
offset1 = samples;
|
|
offset2 = 0;
|
|
}
|
|
|
|
// but wait, is that too long ?
|
|
if (offset1 > ride1->dataPoints().count() ||
|
|
offset2 > ride2->dataPoints().count()) {
|
|
|
|
// fallback to align same time
|
|
offset1 = offset2 = 0;
|
|
}
|
|
//qDebug()<<"clocks make ride1 offset="<<offset1<<"and ride2 offset="<<offset2;
|
|
|
|
}
|
|
break;
|
|
|
|
case 1: // align on shared series
|
|
// using a shotgun algorithm
|
|
{
|
|
// calculate the R2 fit using the current offset
|
|
// for the first shared series
|
|
RideFile *base = ride1;
|
|
RideFile *fit = ride2;
|
|
|
|
// always fit smaller to larger
|
|
int diff = ride1->dataPoints().count() - ride2->dataPoints().count();
|
|
if (diff < 0) { // ride2 has more points
|
|
base=ride2;
|
|
fit= ride1;
|
|
} else { // ride1 has more points
|
|
base=ride1;
|
|
fit=ride2;
|
|
}
|
|
|
|
double bestFit=0.0;
|
|
int offsetFit=0;
|
|
RideFile::SeriesType bestSeries=RideFile::none;
|
|
|
|
QMapIterator<RideFile::SeriesType, QCheckBox *> i(rightSeries);
|
|
while(i.hasNext()) {
|
|
i.next();
|
|
if (i.key() != RideFile::km && leftSeries.value(i.key(), NULL) != NULL) {
|
|
|
|
// for each shared series look for best fit
|
|
RideFile::SeriesType shared = i.key();
|
|
|
|
double bestR2 = 0.0f;
|
|
int bestOffset = 0;
|
|
|
|
// no more than shifting by a third of the ride backwards or forwards
|
|
for(int offset=-1 * (base->dataPoints().count()/3);
|
|
offset<base->dataPoints().count()/3;
|
|
offset++) {
|
|
|
|
double SStot=0.0f, SSres=0.0f;
|
|
double mean =0.0f;
|
|
int count=0;
|
|
|
|
for(int i=0; (i+offset)<base->dataPoints().count(); i++) {
|
|
if ((i+offset)>0) {
|
|
mean += base->dataPoints()[i+offset]->value(shared);
|
|
}
|
|
count++;
|
|
}
|
|
mean /= double(count);
|
|
|
|
for(int i=0; (i+offset)<base->dataPoints().count() && i<fit->dataPoints().count(); i++) {
|
|
if((i+offset)>0) {
|
|
SSres += pow(base->dataPoints()[i+offset]->value(shared) - fit->dataPoints()[i]->value(shared), 2);
|
|
SStot += pow(base->dataPoints()[i+offset]->value(shared) - mean, 2);
|
|
}
|
|
}
|
|
|
|
double R2= 1.0f - (SSres/SStot);
|
|
if (R2 > bestR2) {
|
|
bestR2= R2;
|
|
bestOffset=offset;
|
|
}
|
|
}
|
|
|
|
// is this a better fit ?
|
|
if (bestR2 > bestFit) {
|
|
bestFit=bestR2;
|
|
offsetFit=bestOffset;
|
|
bestSeries=shared;
|
|
Q_UNUSED(bestSeries); // shutup compiler when qDebugs commented out below
|
|
}
|
|
//qDebug()<<"best R2="<<bestR2<<"at best offset"<<bestOffset<<"with series"<<fit->seriesName(shared);
|
|
}
|
|
}
|
|
//qDebug()<<"THEREFORE: best R2="<<bestFit<<"at best offset"<<offsetFit<<"with series"<<ride1->seriesName(bestSeries);
|
|
|
|
// so lets turn that into an offset for ride1 and ride2
|
|
if (bestFit > MINIMUM_R2_FIT) {
|
|
if (diff <0) { // ride2 was the base
|
|
if (offsetFit<0) {
|
|
offset2=offsetFit * -1;
|
|
offset1=0;
|
|
} else {
|
|
offset1=offsetFit;
|
|
offset2=0;
|
|
}
|
|
} else {
|
|
if (offsetFit<0) {
|
|
offset1=offsetFit * -1;
|
|
offset2=0;
|
|
} else {
|
|
offset2=offsetFit;
|
|
offset1=0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 2: // align begin
|
|
offset1=0;
|
|
offset2=0;
|
|
break;
|
|
|
|
case 3: // align end
|
|
int offset = ride1->dataPoints().count() - ride2->dataPoints().count();
|
|
if (offset < 0) {
|
|
offset1 = abs(offset);
|
|
offset2 = 0;
|
|
} else {
|
|
offset2 = abs(offset);
|
|
offset1 = 0;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
void
|
|
MergeActivityWizard::combine()
|
|
{
|
|
// and build a new one
|
|
combined = new RideFile(ride1);
|
|
combinedItem->setRide(combined); // will delete old one
|
|
|
|
// create a combined ride applying the parameters
|
|
// from the wizard for join or merge
|
|
|
|
if (mode == 1) { // JOIN
|
|
|
|
// easy peasy -- loop through one then the other !
|
|
RideFilePoint *lp = NULL;
|
|
|
|
foreach(RideFilePoint *p, ride1->dataPoints()) {
|
|
combined->appendPoint(*p);
|
|
lp = p;
|
|
}
|
|
|
|
// now add the data from the second one!
|
|
double distanceOffset=0;
|
|
double timeOffset=0;
|
|
bool first=true;
|
|
|
|
foreach(RideFilePoint *p, ride2->dataPoints()) {
|
|
if (first) {
|
|
|
|
if (lp) {
|
|
distanceOffset = lp->km + p->km;
|
|
timeOffset = lp->secs + p->secs + recIntSecs;
|
|
}
|
|
first = false;
|
|
}
|
|
|
|
RideFilePoint add = *p;
|
|
add.secs += timeOffset;
|
|
add.km += distanceOffset;
|
|
|
|
combined->appendPoint(add);
|
|
}
|
|
|
|
// any intervals with a number name? find the last
|
|
int intervalN=0;
|
|
foreach(RideFileInterval *interval, ride1->intervals()) {
|
|
int x = interval->name.toInt();
|
|
if (interval->name == QString("%1").arg(x)) {
|
|
if (x > intervalN) intervalN = x;
|
|
}
|
|
}
|
|
|
|
// now run through the intervals for the second ride
|
|
// and add them but renumber any intervals that are just numbers
|
|
foreach(RideFileInterval *interval, ride2->intervals()) {
|
|
int x = interval->name.toInt();
|
|
if (interval->name == QString("%1").arg(x)) {
|
|
interval->name = QString("%1").arg(x+intervalN);
|
|
}
|
|
combined->addInterval(interval->type,
|
|
interval->start + timeOffset,
|
|
interval->stop + timeOffset,
|
|
interval->name);
|
|
}
|
|
|
|
} else { // MERGE
|
|
|
|
RideFilePoint last;
|
|
|
|
for (int i=0; i<ride1->dataPoints().count() + offset1 ||
|
|
i<ride2->dataPoints().count() + offset2; i++) {
|
|
|
|
// fresh point
|
|
RideFilePoint add;
|
|
add.secs = i * recIntSecs;
|
|
add.km = last.km; // if not getting copied at least stay in same place!
|
|
|
|
// fold in ride 1 values
|
|
if (offset1 <= i && i < ride1->dataPoints().count()+offset1) {
|
|
|
|
RideFilePoint source = *(ride1->dataPoints()[i-offset1]);
|
|
|
|
// copy across the data we want
|
|
QMapIterator<RideFile::SeriesType,QCheckBox*> i(leftSeries);
|
|
while(i.hasNext()) {
|
|
i.next();
|
|
// we want this series !
|
|
if (i.value()->isChecked()) {
|
|
add.setValue(i.key(), source.value(i.key()));
|
|
}
|
|
}
|
|
}
|
|
|
|
// fold in ride 2 values
|
|
if (offset2 <= i && i < ride2->dataPoints().count()+offset2) {
|
|
|
|
RideFilePoint source = *(ride2->dataPoints()[i-offset2]);
|
|
|
|
// copy across the data we want
|
|
QMapIterator<RideFile::SeriesType,QCheckBox*> i(rightSeries);
|
|
while(i.hasNext()) {
|
|
i.next();
|
|
// we want this series !
|
|
if (i.value()->isChecked()) {
|
|
add.setValue(i.key(), source.value(i.key()));
|
|
}
|
|
}
|
|
}
|
|
|
|
combined->appendPoint(add);
|
|
last = add;
|
|
}
|
|
|
|
// now realign the intervals, first we need to
|
|
// clear what we already have in combined
|
|
combined->clearIntervals();
|
|
|
|
// run through what we got then
|
|
foreach(RideFileInterval *interval, ride1->intervals()) {
|
|
combined->addInterval(interval->type,
|
|
interval->start + offset1,
|
|
interval->stop + offset1,
|
|
interval->name);
|
|
}
|
|
// run through what we got then
|
|
foreach(RideFileInterval *interval, ride2->intervals()) {
|
|
combined->addInterval(interval->type,
|
|
interval->start + offset2,
|
|
interval->stop + offset2,
|
|
interval->name);
|
|
}
|
|
}
|
|
}
|
|
|
|
/*----------------------------------------------------------------------
|
|
* Wizard Pages
|
|
*--------------------------------------------------------------------*/
|
|
|
|
// welcome
|
|
MergeWelcome::MergeWelcome(MergeActivityWizard *parent) : QWizardPage(parent), wizard(parent)
|
|
{
|
|
setTitle(tr("Combine Activities"));
|
|
setSubTitle(tr("Lets get started"));
|
|
|
|
QVBoxLayout *layout = new QVBoxLayout;
|
|
setLayout(layout);
|
|
|
|
QLabel *label = new QLabel(tr("This wizard will help you combine data with "
|
|
"the currently selected activity.\n\n"
|
|
"You will be able to import or download data before "
|
|
"merging or joining the data and manually adjusting "
|
|
"the alignment of data series before it is saved."));
|
|
label->setWordWrap(true);
|
|
|
|
layout->addWidget(label);
|
|
layout->addStretch();
|
|
}
|
|
|
|
//Select source for merge
|
|
MergeSource::MergeSource(MergeActivityWizard *parent) : QWizardPage(parent), wizard(parent)
|
|
{
|
|
setTitle(tr("Select Source"));
|
|
setSubTitle(tr("Where is the data you want to combine ?"));
|
|
|
|
QVBoxLayout *layout = new QVBoxLayout;
|
|
setLayout(layout);
|
|
|
|
mapper = new QSignalMapper(this);
|
|
connect(mapper, SIGNAL(mapped(QString)), this, SLOT(clicked(QString)));
|
|
|
|
// select a file
|
|
QCommandLinkButton *p = new QCommandLinkButton(tr("Import from a File"),
|
|
tr("Import and combine from a file on your hard disk"
|
|
" or device mounted as a USB disk to combine with "
|
|
"the current activity."), this);
|
|
connect(p, SIGNAL(clicked()), mapper, SLOT(map()));
|
|
mapper->setMapping(p, "import");
|
|
layout->addWidget(p);
|
|
|
|
// download from a device
|
|
p = new QCommandLinkButton(tr("Download from Device"),
|
|
tr("Download data from a serial port device such as a Moxy Muscle Oxygen Monitor or"
|
|
" bike computer to combine with the current activity."), this);
|
|
connect(p, SIGNAL(clicked()), mapper, SLOT(map()));
|
|
mapper->setMapping(p, "download");
|
|
layout->addWidget(p);
|
|
|
|
// select from rides on same day
|
|
p = new QCommandLinkButton(tr("Existing Activity"),
|
|
tr("Combine data from an activity that has already been imported or downloaded into"
|
|
" GoldenCheetah. Selecting from a list of all available activities. "), this);
|
|
connect(p, SIGNAL(clicked()), mapper, SLOT(map()));
|
|
mapper->setMapping(p, "choose");
|
|
layout->addWidget(p);
|
|
|
|
label = new QLabel("", this);
|
|
layout->addWidget(label);
|
|
|
|
next = 30;
|
|
setFinalPage(false);
|
|
}
|
|
|
|
void
|
|
MergeSource::initializePage()
|
|
{
|
|
}
|
|
|
|
|
|
void
|
|
MergeSource::clicked(QString p)
|
|
{
|
|
// reset -- particularly since we might get here from
|
|
// other pages hitting 'Back'
|
|
initializePage();
|
|
|
|
// move on if we imported one
|
|
if (p == "import" && importFile() == true) {
|
|
next = 30;
|
|
wizard->next();
|
|
}
|
|
|
|
// move on if we downloaded one
|
|
if (p == "download") {
|
|
next = 25;
|
|
wizard->next();
|
|
}
|
|
|
|
// select from a list
|
|
if (p == "choose") {
|
|
next = 27;
|
|
wizard->next();
|
|
}
|
|
|
|
}
|
|
|
|
bool
|
|
MergeSource::importFile()
|
|
{
|
|
QVariant lastDirVar = appsettings->value(this, GC_SETTINGS_LAST_IMPORT_PATH);
|
|
QString lastDir = (lastDirVar != QVariant())
|
|
? lastDirVar.toString() : QDir::homePath();
|
|
|
|
const RideFileFactory &rff = RideFileFactory::instance();
|
|
QStringList suffixList = rff.suffixes();
|
|
suffixList.replaceInStrings(QRegExp("^"), "*.");
|
|
QStringList fileNames;
|
|
QStringList allFormats;
|
|
allFormats << QString(tr("All Supported Formats (%1)")).arg(suffixList.join(" "));
|
|
foreach(QString suffix, rff.suffixes())
|
|
allFormats << QString("%1 (*.%2)").arg(rff.description(suffix)).arg(suffix);
|
|
allFormats << tr("All files (*.*)");
|
|
fileNames = QFileDialog::getOpenFileNames(
|
|
this, tr("Import from File"), lastDir,
|
|
allFormats.join(";;"));
|
|
if (!fileNames.isEmpty()) {
|
|
lastDir = QFileInfo(fileNames.front()).absolutePath();
|
|
appsettings->setValue(GC_SETTINGS_LAST_IMPORT_PATH, lastDir);
|
|
QStringList fileNamesCopy = fileNames; // QT doc says iterate over a copy
|
|
return importFile(fileNamesCopy);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool
|
|
MergeSource::importFile(QList<QString> files)
|
|
{
|
|
// get fullpath name for processing
|
|
QFileInfo filename = QFileInfo(files[0]).absoluteFilePath();
|
|
|
|
QFile thisfile(files[0]);
|
|
QFileInfo thisfileinfo(files[0]);
|
|
QStringList errors;
|
|
|
|
if (thisfileinfo.exists() && thisfileinfo.isFile() && thisfileinfo.isReadable()) {
|
|
|
|
// is it one we understand ?
|
|
QStringList suffixList = RideFileFactory::instance().suffixes();
|
|
QRegExp suffixes(QString("^(%1)$").arg(suffixList.join("|")));
|
|
suffixes.setCaseSensitivity(Qt::CaseInsensitive);
|
|
|
|
if (suffixes.exactMatch(thisfileinfo.suffix())) {
|
|
|
|
RideFile *ride = RideFileFactory::instance().openRideFile(wizard->context, thisfile, errors);
|
|
|
|
// did it parse ok?
|
|
if (ride) {
|
|
|
|
wizard->setRide(&wizard->ride2, ride);
|
|
return true;
|
|
}
|
|
|
|
} else {
|
|
|
|
wizard->setRide(&wizard->ride2, NULL);
|
|
errors.append(tr("Error - Unknown file type"));
|
|
return false;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Download dialog embedded
|
|
MergeDownload::MergeDownload(MergeActivityWizard *parent) : QWizardPage(parent), wizard(parent)
|
|
{
|
|
setTitle(tr("Download Activity"));
|
|
setSubTitle(tr("Download Activity to Combine"));
|
|
|
|
QVBoxLayout *layout = new QVBoxLayout;
|
|
setLayout(layout);
|
|
|
|
// embed download dialog
|
|
QMdiArea *mdiarea = new QMdiArea(this);
|
|
layout->addWidget(mdiarea);
|
|
|
|
// get a download dialog -- embedded
|
|
DownloadRideDialog *download = new DownloadRideDialog(parent->context, true);
|
|
//download->setInputMode(QInputDialog.TextInput);
|
|
QMdiSubWindow *w = mdiarea->addSubWindow(download, Qt::FramelessWindowHint);
|
|
|
|
// maximize and no title bar etc
|
|
w->showMaximized();
|
|
layout->addStretch();
|
|
|
|
connect(download, SIGNAL(downloadStarts()), this, SLOT(downloadStarts()));
|
|
connect(download, SIGNAL(downloadEnds()), this, SLOT(downloadEnds()));
|
|
connect(download, SIGNAL(downloadFiles(QList<DeviceDownloadFile>)), this,
|
|
SLOT(downloadFiles(QList<DeviceDownloadFile>)));
|
|
}
|
|
|
|
void
|
|
MergeDownload::downloadFiles(QList<DeviceDownloadFile>files)
|
|
{
|
|
// Bingo ! only one ride so must be this one
|
|
// saves having to select from a pesky list
|
|
// XXX WE NEED TO UPDATE DOWNLOADRIDEDIALOG TO
|
|
// ALLOW SELECTION OF RIDES TO PROCESS AND
|
|
// ALSO MAKE IT LIMIT THIS TO ONE RIDE WHEN
|
|
// EMBEDDED IN MERGEDOWNLOAD ! XXX
|
|
if (files.count() == 1) {
|
|
|
|
QFile thisfile(files[0].name);
|
|
QStringList errors;
|
|
|
|
RideFileReader *reader = RideFileFactory::instance().readerForSuffix(files[0].extension);
|
|
if (!reader) return;
|
|
|
|
RideFile *ride = reader->openRideFile(thisfile, errors);
|
|
|
|
// did it parse ok?
|
|
if (ride) {
|
|
|
|
wizard->setRide(&wizard->ride2 ,ride);
|
|
next = 30;
|
|
wizard->next();
|
|
return;
|
|
} else {
|
|
|
|
wizard->setRide(&wizard->ride2, NULL);
|
|
errors.append(tr("Error - Unknown file type"));
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
void
|
|
MergeDownload::downloadStarts()
|
|
{
|
|
wizard->button(QWizard::BackButton)->setEnabled(false);
|
|
}
|
|
|
|
void
|
|
MergeDownload::downloadEnds()
|
|
{
|
|
wizard->button(QWizard::BackButton)->setEnabled(true);
|
|
}
|
|
|
|
void
|
|
MergeDownload::initializePage()
|
|
{
|
|
// might be needed for download ????
|
|
}
|
|
|
|
// Choose from the ride list
|
|
MergeChoose::MergeChoose(MergeActivityWizard *parent) : QWizardPage(parent), wizard(parent)
|
|
{
|
|
setTitle(tr("Choose an Activity"));
|
|
setSubTitle(tr("Choose an Existing activity to Combine"));
|
|
|
|
chosen = false;
|
|
|
|
QVBoxLayout *layout = new QVBoxLayout;
|
|
setLayout(layout);
|
|
|
|
files = new QTreeWidget;
|
|
files->headerItem()->setText(0, tr("Filename"));
|
|
files->headerItem()->setText(1, tr("Date"));
|
|
files->headerItem()->setText(2, tr("Time"));
|
|
|
|
files->setColumnCount(3);
|
|
files->setColumnWidth(0, 190); // filename
|
|
files->setColumnWidth(1, 95); // date
|
|
files->setColumnWidth(2, 90); // time
|
|
files->setSelectionMode(QAbstractItemView::SingleSelection);
|
|
files->setUniformRowHeights(true);
|
|
files->setIndentation(0);
|
|
|
|
// populate with each ride in the ridelist
|
|
foreach (RideItem *rideItem, wizard->context->athlete->rideCache->rides()) {
|
|
|
|
QTreeWidgetItem *add = new QTreeWidgetItem(files->invisibleRootItem());
|
|
add->setFlags(add->flags() & ~Qt::ItemIsEditable);
|
|
|
|
// we will wipe the original file
|
|
add->setText(0, rideItem->fileName);
|
|
add->setText(1, rideItem->dateTime.toString(tr("dd MMM yyyy")));
|
|
add->setText(2, rideItem->dateTime.toString("hh:mm:ss"));
|
|
}
|
|
|
|
layout->addWidget(files);
|
|
connect(files, SIGNAL(itemSelectionChanged()), this, SLOT(selected()));
|
|
}
|
|
|
|
void
|
|
MergeChoose::selected()
|
|
{
|
|
wizard->button(QWizard::BackButton)->setEnabled(true);
|
|
chosen = true;
|
|
next = 30;
|
|
emit completeChanged();
|
|
}
|
|
|
|
bool
|
|
MergeChoose::validatePage()
|
|
{
|
|
// make sure the currently selected ride has data
|
|
// and can be opened etc
|
|
QString filename = "none";
|
|
|
|
// which one is selected ?
|
|
if (files->currentItem()) filename=files->currentItem()->text(0);
|
|
|
|
// open it..
|
|
QStringList errors;
|
|
QList<RideFile*> rides;
|
|
QFile thisfile(QString(wizard->context->athlete->home->activities().absolutePath()+"/"+filename));
|
|
RideFile *ride = RideFileFactory::instance().openRideFile(wizard->context, thisfile, errors, &rides);
|
|
|
|
if (ride && ride->dataPoints().count()) {
|
|
|
|
wizard->setRide(&wizard->ride2, ride);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void
|
|
MergeChoose::initializePage()
|
|
{
|
|
}
|
|
|
|
//Select mode for merge
|
|
MergeMode::MergeMode(MergeActivityWizard *parent) : QWizardPage(parent), wizard(parent)
|
|
{
|
|
setTitle(tr("Select Mode"));
|
|
setSubTitle(tr("How would you like to combine the data ?"));
|
|
|
|
QVBoxLayout *layout = new QVBoxLayout;
|
|
setLayout(layout);
|
|
|
|
mapper = new QSignalMapper(this);
|
|
connect(mapper, SIGNAL(mapped(QString)), this, SLOT(clicked(QString)));
|
|
|
|
// merge
|
|
QCommandLinkButton *p = new QCommandLinkButton(tr("Merge Data to add another data series"),
|
|
tr("Merge data series from one recording into the current activity"
|
|
" where different types of data (e.g. O2 data from a Moxy) have been "
|
|
" recorded by different devices. Taking care to align the data in time."), this);
|
|
connect(p, SIGNAL(clicked()), mapper, SLOT(map()));
|
|
mapper->setMapping(p, "merge");
|
|
layout->addWidget(p);
|
|
|
|
// join
|
|
p = new QCommandLinkButton(tr("Join Data to form a longer activity"),
|
|
tr("Append the data to the end of the current activity "
|
|
" to create a longer activity that was recorded in multiple"
|
|
" parts."), this);
|
|
connect(p, SIGNAL(clicked()), mapper, SLOT(map()));
|
|
mapper->setMapping(p, "join");
|
|
layout->addWidget(p);
|
|
|
|
label = new QLabel("", this);
|
|
layout->addWidget(label);
|
|
|
|
next = 40;
|
|
setFinalPage(false);
|
|
}
|
|
|
|
void
|
|
MergeMode::initializePage()
|
|
{
|
|
}
|
|
|
|
|
|
void
|
|
MergeMode::clicked(QString p)
|
|
{
|
|
// reset -- particularly since we might get here from
|
|
// other pages hitting 'Back'
|
|
initializePage();
|
|
|
|
if (p == "join") {
|
|
wizard->mode = 1; // join is easy !
|
|
wizard->combine(); // analyse not required just combined them !
|
|
next = 1000;
|
|
} else {
|
|
// where to next ?
|
|
wizard->mode = 0; // merge ...
|
|
next = 40;
|
|
}
|
|
wizard->next();
|
|
}
|
|
|
|
//Select merge strategy
|
|
MergeStrategy::MergeStrategy(MergeActivityWizard *parent) : QWizardPage(parent), wizard(parent)
|
|
{
|
|
setTitle(tr("Select Strategy"));
|
|
setSubTitle(tr("How should we align the data ?"));
|
|
|
|
QVBoxLayout *layout = new QVBoxLayout;
|
|
setLayout(layout);
|
|
|
|
mapper = new QSignalMapper(this);
|
|
connect(mapper, SIGNAL(mapped(QString)), this, SLOT(clicked(QString)));
|
|
|
|
// time
|
|
QCommandLinkButton *p = new QCommandLinkButton(tr("Align using start time"),
|
|
tr("Align the data from the two activities based upon the start time "
|
|
"for the activities. This will work well if the devices used to "
|
|
"record the data have their clocks synchronised / close to each other."), this);
|
|
connect(p, SIGNAL(clicked()), mapper, SLOT(map()));
|
|
mapper->setMapping(p, "time");
|
|
layout->addWidget(p);
|
|
|
|
// shared data
|
|
shared = new QCommandLinkButton(tr("Align using shared data series"),
|
|
tr("If the two activities both contain the same data series, for example "
|
|
"where both devices recorded cadence or perhaps HR, then we can align the other "
|
|
"data series in time by matching the peaks and troughs in the shared data."), this);
|
|
connect(shared, SIGNAL(clicked()), mapper, SLOT(map()));
|
|
mapper->setMapping(shared, "shared");
|
|
layout->addWidget(shared);
|
|
|
|
// start at same time
|
|
p = new QCommandLinkButton(tr("Align starting together"),
|
|
tr("Regardless of the timestamp on the activity, align with both "
|
|
"activities starting at the same time."), this);
|
|
connect(p, SIGNAL(clicked()), mapper, SLOT(map()));
|
|
mapper->setMapping(p, "left");
|
|
layout->addWidget(p);
|
|
|
|
// start at same time
|
|
p = new QCommandLinkButton(tr("Align ending together"),
|
|
tr("Regardless of the timestamp on the activity, align with both "
|
|
"activities ending at the same time."), this);
|
|
connect(p, SIGNAL(clicked()), mapper, SLOT(map()));
|
|
mapper->setMapping(p, "right");
|
|
layout->addWidget(p);
|
|
|
|
label = new QLabel("", this);
|
|
layout->addWidget(label);
|
|
|
|
next = 60;
|
|
setFinalPage(false);
|
|
}
|
|
|
|
void
|
|
MergeStrategy::initializePage()
|
|
{
|
|
// are there any shared data -- ignoring time and distance
|
|
bool hasShared = false;
|
|
QMapIterator<RideFile::SeriesType, QCheckBox *> i(wizard->rightSeries);
|
|
while(i.hasNext()) {
|
|
i.next();
|
|
if (i.key() != RideFile::km && wizard->leftSeries.value(i.key(), NULL) != NULL)
|
|
hasShared = true;
|
|
}
|
|
shared->setEnabled(hasShared);
|
|
}
|
|
|
|
|
|
void
|
|
MergeStrategy::clicked(QString p)
|
|
{
|
|
// reset -- particularly since we might get here from
|
|
// other pages hitting 'Back'
|
|
initializePage();
|
|
|
|
if (p == "time") {
|
|
wizard->strategy = 0;
|
|
} else if (p == "shared" ) {
|
|
// where to next ?
|
|
wizard->strategy = 1; // merge ...
|
|
} else if (p == "left" ) {
|
|
wizard->strategy = 2; // merge ...
|
|
} else if (p == "right" ) {
|
|
wizard->strategy = 3; // merge ...
|
|
}
|
|
|
|
// now run strategy and get on
|
|
wizard->analyse();
|
|
wizard->combine();
|
|
|
|
// lets do this thing !
|
|
next = 60;
|
|
wizard->next();
|
|
}
|
|
|
|
// Synchronise start of files
|
|
MergeAdjust::MergeAdjust(MergeActivityWizard *parent) : QWizardPage(parent), wizard(parent)
|
|
{
|
|
setTitle(tr("Adjust Alignment"));
|
|
setSubTitle(tr("Adjust merge alignment in time"));
|
|
|
|
// need more space on this page!
|
|
setContentsMargins(0,0,0,0);
|
|
|
|
// Plot files
|
|
QVBoxLayout *layout = new QVBoxLayout;
|
|
setLayout(layout);
|
|
layout->setSpacing(5);
|
|
layout->setContentsMargins(5,5,5,5);
|
|
|
|
spanSlider = new QxtSpanSlider(Qt::Horizontal, this);
|
|
spanSlider->setFocusPolicy(Qt::NoFocus);
|
|
spanSlider->setHandleMovementMode(QxtSpanSlider::NoOverlapping);
|
|
spanSlider->setLowerValue(0);
|
|
spanSlider->setUpperValue(15);
|
|
#ifdef Q_OS_MAC
|
|
// BUG in QMacStyle and painting of spanSlider
|
|
// so we use a plain style to avoid it, but only
|
|
// on a MAC, since win and linux are fine
|
|
#if QT_VERSION > 0x5000
|
|
QStyle *style = QStyleFactory::create("fusion");
|
|
#else
|
|
QStyle *style = QStyleFactory::create("Cleanlooks");
|
|
#endif
|
|
spanSlider->setStyle(style);
|
|
#endif
|
|
|
|
fullPlot = new AllPlot(this, NULL, wizard->context);
|
|
fullPlot->setPaintBrush(0);
|
|
#ifdef Q_OS_MAC
|
|
fullPlot->setFixedHeight(300);
|
|
#else
|
|
fullPlot->setFixedHeight(300);
|
|
#endif
|
|
fullPlot->setHighlightIntervals(false);
|
|
static_cast<QwtPlotCanvas*>(fullPlot->canvas())->setBorderRadius(0);
|
|
fullPlot->setWantAxis(false, true);
|
|
QPalette pal = palette();
|
|
fullPlot->axisWidget(QwtPlot::xBottom)->setPalette(pal);
|
|
|
|
layout->addWidget(spanSlider);
|
|
layout->addWidget(fullPlot);
|
|
layout->addStretch();
|
|
|
|
QLabel *adjust = new QLabel(tr("Adjust:"));
|
|
adjustSlider = new QSlider(Qt::Horizontal, this);
|
|
reset = new QPushButton(tr("Reset"));
|
|
|
|
QHBoxLayout *hl = new QHBoxLayout;
|
|
hl->addWidget(adjust);
|
|
hl->addWidget(adjustSlider);
|
|
hl->addWidget(reset);
|
|
layout->addLayout(hl);
|
|
layout->addStretch();
|
|
|
|
connect(spanSlider, SIGNAL(lowerPositionChanged(int)), this, SLOT(zoomChanged()));
|
|
connect(spanSlider, SIGNAL(upperPositionChanged(int)), this, SLOT(zoomChanged()));
|
|
|
|
connect(reset, SIGNAL(clicked()), this, SLOT(resetClicked()));
|
|
connect(adjustSlider, SIGNAL(valueChanged(int)), this, SLOT(offsetChanged()));
|
|
}
|
|
|
|
void
|
|
MergeAdjust::initializePage()
|
|
{
|
|
// remember so we can reset
|
|
offset1 = wizard->offset1;
|
|
offset2 = wizard->offset2;
|
|
|
|
// setup plot
|
|
fullPlot->setDataFromRide(wizard->combinedItem, QList<UserData*>());
|
|
spanSlider->setMinimum(0);
|
|
spanSlider->setMaximum(wizard->combined->dataPoints().last()->secs);
|
|
spanSlider->setLowerValue(spanSlider->minimum());
|
|
spanSlider->setUpperValue(spanSlider->maximum());
|
|
zoomChanged();
|
|
|
|
// what to show?
|
|
fullPlot->setShow(RideFile::none, false); // switch all off
|
|
|
|
// now add in what we got
|
|
QMapIterator<RideFile::SeriesType, QCheckBox *> i(wizard->rightSeries);
|
|
while(i.hasNext()) {
|
|
i.next();
|
|
if (i.value()->isChecked())
|
|
fullPlot->setShow(i.key(), true);
|
|
}
|
|
QMapIterator<RideFile::SeriesType, QCheckBox *> j(wizard->leftSeries);
|
|
while(j.hasNext()) {
|
|
j.next();
|
|
if (j.value()->isChecked())
|
|
fullPlot->setShow(j.key(), true);
|
|
}
|
|
|
|
// setup adjuster
|
|
adjustSlider->setMinimum(-1 * wizard->combined->dataPoints().count());
|
|
adjustSlider->setMaximum(wizard->combined->dataPoints().count());
|
|
adjustSlider->setValue(wizard->offset2 - wizard->offset1);
|
|
}
|
|
|
|
void
|
|
MergeAdjust::offsetChanged()
|
|
{
|
|
if (adjustSlider->value() < 0) {
|
|
wizard->offset1 = adjustSlider->value() * -1;
|
|
wizard->offset2 = 0;
|
|
} else {
|
|
wizard->offset1 = 0;
|
|
wizard->offset2 = adjustSlider->value();
|
|
}
|
|
wizard->combine();
|
|
|
|
fullPlot->setDataFromRide(wizard->combinedItem, QList<UserData*>());
|
|
|
|
bool rescale = (spanSlider->minimum() == spanSlider->lowerValue() &&
|
|
spanSlider->maximum() == spanSlider->upperValue());
|
|
|
|
spanSlider->setMinimum(0);
|
|
spanSlider->setMaximum(wizard->combined->dataPoints().last()->secs);
|
|
|
|
if (rescale) {
|
|
spanSlider->setLowerValue(spanSlider->minimum());
|
|
spanSlider->setUpperValue(spanSlider->maximum());
|
|
zoomChanged();
|
|
}
|
|
}
|
|
|
|
void
|
|
MergeAdjust::resetClicked()
|
|
{
|
|
wizard->offset1 = offset1;
|
|
wizard->offset2 = offset2;
|
|
initializePage();
|
|
}
|
|
|
|
|
|
void
|
|
MergeAdjust::zoomChanged()
|
|
{
|
|
fullPlot->setAxisScale(QwtPlot::xBottom, spanSlider->lowerValue()/60.0f, spanSlider->upperValue()/60.0f);
|
|
fullPlot->replot();
|
|
}
|
|
|
|
// select
|
|
MergeSelect::MergeSelect(MergeActivityWizard *parent) : QWizardPage(parent), wizard(parent)
|
|
{
|
|
setTitle(tr("Merge Data Series"));
|
|
setSubTitle(tr("Select the series to merge together"));
|
|
|
|
QVBoxLayout *mainlayout = new QVBoxLayout(this);
|
|
|
|
QFont def;
|
|
def.setWeight(QFont::Bold);
|
|
|
|
QHBoxLayout *names = new QHBoxLayout;
|
|
leftName = new QLabel("Current Selection", this);
|
|
rightName = new QLabel("right", this);
|
|
leftName->setFixedHeight(20);
|
|
leftName->setFont(def);
|
|
leftName->setAlignment(Qt::AlignHCenter | Qt::AlignVCenter);
|
|
rightName->setFixedHeight(20);
|
|
rightName->setAlignment(Qt::AlignHCenter | Qt::AlignVCenter);
|
|
rightName->setFont(def);
|
|
names->addWidget(leftName);
|
|
names->addWidget(rightName);
|
|
mainlayout->addLayout(names);
|
|
|
|
QHBoxLayout *leftright = new QHBoxLayout;
|
|
mainlayout->addLayout(leftright);
|
|
|
|
QScrollArea *left = new QScrollArea(this);
|
|
left->setAutoFillBackground(false);
|
|
left->setWidgetResizable(true);
|
|
left->setContentsMargins(0,0,0,0);
|
|
QScrollArea *right = new QScrollArea(this);
|
|
right->setAutoFillBackground(false);
|
|
right->setWidgetResizable(true);
|
|
right->setContentsMargins(0,0,0,0);
|
|
|
|
leftright->addWidget(left);
|
|
leftright->addWidget(right);
|
|
|
|
// checkboxes on the left
|
|
QWidget *leftWidget = new QWidget(this);
|
|
leftWidget->setContentsMargins(0,0,0,0);
|
|
leftLayout = new QVBoxLayout(leftWidget);
|
|
leftLayout->setContentsMargins(0,0,0,0);
|
|
leftLayout->setSpacing(0);
|
|
|
|
// checkboxes on the right
|
|
QWidget *rightWidget = new QWidget(this);
|
|
rightWidget->setContentsMargins(0,0,0,0);
|
|
rightLayout = new QVBoxLayout(rightWidget);
|
|
rightLayout->setContentsMargins(0,0,0,0);
|
|
rightLayout->setSpacing(0);
|
|
|
|
left->setWidget(leftWidget);
|
|
right->setWidget(rightWidget);
|
|
}
|
|
|
|
void
|
|
MergeSelect::initializePage()
|
|
{
|
|
rightName->setText(QString("%1 (%2)").arg(wizard->ride2->startTime().toString("d MMM yy hh:mm:ss"))
|
|
.arg(wizard->ride2->deviceType()));
|
|
|
|
// create a checkbox for each series present in
|
|
QMapIterator<RideFile::SeriesType, QCheckBox*> i(wizard->leftSeries);
|
|
while(i.hasNext()) {
|
|
i.next();
|
|
delete i.value();
|
|
}
|
|
QMapIterator<RideFile::SeriesType, QCheckBox*> j(wizard->rightSeries);
|
|
while(j.hasNext()) {
|
|
j.next();
|
|
delete j.value();
|
|
}
|
|
wizard->leftSeries.clear();
|
|
wizard->rightSeries.clear();
|
|
|
|
// get rid of the stretch
|
|
leftLayout->takeAt(0);
|
|
rightLayout->takeAt(0);
|
|
|
|
for(int i=0; i < static_cast<int>(RideFile::none); i++) {
|
|
|
|
// save us casting all the time
|
|
RideFile::SeriesType series = static_cast<RideFile::SeriesType>(i);
|
|
|
|
// the one thing we don't select !
|
|
if (series == RideFile::secs) continue;
|
|
|
|
bool lefthas = false;
|
|
if (wizard->ride1->isDataPresent(series)) {
|
|
lefthas = true;
|
|
|
|
QCheckBox *add = new QCheckBox(wizard->ride1->seriesName(series));
|
|
add->setChecked(true);
|
|
add->setEnabled(false);
|
|
|
|
leftLayout->addWidget(add);
|
|
wizard->leftSeries.insert(series, add);
|
|
}
|
|
|
|
if (wizard->ride2->isDataPresent(series)) {
|
|
QCheckBox *add = new QCheckBox(wizard->ride2->seriesName(series));
|
|
add->setChecked(!lefthas);
|
|
|
|
rightLayout->addWidget(add);
|
|
wizard->rightSeries.insert(series, add);
|
|
|
|
connect(add, SIGNAL(stateChanged(int)), this, SLOT(checkboxes()));
|
|
}
|
|
}
|
|
|
|
leftLayout->addStretch();
|
|
rightLayout->addStretch();
|
|
}
|
|
|
|
void
|
|
MergeSelect::checkboxes()
|
|
{
|
|
// as we turn on/off right side checkboxes we need to
|
|
// turn off/on left side checkboxes
|
|
QMapIterator<RideFile::SeriesType,QCheckBox*> i(wizard->rightSeries);
|
|
while(i.hasNext()) {
|
|
|
|
i.next();
|
|
QCheckBox *left=NULL;
|
|
if ((left=wizard->leftSeries.value(i.key(), NULL)) != NULL) {
|
|
left->setChecked(!i.value()->isChecked());
|
|
}
|
|
}
|
|
}
|
|
|
|
MergeConfirm::MergeConfirm(MergeActivityWizard *parent) : QWizardPage(parent), wizard(parent)
|
|
{
|
|
setTitle(tr("Confirm"));
|
|
setSubTitle(tr("Complete and Save"));
|
|
|
|
QVBoxLayout *layout = new QVBoxLayout;
|
|
setLayout(layout);
|
|
|
|
QLabel *label = new QLabel(tr("Press Finish to update the current activity with "
|
|
" the combined data.\n\n"
|
|
"The changes will be saved and cannot be undone.\n\n"
|
|
"If you press continue the activity will be saved, if you "
|
|
"do not want to continue either go back and change "
|
|
"the settings or press cancel to abort."));
|
|
label->setWordWrap(true);
|
|
|
|
layout->addWidget(label);
|
|
layout->addStretch();
|
|
}
|
|
|
|
bool
|
|
MergeConfirm::validatePage()
|
|
{
|
|
// We are done -- recalculate derived series, save and mark done
|
|
wizard->current->notifyRideDataChanged();
|
|
wizard->combined->recalculateDerivedSeries(true);
|
|
wizard->current->setRide(wizard->combined);
|
|
wizard->context->mainWindow->saveSilent(wizard->context, wizard->current);
|
|
wizard->current->setDirty(false); // lose changes
|
|
|
|
return true;
|
|
}
|